dsh-tower 1.3.0 → 2.0.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 +109 -0
- package/CONTRIBUTING.md +1 -1
- package/README.en.md +44 -8
- package/README.md +42 -8
- package/cordis.patch.yml +11 -1
- package/docs/README.md +10 -5
- package/docs/architecture/overview.md +13 -9
- package/docs/faq.md +45 -2
- package/docs/guide/hot-reload.md +189 -0
- package/docs/guide/install-upgrade-migration.md +11 -0
- package/docs/guide/quickstart.md +33 -1
- package/docs/guide/scenarios-and-philosophy.md +309 -0
- package/docs/operations/runbook.md +23 -0
- package/docs/operations/security.md +23 -0
- package/docs/reference/blockers.md +10 -2
- package/docs/reference/config.md +65 -2
- package/docs/reference/doctor.md +33 -0
- package/docs/reference/errors.md +50 -0
- package/docs/reference/events.md +45 -0
- package/docs/reference/operations.md +55 -0
- package/docs/reference/testing.md +61 -0
- package/docs/reference/tools.md +65 -23
- package/docs/zh/README.md +60 -0
- package/docs/zh/faq.md +81 -0
- package/docs/zh/guide/hot-reload.md +178 -0
- package/docs/zh/guide/install-upgrade-migration.md +57 -0
- package/docs/zh/guide/quickstart.md +96 -0
- package/docs/zh/guide/scenarios-and-philosophy.md +211 -0
- package/docs/zh/operations/security.md +42 -0
- package/docs/zh/reference/blockers.md +62 -0
- package/docs/zh/reference/config.md +113 -0
- package/docs/zh/reference/doctor.md +31 -0
- package/docs/zh/reference/errors.md +52 -0
- package/docs/zh/reference/events.md +46 -0
- package/docs/zh/reference/operations.md +50 -0
- package/docs/zh/reference/testing.md +60 -0
- package/docs/zh/reference/tools.md +75 -0
- package/index.js +261 -13
- package/lib/batch.js +578 -0
- package/lib/config.js +275 -9
- package/lib/doctor.js +171 -0
- package/lib/errors.js +111 -0
- package/lib/hmr.js +196 -0
- package/lib/leases.js +317 -0
- package/lib/legacy.js +413 -0
- package/lib/modeStore.js +28 -1
- package/lib/store.js +24 -1
- package/lib/tests.js +628 -0
- package/lib/tools.js +1566 -184
- package/lib/tower.js +986 -103
- package/lib/workspaceLock.js +117 -0
- package/lib/worktreeOwnership.js +166 -0
- package/package.json +4 -1
- package/reviewer.js +1 -0
- package/scripts/check-docs.mjs +135 -0
- package/scripts/enable-dev-hmr.mjs +347 -0
- package/skills/tower-review.md +4 -2
- package/skills/tower-worker-protocol.md +3 -1
- package/worker.js +4 -2
package/lib/tools.js
CHANGED
|
@@ -1,22 +1,489 @@
|
|
|
1
|
-
// Tower tools: init, plan, mission, status, spawn, roster, adopt.
|
|
2
|
-
import { Tower } from './tower.js'
|
|
1
|
+
// Tower tools: init, plan, mission, status, spawn, roster, adopt, findings.
|
|
2
|
+
import { Tower, ROSTER_INACTIVE_STATES } from './tower.js'
|
|
3
|
+
import { TowerToolError, classifyError } from './errors.js'
|
|
4
|
+
import { git, resolveRepo, hasAnyCommit } from './git.js'
|
|
3
5
|
import { runtimeConfig } from './config.js'
|
|
6
|
+
import { createHmrBridge } from './hmr.js'
|
|
7
|
+
import { readDiscoveryConfig, normalizeDiscovery, runDiscovery } from './tests.js'
|
|
8
|
+
import { validateCreate as validateBatchCreate, validateClose as validateBatchClose, summarize as summarizeBatch, BatchError } from './batch.js'
|
|
9
|
+
import { acquireLease, releaseLease, inspectLeases } from './leases.js'
|
|
10
|
+
import { importLegacySnapshot, validateLegacySnapshot, validateLegacyMap } from './legacy.js'
|
|
11
|
+
import { runDoctor } from './doctor.js'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import fsp from 'node:fs/promises'
|
|
14
|
+
import crypto from 'node:crypto'
|
|
4
15
|
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
16
|
+
const S = {
|
|
17
|
+
str: { type: 'string' },
|
|
18
|
+
int: { type: 'integer' },
|
|
19
|
+
bool: { type: 'boolean' },
|
|
20
|
+
arr: (items) => ({ type: 'array', items }),
|
|
21
|
+
obj: (properties = {}, required = []) => ({ type: 'object', properties, required, additionalProperties: true }),
|
|
22
|
+
any: {},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const blockerSchema = S.obj({ code: S.str, detail: S.str, cleanup: S.str, timeout: S.bool }, ['code', 'detail'])
|
|
26
|
+
const worktreeSchema = S.obj({ path: S.str, branch: S.str, baseCommit: S.str })
|
|
27
|
+
const missionSummarySchema = S.obj({
|
|
28
|
+
id: S.str,
|
|
29
|
+
title: S.str,
|
|
30
|
+
kind: S.str,
|
|
31
|
+
status: S.str,
|
|
32
|
+
scope: S.arr(S.str),
|
|
33
|
+
deps: S.arr(S.str),
|
|
34
|
+
owner: S.any,
|
|
35
|
+
worktree: S.any,
|
|
36
|
+
blockers: S.arr(S.str),
|
|
37
|
+
tasks: S.any,
|
|
38
|
+
scopeGeneration: S.int,
|
|
39
|
+
mergedCommit: S.any,
|
|
40
|
+
mergedAt: S.any,
|
|
41
|
+
updatedAt: S.any,
|
|
42
|
+
})
|
|
43
|
+
const findingSummarySchema = S.obj({
|
|
44
|
+
id: S.str,
|
|
45
|
+
seq: S.int,
|
|
46
|
+
type: S.str,
|
|
47
|
+
title: S.str,
|
|
48
|
+
severity: S.any,
|
|
49
|
+
status: S.str,
|
|
50
|
+
missionId: S.any,
|
|
51
|
+
branch: S.any,
|
|
52
|
+
reportedBy: S.any,
|
|
53
|
+
at: S.any,
|
|
54
|
+
updatedAt: S.any,
|
|
55
|
+
closedAt: S.any,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// Per-tool output schemas. `additionalProperties: true` keeps future fields
|
|
59
|
+
// additive; `required` lists only fields every successful path returns.
|
|
60
|
+
const SCHEMAS = {
|
|
61
|
+
init: S.obj(
|
|
62
|
+
{
|
|
63
|
+
created: S.bool,
|
|
64
|
+
repoRoot: S.str,
|
|
65
|
+
commonDir: S.str,
|
|
66
|
+
statePath: S.str,
|
|
67
|
+
base: S.str,
|
|
68
|
+
revision: S.int,
|
|
69
|
+
missions: S.int,
|
|
70
|
+
},
|
|
71
|
+
['created', 'repoRoot', 'commonDir', 'statePath', 'base', 'revision', 'missions'],
|
|
72
|
+
),
|
|
73
|
+
plan: S.obj({ revision: S.int, missions: S.arr(missionSummarySchema) }, ['revision', 'missions']),
|
|
74
|
+
mission: S.obj({ revision: S.int, mission: S.obj() }, ['revision', 'mission']),
|
|
75
|
+
status: S.obj(
|
|
76
|
+
{
|
|
77
|
+
repoRoot: S.str,
|
|
78
|
+
commonDir: S.str,
|
|
79
|
+
statePath: S.str,
|
|
80
|
+
base: S.str,
|
|
81
|
+
revision: S.int,
|
|
82
|
+
counts: S.obj(),
|
|
83
|
+
openTasks: S.int,
|
|
84
|
+
roster: S.int,
|
|
85
|
+
messages: S.int,
|
|
86
|
+
unreadTower: S.int,
|
|
87
|
+
findings: S.int,
|
|
88
|
+
findingsByStatus: S.obj(),
|
|
89
|
+
reviews: S.int,
|
|
90
|
+
config: S.obj({ path: S.any, repoConfigPresent: S.bool, spawn: S.obj() }),
|
|
91
|
+
recentFindings: S.arr(S.obj()),
|
|
92
|
+
missions: S.arr(S.obj()),
|
|
93
|
+
activity: S.arr(S.obj()),
|
|
94
|
+
},
|
|
95
|
+
['repoRoot', 'commonDir', 'statePath', 'base', 'revision', 'counts', 'missions', 'activity'],
|
|
96
|
+
),
|
|
97
|
+
spawn: S.obj(
|
|
98
|
+
{
|
|
99
|
+
name: S.str,
|
|
100
|
+
agentId: S.str,
|
|
101
|
+
role: S.str,
|
|
102
|
+
missionId: S.str,
|
|
103
|
+
preset: S.str,
|
|
104
|
+
provider: S.str,
|
|
105
|
+
maxDepth: S.int,
|
|
106
|
+
agentOptions: S.any,
|
|
107
|
+
worktree: worktreeSchema,
|
|
108
|
+
created: S.bool,
|
|
109
|
+
briefingChars: S.int,
|
|
110
|
+
rosterEntry: S.obj(),
|
|
111
|
+
stopReason: S.any,
|
|
112
|
+
timedOut: S.bool,
|
|
113
|
+
output: S.any,
|
|
114
|
+
diagnostic: S.any,
|
|
115
|
+
},
|
|
116
|
+
['name', 'agentId', 'role', 'missionId', 'preset', 'provider', 'worktree'],
|
|
117
|
+
),
|
|
118
|
+
roster: S.obj(
|
|
119
|
+
{ revision: S.int, roster: S.arr(S.obj()), parentStatus: S.obj(), missions: S.arr(S.obj()) },
|
|
120
|
+
['revision', 'roster', 'parentStatus', 'missions'],
|
|
121
|
+
),
|
|
122
|
+
diff: S.obj(
|
|
123
|
+
{
|
|
124
|
+
missionId: S.str,
|
|
125
|
+
branch: S.str,
|
|
126
|
+
baseTip: S.str,
|
|
127
|
+
branchTip: S.str,
|
|
128
|
+
mergeBase: S.str,
|
|
129
|
+
files: S.arr(S.str),
|
|
130
|
+
commits: S.arr(S.str),
|
|
131
|
+
truncated: S.bool,
|
|
132
|
+
patch: S.str,
|
|
133
|
+
},
|
|
134
|
+
['missionId', 'branch', 'baseTip', 'branchTip', 'mergeBase', 'files', 'commits', 'truncated', 'patch'],
|
|
135
|
+
),
|
|
136
|
+
adopt: S.obj(
|
|
137
|
+
{ retired: S.arr(S.str), skippedUnknownParents: S.arr(S.str), roster: S.arr(S.obj()), parentStatus: S.obj() },
|
|
138
|
+
['retired', 'skippedUnknownParents', 'roster', 'parentStatus'],
|
|
139
|
+
),
|
|
140
|
+
send: S.obj({ message: S.obj(), deliveries: S.arr(S.obj()) }, ['message', 'deliveries']),
|
|
141
|
+
inbox: S.obj(
|
|
142
|
+
{ reader: S.str, messages: S.arr(S.obj()), pageSize: S.int, unreadRemaining: S.int },
|
|
143
|
+
['reader', 'messages', 'pageSize', 'unreadRemaining'],
|
|
144
|
+
),
|
|
145
|
+
complete: S.obj({ mission: S.obj(), commits: S.int, unread: S.int }, ['mission', 'commits', 'unread']),
|
|
146
|
+
finding: S.obj(
|
|
147
|
+
{
|
|
148
|
+
id: S.str,
|
|
149
|
+
seq: S.int,
|
|
150
|
+
type: S.str,
|
|
151
|
+
title: S.str,
|
|
152
|
+
severity: S.any,
|
|
153
|
+
summary: S.str,
|
|
154
|
+
location: S.any,
|
|
155
|
+
details: S.str,
|
|
156
|
+
suggested_fix: S.str,
|
|
157
|
+
missionId: S.any,
|
|
158
|
+
branch: S.any,
|
|
159
|
+
reportedBy: S.any,
|
|
160
|
+
at: S.any,
|
|
161
|
+
status: S.str,
|
|
162
|
+
updatedAt: S.any,
|
|
163
|
+
closedAt: S.any,
|
|
164
|
+
notes: S.arr(S.obj()),
|
|
165
|
+
},
|
|
166
|
+
['id', 'type', 'title', 'summary', 'details', 'suggested_fix', 'status'],
|
|
167
|
+
),
|
|
168
|
+
findings: S.obj(
|
|
169
|
+
{
|
|
170
|
+
findings: S.arr(findingSummarySchema),
|
|
171
|
+
finding: S.obj(),
|
|
172
|
+
counts: S.obj(),
|
|
173
|
+
total: S.int,
|
|
174
|
+
filtered: S.int,
|
|
175
|
+
action: S.str,
|
|
176
|
+
},
|
|
177
|
+
['counts', 'total'],
|
|
178
|
+
),
|
|
179
|
+
review: S.obj(
|
|
180
|
+
{ review: S.obj(), branchTip: S.str, missionStatus: S.str, round: S.int, reviewerRounds: S.int },
|
|
181
|
+
['review', 'branchTip', 'missionStatus', 'round', 'reviewerRounds'],
|
|
182
|
+
),
|
|
183
|
+
preflight: S.obj(
|
|
184
|
+
{
|
|
185
|
+
ok: S.bool,
|
|
186
|
+
blockers: S.arr(blockerSchema),
|
|
187
|
+
missionId: S.str,
|
|
188
|
+
branchTip: S.any,
|
|
189
|
+
baseTip: S.any,
|
|
190
|
+
changedFiles: S.any,
|
|
191
|
+
review: S.any,
|
|
192
|
+
},
|
|
193
|
+
['ok', 'blockers', 'missionId'],
|
|
194
|
+
),
|
|
195
|
+
merge: S.obj(
|
|
196
|
+
{
|
|
197
|
+
ok: S.bool,
|
|
198
|
+
missionId: S.str,
|
|
199
|
+
mergedCommit: S.any,
|
|
200
|
+
baseTip: S.any,
|
|
201
|
+
branchTip: S.any,
|
|
202
|
+
parents: S.any,
|
|
203
|
+
blockers: S.arr(blockerSchema),
|
|
204
|
+
warnings: S.any,
|
|
205
|
+
alreadyMerged: S.bool,
|
|
206
|
+
recoveredFromFailure: S.bool,
|
|
207
|
+
},
|
|
208
|
+
['ok'],
|
|
209
|
+
),
|
|
210
|
+
rebase: S.obj(
|
|
211
|
+
{
|
|
212
|
+
ok: S.bool,
|
|
213
|
+
oldTip: S.any,
|
|
214
|
+
newTip: S.any,
|
|
215
|
+
baseTip: S.any,
|
|
216
|
+
alreadyMerged: S.bool,
|
|
217
|
+
alreadyUpToDate: S.bool,
|
|
218
|
+
skipped: S.bool,
|
|
219
|
+
reviewWaived: S.bool,
|
|
220
|
+
note: S.str,
|
|
221
|
+
blockers: S.arr(blockerSchema),
|
|
222
|
+
},
|
|
223
|
+
['ok'],
|
|
224
|
+
),
|
|
225
|
+
teardown: S.obj(
|
|
226
|
+
{ ok: S.bool, dryRun: S.bool, removed: S.arr(S.str), decisions: S.arr(S.obj()) },
|
|
227
|
+
['ok', 'dryRun', 'removed', 'decisions'],
|
|
228
|
+
),
|
|
229
|
+
interrupt: S.obj(
|
|
230
|
+
{
|
|
231
|
+
ok: S.bool,
|
|
232
|
+
interrupted: S.bool,
|
|
233
|
+
previousActivity: S.str,
|
|
234
|
+
entry: S.obj(),
|
|
235
|
+
reason: S.str,
|
|
236
|
+
},
|
|
237
|
+
['ok', 'interrupted'],
|
|
238
|
+
),
|
|
239
|
+
reload: S.obj(
|
|
240
|
+
{
|
|
241
|
+
ok: S.bool,
|
|
242
|
+
scheduled: S.bool,
|
|
243
|
+
delayMs: S.int,
|
|
244
|
+
requiredIds: S.arr(S.str),
|
|
245
|
+
error: S.str,
|
|
246
|
+
restartHint: S.str,
|
|
247
|
+
},
|
|
248
|
+
['ok', 'scheduled'],
|
|
249
|
+
),
|
|
250
|
+
doctor: S.obj({ ok: S.bool, checks: S.arr(S.obj()), environmentContract: S.obj() }, ['ok', 'checks']),
|
|
251
|
+
test: S.obj(
|
|
252
|
+
{
|
|
253
|
+
ok: S.bool,
|
|
254
|
+
configured: S.bool,
|
|
255
|
+
treeTip: S.any,
|
|
256
|
+
tests: S.arr(S.obj()),
|
|
257
|
+
commands: S.arr(S.obj()),
|
|
258
|
+
error: S.str,
|
|
259
|
+
receipt: S.obj(),
|
|
260
|
+
checks: S.arr(S.obj()),
|
|
261
|
+
},
|
|
262
|
+
['ok', 'configured'],
|
|
263
|
+
),
|
|
264
|
+
batch: S.obj(
|
|
265
|
+
{ ok: S.bool, action: S.str, batch: S.obj(), batches: S.arr(S.obj()), blockers: S.arr(blockerSchema), summary: S.obj() },
|
|
266
|
+
['ok', 'action'],
|
|
267
|
+
),
|
|
268
|
+
resource: S.obj(
|
|
269
|
+
{ ok: S.bool, action: S.str, lease: S.obj(), leases: S.arr(S.obj()), released: S.bool },
|
|
270
|
+
['ok', 'action'],
|
|
271
|
+
),
|
|
272
|
+
importLegacy: S.obj({ ok: S.bool, summary: S.obj(), snapshot: S.obj() }, ['ok', 'summary']),
|
|
273
|
+
legacyMap: S.obj({ ok: S.bool, map: S.obj(), maps: S.arr(S.obj()) }, ['ok']),
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function renderValue(_args, value) {
|
|
277
|
+
return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function output(schema) {
|
|
281
|
+
return { schema, render: renderValue }
|
|
8
282
|
}
|
|
9
283
|
|
|
10
284
|
const ROLE_ENUM = ['worker', 'reviewer']
|
|
11
285
|
|
|
12
|
-
function
|
|
13
|
-
|
|
286
|
+
function normalizeToolArgs(args, aliases = {}) {
|
|
287
|
+
const out = { ...(args ?? {}) }
|
|
288
|
+
for (const [canonical, names] of Object.entries(aliases)) {
|
|
289
|
+
const current = out[canonical]
|
|
290
|
+
if (current !== undefined && current !== null) continue
|
|
291
|
+
for (const name of names) {
|
|
292
|
+
if (out[name] !== undefined && out[name] !== null) {
|
|
293
|
+
out[canonical] = out[name]
|
|
294
|
+
break
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return out
|
|
14
299
|
}
|
|
15
300
|
|
|
16
301
|
function fail(message) {
|
|
17
302
|
throw new Error(message)
|
|
18
303
|
}
|
|
19
304
|
|
|
305
|
+
/** Resolve the calling agent to a roster role; unknown callers are the tower. */
|
|
306
|
+
async function actorFor(tower, exec) {
|
|
307
|
+
const agentId = exec?.agent?.id !== undefined ? String(exec.agent.id) : null
|
|
308
|
+
if (!agentId) return { name: 'tower', role: 'tower', agentId: null }
|
|
309
|
+
let view
|
|
310
|
+
try {
|
|
311
|
+
view = await tower.rosterView()
|
|
312
|
+
} catch {
|
|
313
|
+
// Before tower_init there is no state; the caller is the tower.
|
|
314
|
+
return { name: 'tower', role: 'tower', agentId }
|
|
315
|
+
}
|
|
316
|
+
const self = view.roster.find((entry) => entry.agentId === agentId)
|
|
317
|
+
if (!self) return { name: 'tower', role: 'tower', agentId }
|
|
318
|
+
return { name: self.name, role: self.role, agentId }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function requireTowerActor(actor, action) {
|
|
322
|
+
if (actor.role !== 'tower') {
|
|
323
|
+
throw new TowerToolError('not-authorized', `only the tower can ${action}; ${actor.name} is a ${actor.role}`)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const SEND_RATE = new Map()
|
|
328
|
+
const SPAWN_QUEUES = new Map()
|
|
329
|
+
const WATCHED_REPOS = new Set()
|
|
330
|
+
function watchRepo(repoRoot) {
|
|
331
|
+
WATCHED_REPOS.add(repoRoot)
|
|
332
|
+
if (WATCHED_REPOS.size > 100) {
|
|
333
|
+
const oldest = WATCHED_REPOS.values().next().value
|
|
334
|
+
WATCHED_REPOS.delete(oldest)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
let workspaceHook = null
|
|
338
|
+
let sharedHmrBridge = null
|
|
339
|
+
|
|
340
|
+
/** Serialize spawn decisions per repository so precheck + activation + record are atomic. */
|
|
341
|
+
function enqueueSpawn(key, operation) {
|
|
342
|
+
const previous = SPAWN_QUEUES.get(key) ?? Promise.resolve()
|
|
343
|
+
const run = previous.catch(() => {}).then(operation)
|
|
344
|
+
const settled = run.catch(() => {})
|
|
345
|
+
SPAWN_QUEUES.set(key, settled)
|
|
346
|
+
settled.finally(() => {
|
|
347
|
+
if (SPAWN_QUEUES.get(key) === settled) SPAWN_QUEUES.delete(key)
|
|
348
|
+
})
|
|
349
|
+
return run
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** In-memory per-repository tower_send rate limit (process-local, advisory). */
|
|
353
|
+
function checkSendRate(config, repoRoot) {
|
|
354
|
+
const max = Number(config.send?.maxPerMinute)
|
|
355
|
+
if (!max || max <= 0) return
|
|
356
|
+
const key = String(repoRoot)
|
|
357
|
+
const now = Date.now()
|
|
358
|
+
const bucket = SEND_RATE.get(key)
|
|
359
|
+
if (!bucket || now - bucket.start >= 60000) {
|
|
360
|
+
SEND_RATE.set(key, { start: now, count: 1 })
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
if (bucket.count >= max) {
|
|
364
|
+
throw new TowerToolError('rate-limited', `tower_send rate limit reached (${max}/min for ${key}); retry later or raise send.maxPerMinute`)
|
|
365
|
+
}
|
|
366
|
+
bucket.count += 1
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function interruptRosterAgent(ctx, tower, exec, input) {
|
|
370
|
+
const subagents = ctx.get('subagents')
|
|
371
|
+
if (!subagents || typeof subagents.interrupt !== 'function') {
|
|
372
|
+
throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot interrupt')
|
|
373
|
+
}
|
|
374
|
+
const view = await tower.rosterView()
|
|
375
|
+
const entry = view.roster.find(
|
|
376
|
+
(row) =>
|
|
377
|
+
(input.agentId !== undefined && row.agentId === String(input.agentId)) ||
|
|
378
|
+
(input.to !== undefined && row.name === input.to),
|
|
379
|
+
)
|
|
380
|
+
if (!entry) throw new TowerToolError('invalid-argument', `unknown roster agent "${input.to ?? input.agentId}"`)
|
|
381
|
+
const { activity } = await observeActivity(ctx, tower)
|
|
382
|
+
const previousActivity = activity[entry.agentId] ?? 'unknown'
|
|
383
|
+
if (previousActivity !== 'running') {
|
|
384
|
+
return { interrupted: false, reason: `agent is ${previousActivity}, not running`, entry }
|
|
385
|
+
}
|
|
386
|
+
subagents.interrupt(entry.agentId, { kind: 'ancestor', agent: exec.agent })
|
|
387
|
+
const updated = await tower.recordInterrupt({
|
|
388
|
+
agentId: entry.agentId,
|
|
389
|
+
name: entry.name,
|
|
390
|
+
reason: input.reason ?? 'interrupted by tower',
|
|
391
|
+
})
|
|
392
|
+
return { interrupted: true, previousActivity, entry: updated }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** autoGitInit support: Tower.open needs a repository, so prepare it first.
|
|
396
|
+
* Only absolute, existing directories are eligible; a typo must not git-init the
|
|
397
|
+
* DSH process cwd or create a repository at an unintended path. */
|
|
398
|
+
async function ensureRepository(repoRootInput, autoGitInit) {
|
|
399
|
+
try {
|
|
400
|
+
return await resolveRepo(repoRootInput)
|
|
401
|
+
} catch (error) {
|
|
402
|
+
if (autoGitInit !== true) throw error
|
|
403
|
+
if (typeof repoRootInput !== 'string' || !path.isAbsolute(repoRootInput)) {
|
|
404
|
+
throw new TowerToolError('repo-invalid', `autoGitInit requires an absolute directory path: ${repoRootInput}`)
|
|
405
|
+
}
|
|
406
|
+
const resolved = path.resolve(repoRootInput)
|
|
407
|
+
let stat = null
|
|
408
|
+
try {
|
|
409
|
+
stat = await fsp.stat(resolved)
|
|
410
|
+
} catch {
|
|
411
|
+
stat = null
|
|
412
|
+
}
|
|
413
|
+
if (!stat || !stat.isDirectory()) {
|
|
414
|
+
throw new TowerToolError('repo-invalid', `autoGitInit requires an existing directory: ${resolved}`)
|
|
415
|
+
}
|
|
416
|
+
if (resolved === path.parse(resolved).root) {
|
|
417
|
+
throw new TowerToolError('repo-invalid', 'autoGitInit refuses to initialize a filesystem root')
|
|
418
|
+
}
|
|
419
|
+
await git(resolved, ['init'])
|
|
420
|
+
const repo = await resolveRepo(resolved)
|
|
421
|
+
if (!(await hasAnyCommit(repo.root))) {
|
|
422
|
+
await git(repo.root, [
|
|
423
|
+
'-c',
|
|
424
|
+
'user.name=dsh-tower',
|
|
425
|
+
'-c',
|
|
426
|
+
'user.email=dsh-tower@localhost',
|
|
427
|
+
'commit',
|
|
428
|
+
'--allow-empty',
|
|
429
|
+
'-m',
|
|
430
|
+
'tower: initial empty commit',
|
|
431
|
+
])
|
|
432
|
+
}
|
|
433
|
+
return repo
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function define(ctx, definition) {
|
|
438
|
+
const { aliases, requiresMissionId, board, execute, ...rest } = definition
|
|
439
|
+
ctx.effect(() =>
|
|
440
|
+
ctx.tools.register({
|
|
441
|
+
...rest,
|
|
442
|
+
async execute(args, exec) {
|
|
443
|
+
const normalized = normalizeToolArgs(args, aliases)
|
|
444
|
+
if (requiresMissionId) {
|
|
445
|
+
const id = normalized.missionId
|
|
446
|
+
if (typeof id !== 'string' || id.trim().length === 0) {
|
|
447
|
+
throw new TowerToolError('invalid-argument', `${rest.name} requires missionId (alias: id)`)
|
|
448
|
+
}
|
|
449
|
+
normalized.missionId = id.trim()
|
|
450
|
+
}
|
|
451
|
+
let value
|
|
452
|
+
try {
|
|
453
|
+
if (workspaceHook && typeof normalized.repoRoot === 'string' && rest.name !== 'tower_init') {
|
|
454
|
+
await workspaceHook.ensure(normalized.repoRoot, exec, rest.name)
|
|
455
|
+
}
|
|
456
|
+
value = await execute(normalized, exec)
|
|
457
|
+
} catch (error) {
|
|
458
|
+
throw classifyError(error)
|
|
459
|
+
}
|
|
460
|
+
if (typeof normalized.repoRoot === 'string') watchRepo(normalized.repoRoot)
|
|
461
|
+
const event = rest.name.startsWith('tower_') ? rest.name.slice('tower_'.length) : null
|
|
462
|
+
if (event && !['mode', 'control', 'reload'].includes(event)) {
|
|
463
|
+
try {
|
|
464
|
+
ctx.emit?.(`tower/${event}`, {
|
|
465
|
+
tool: rest.name,
|
|
466
|
+
repoRoot: typeof normalized.repoRoot === 'string' ? normalized.repoRoot : null,
|
|
467
|
+
value: value ?? null,
|
|
468
|
+
})
|
|
469
|
+
} catch {
|
|
470
|
+
// events are an optional side channel; never fail a tool for them
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (board === true && typeof normalized.repoRoot === 'string') {
|
|
474
|
+
try {
|
|
475
|
+
const opened = await Tower.open(normalized.repoRoot)
|
|
476
|
+
await opened.writeBoard()
|
|
477
|
+
} catch {
|
|
478
|
+
// board output is best effort
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return value
|
|
482
|
+
},
|
|
483
|
+
}),
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
|
|
20
487
|
function textOf(blocks) {
|
|
21
488
|
if (!Array.isArray(blocks)) return null
|
|
22
489
|
return blocks
|
|
@@ -24,6 +491,52 @@ function textOf(blocks) {
|
|
|
24
491
|
.join('\n')
|
|
25
492
|
}
|
|
26
493
|
|
|
494
|
+
/** Read `package.json tower.testDiscovery` and run it for one mission worktree.
|
|
495
|
+
* Returns `{configured:false}` when the repository has no discovery block. */
|
|
496
|
+
async function runMissionDiscovery(tower, mission) {
|
|
497
|
+
const testsConfig = tower.config.tests ?? {}
|
|
498
|
+
if (testsConfig.enabled === false || testsConfig.allowPackageDiscovery === false) {
|
|
499
|
+
return { configured: false }
|
|
500
|
+
}
|
|
501
|
+
const worktreePath = mission?.worktree?.path
|
|
502
|
+
if (!worktreePath) return { configured: false, error: 'mission has no worktree' }
|
|
503
|
+
let packageText
|
|
504
|
+
try {
|
|
505
|
+
packageText = await fsp.readFile(path.join(worktreePath, 'package.json'), 'utf8')
|
|
506
|
+
} catch (error) {
|
|
507
|
+
if (error?.code === 'ENOENT') return { configured: false }
|
|
508
|
+
throw error
|
|
509
|
+
}
|
|
510
|
+
const read = readDiscoveryConfig(packageText)
|
|
511
|
+
if (!read.configured) {
|
|
512
|
+
if (read.error) throw new TowerToolError('invalid-argument', `package.json test discovery is invalid: ${read.error}`)
|
|
513
|
+
return { configured: false, error: null }
|
|
514
|
+
}
|
|
515
|
+
const normalized = normalizeDiscovery(read.config)
|
|
516
|
+
if (!normalized.ok) {
|
|
517
|
+
throw new TowerToolError('invalid-argument', `invalid tower.testDiscovery: ${normalized.error}`)
|
|
518
|
+
}
|
|
519
|
+
const receipt = await runDiscovery({
|
|
520
|
+
worktreePath,
|
|
521
|
+
config: read.config,
|
|
522
|
+
timeoutMs: Number(testsConfig.timeoutMs) || 60000,
|
|
523
|
+
maxBytes: Number(testsConfig.maxBytes) || 16 * 1024 * 1024,
|
|
524
|
+
missionId: mission.id,
|
|
525
|
+
diagnosticsDir: path.join(tower.paths.dir, 'test-runs', `${mission.id}-${Date.now()}`),
|
|
526
|
+
})
|
|
527
|
+
const stored = await tower.recordTestRun({ missionId: mission.id, receipt })
|
|
528
|
+
const commands = Array.isArray(stored.commands) ? stored.commands : []
|
|
529
|
+
const check = {
|
|
530
|
+
name: 'tests',
|
|
531
|
+
command: commands.map((command) => `${command.id}: ${command.command}`).join('; ') || 'tower.testDiscovery',
|
|
532
|
+
exitCode: commands.every((command) => command.exitCode === 0) ? 0 : (commands.find((command) => command.exitCode !== 0)?.exitCode ?? 1),
|
|
533
|
+
evidence: stored.ok
|
|
534
|
+
? `${stored.tests?.length ?? 0} test(s) passed at ${String(stored.treeTip ?? '').slice(0, 12)}; receipt ${stored.receiptPath ?? stored.diagnosticsPath}`
|
|
535
|
+
: `test discovery failed: ${String(stored.error ?? 'unknown').slice(0, 300)}`,
|
|
536
|
+
}
|
|
537
|
+
return { configured: true, receipt: stored, check }
|
|
538
|
+
}
|
|
539
|
+
|
|
27
540
|
async function observeActivity(ctx, tower) {
|
|
28
541
|
const view = await tower.rosterView()
|
|
29
542
|
const subagents = ctx.get('subagents')
|
|
@@ -43,7 +556,22 @@ async function observeActivity(ctx, tower) {
|
|
|
43
556
|
return { activity, parentStatus }
|
|
44
557
|
}
|
|
45
558
|
|
|
46
|
-
function
|
|
559
|
+
function clip(text, max = 2000) {
|
|
560
|
+
const value = String(text ?? '')
|
|
561
|
+
return value.length > max ? `${value.slice(0, max)}\n… truncated` : value
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function workerBriefing({
|
|
565
|
+
repoRoot,
|
|
566
|
+
base,
|
|
567
|
+
mission,
|
|
568
|
+
deps,
|
|
569
|
+
worktree,
|
|
570
|
+
instructions,
|
|
571
|
+
reviews = [],
|
|
572
|
+
findings = [],
|
|
573
|
+
completionReport = null,
|
|
574
|
+
}) {
|
|
47
575
|
const lines = []
|
|
48
576
|
lines.push(`# Tower mission ${mission.id} — ${mission.title}`)
|
|
49
577
|
lines.push('')
|
|
@@ -68,6 +596,36 @@ function workerBriefing({ repoRoot, base, mission, deps, worktree, instructions
|
|
|
68
596
|
lines.push('Context (verbatim from the user):')
|
|
69
597
|
lines.push(mission.context)
|
|
70
598
|
}
|
|
599
|
+
if ((mission.references ?? []).length > 0) {
|
|
600
|
+
lines.push('References (read-only pointers; read them before deciding):')
|
|
601
|
+
for (const reference of mission.references) lines.push(`- ${reference}`)
|
|
602
|
+
}
|
|
603
|
+
if (mission.reviewPolicy === 'none') {
|
|
604
|
+
lines.push(`Review policy: none (user exemption: ${mission.reviewExemptionReason ?? 'missing reason'})`)
|
|
605
|
+
}
|
|
606
|
+
if (completionReport) {
|
|
607
|
+
lines.push('')
|
|
608
|
+
lines.push(`Previous completion report (by ${completionReport.by ?? 'worker'}):`)
|
|
609
|
+
lines.push(clip(completionReport.report, 1500))
|
|
610
|
+
if ((completionReport.checks ?? []).length > 0) {
|
|
611
|
+
lines.push('Previous checks:')
|
|
612
|
+
for (const check of completionReport.checks) lines.push(`- ${check.name}${check.exitCode !== undefined ? ` (exit ${check.exitCode})` : ''}`)
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (reviews.length > 0) {
|
|
616
|
+
lines.push('')
|
|
617
|
+
lines.push('Previous review rounds for this branch (newest first) — address every finding:')
|
|
618
|
+
for (const review of reviews) {
|
|
619
|
+
lines.push(`- R${review.round} by ${review.reviewer}: ${review.status} / ${review.mergeVerdict}`)
|
|
620
|
+
if (review.findings) lines.push(` findings: ${clip(review.findings, 1200).replaceAll('\n', '\n ')}`)
|
|
621
|
+
if (review.decision) lines.push(` decision: ${clip(review.decision, 600)}`)
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (findings.length > 0) {
|
|
625
|
+
lines.push('')
|
|
626
|
+
lines.push('Findings related to this mission:')
|
|
627
|
+
for (const finding of findings) lines.push(`- ${finding.id} [${finding.status}] ${finding.severity ?? 'severity?'} ${finding.title}`)
|
|
628
|
+
}
|
|
71
629
|
lines.push('')
|
|
72
630
|
lines.push('Rules:')
|
|
73
631
|
lines.push(`- Pass workdir="${worktree.path}" on every shell command; never edit the main checkout.`)
|
|
@@ -77,7 +635,7 @@ function workerBriefing({ repoRoot, base, mission, deps, worktree, instructions
|
|
|
77
635
|
`- Update progress: tower_mission({repoRoot:"${repoRoot}",id:"${mission.id}",task_done:"..."}) or blocker:"...".`,
|
|
78
636
|
)
|
|
79
637
|
lines.push(
|
|
80
|
-
`- When the work is finished and every task is closed, call tower_complete({repoRoot:"${repoRoot}",missionId:"${mission.id}",report:"<full handoff>"}). Unread inbox messages block completion, so read them with tower_inbox first.`,
|
|
638
|
+
`- When the work is finished and every task is closed, call tower_complete({repoRoot:"${repoRoot}",missionId:"${mission.id}",report:"<full handoff>",checks:[{name,command,exitCode,evidence}]}). Unread inbox messages block completion, so read them with tower_inbox first.`,
|
|
81
639
|
)
|
|
82
640
|
lines.push('- Your final message is the full handoff: files changed, how you verified, what remains.')
|
|
83
641
|
if (instructions) {
|
|
@@ -88,7 +646,17 @@ function workerBriefing({ repoRoot, base, mission, deps, worktree, instructions
|
|
|
88
646
|
return lines.join('\n')
|
|
89
647
|
}
|
|
90
648
|
|
|
91
|
-
function reviewerBriefing({
|
|
649
|
+
export function reviewerBriefing({
|
|
650
|
+
repoRoot,
|
|
651
|
+
base,
|
|
652
|
+
mission,
|
|
653
|
+
deps,
|
|
654
|
+
worktree,
|
|
655
|
+
instructions,
|
|
656
|
+
reviews = [],
|
|
657
|
+
findings = [],
|
|
658
|
+
completionReport = null,
|
|
659
|
+
}) {
|
|
92
660
|
const lines = []
|
|
93
661
|
lines.push(`# Tower review — mission ${mission.id} — ${mission.title}`)
|
|
94
662
|
lines.push('')
|
|
@@ -111,6 +679,36 @@ function reviewerBriefing({ repoRoot, base, mission, deps, worktree, instruction
|
|
|
111
679
|
lines.push('Context (verbatim from the user):')
|
|
112
680
|
lines.push(mission.context)
|
|
113
681
|
}
|
|
682
|
+
if ((mission.references ?? []).length > 0) {
|
|
683
|
+
lines.push('References (read-only pointers):')
|
|
684
|
+
for (const reference of mission.references) lines.push(`- ${reference}`)
|
|
685
|
+
}
|
|
686
|
+
if (completionReport) {
|
|
687
|
+
lines.push('')
|
|
688
|
+
lines.push(`Completion report (by ${completionReport.by ?? 'worker'}):`)
|
|
689
|
+
lines.push(clip(completionReport.report, 2000))
|
|
690
|
+
if ((completionReport.checks ?? []).length > 0) {
|
|
691
|
+
lines.push('Completion checks the worker ran:')
|
|
692
|
+
for (const check of completionReport.checks) {
|
|
693
|
+
lines.push(
|
|
694
|
+
`- ${check.name}${check.exitCode !== undefined ? ` (exit ${check.exitCode})` : ''}${check.evidence ? ` — ${clip(check.evidence, 400)}` : ''}`,
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (reviews.length > 0) {
|
|
700
|
+
lines.push('')
|
|
701
|
+
lines.push('Prior review rounds for this branch (newest first):')
|
|
702
|
+
for (const review of reviews) {
|
|
703
|
+
lines.push(`- R${review.round} by ${review.reviewer}: ${review.status} / ${review.mergeVerdict}`)
|
|
704
|
+
if (review.findings) lines.push(` findings: ${clip(review.findings, 1200).replaceAll('\n', '\n ')}`)
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (findings.length > 0) {
|
|
708
|
+
lines.push('')
|
|
709
|
+
lines.push('Related findings:')
|
|
710
|
+
for (const finding of findings) lines.push(`- ${finding.id} [${finding.status}] ${finding.severity ?? 'severity?'} ${finding.title}`)
|
|
711
|
+
}
|
|
114
712
|
lines.push('')
|
|
115
713
|
lines.push('Rules:')
|
|
116
714
|
lines.push('- Read-only: do not modify files, commit, or merge.')
|
|
@@ -131,24 +729,76 @@ function reviewerBriefing({ repoRoot, base, mission, deps, worktree, instruction
|
|
|
131
729
|
return lines.join('\n')
|
|
132
730
|
}
|
|
133
731
|
|
|
134
|
-
export function registerTowerTools(ctx) {
|
|
732
|
+
export function registerTowerTools(ctx, options = {}) {
|
|
733
|
+
workspaceHook = options.workspace ?? null
|
|
734
|
+
sharedHmrBridge = options.hmrBridge ?? null
|
|
735
|
+
// A lightweight roster watchdog: the authoritative activity comes from the
|
|
736
|
+
// subagents catalog; a missing observation after spawn.staleMs is marked
|
|
737
|
+
// stale (never silently treated as stopped) and blocks merge via the gate.
|
|
738
|
+
const watchMs = Math.max(1000, Number(runtimeConfig().mode?.heartbeatMs) || 10000)
|
|
739
|
+
const timer = setInterval(() => {
|
|
740
|
+
void (async () => {
|
|
741
|
+
try {
|
|
742
|
+
if (!ctx.get('subagents')) return
|
|
743
|
+
for (const repoRoot of [...WATCHED_REPOS]) {
|
|
744
|
+
const tower = await Tower.open(repoRoot)
|
|
745
|
+
const view = await tower.rosterView()
|
|
746
|
+
const live = view.roster.filter((entry) => !ROSTER_INACTIVE_STATES.includes(entry.state))
|
|
747
|
+
if (live.length === 0) { WATCHED_REPOS.delete(repoRoot); continue }
|
|
748
|
+
const parents = [...new Set(live.map((entry) => entry.parentSessionId).filter(Boolean))]
|
|
749
|
+
if (parents.length === 0) continue
|
|
750
|
+
const { activity, parentStatus } = await observeActivity(ctx, tower)
|
|
751
|
+
const authoritative = parents.every((parent) => parentStatus[parent] === 'ok')
|
|
752
|
+
await tower.sweepRoster({
|
|
753
|
+
staleMs: Number(tower.config.spawn?.staleMs) || 0,
|
|
754
|
+
activity,
|
|
755
|
+
authoritative,
|
|
756
|
+
})
|
|
757
|
+
}
|
|
758
|
+
} catch {
|
|
759
|
+
// watchdog failures are best effort; tools still re-check on demand
|
|
760
|
+
}
|
|
761
|
+
})()
|
|
762
|
+
}, watchMs)
|
|
763
|
+
timer.unref?.()
|
|
764
|
+
ctx.effect(() => () => clearInterval(timer))
|
|
135
765
|
define(ctx, {
|
|
136
766
|
name: 'tower_init',
|
|
137
767
|
description:
|
|
138
|
-
'Initialize Tower mission state for a repository (absolute repoRoot). Creates <git-common-dir>/dsh-tower/state.json and an activity log; idempotent on an existing workspace.
|
|
768
|
+
'Initialize Tower mission state for a repository (absolute repoRoot). Creates <git-common-dir>/dsh-tower/state.json and an activity log; idempotent on an existing workspace. Options: autoGitInit (git init + empty initial commit when the directory is not a repository), snapshotDirty (snapshot uncommitted work into a dangling commit used as the mission baseline), reanchor (change base when every mission is merged/abandoned). Only the tower may initialize.',
|
|
139
769
|
parameters: {
|
|
140
770
|
type: 'object',
|
|
141
771
|
properties: {
|
|
142
772
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
143
773
|
base: { type: 'string', description: 'Optional local base branch (defaults to the checked-out branch).' },
|
|
774
|
+
autoGitInit: { type: 'boolean', description: 'Run git init (and an empty initial commit) when repoRoot is not a repository yet.' },
|
|
775
|
+
snapshotDirty: { type: 'boolean', description: 'Snapshot uncommitted tracked changes into a dangling commit used as the mission baseline (untracked files are not included).' },
|
|
776
|
+
reanchor: { type: 'boolean', description: 'Update the recorded base when no mission is active.' },
|
|
144
777
|
},
|
|
145
778
|
required: ['repoRoot'],
|
|
146
779
|
additionalProperties: false,
|
|
147
780
|
},
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
781
|
+
aliases: { auto_git_init: ['autoGitInit'], snapshot_dirty: ['snapshotDirty'] },
|
|
782
|
+
board: true,
|
|
783
|
+
output: output(SCHEMAS.init),
|
|
784
|
+
async execute(args, exec) {
|
|
785
|
+
const profileInit = runtimeConfig().init ?? {}
|
|
786
|
+
let repo = null
|
|
787
|
+
try {
|
|
788
|
+
repo = await resolveRepo(args.repoRoot)
|
|
789
|
+
} catch (error) {
|
|
790
|
+
const autoGitInit =
|
|
791
|
+
args.autoGitInit === true || (args.autoGitInit === undefined && profileInit.autoGitInit === true)
|
|
792
|
+
repo = await ensureRepository(args.repoRoot, autoGitInit)
|
|
793
|
+
}
|
|
794
|
+
const tower = await Tower.open(repo.root)
|
|
795
|
+
const initConfig = tower.config.init ?? {}
|
|
796
|
+
const snapshotDirty =
|
|
797
|
+
args.snapshotDirty === true || (args.snapshotDirty === undefined && initConfig.snapshotDirty === true)
|
|
798
|
+
const actor = await actorFor(tower, exec)
|
|
799
|
+
requireTowerActor(actor, 'initialize Tower state')
|
|
800
|
+
const base = args.base ?? workspaceHook?.basePreference?.(exec) ?? undefined
|
|
801
|
+
return tower.init({ base, snapshotDirty, reanchor: args.reanchor })
|
|
152
802
|
},
|
|
153
803
|
})
|
|
154
804
|
|
|
@@ -172,6 +822,21 @@ export function registerTowerTools(ctx) {
|
|
|
172
822
|
deps: { type: 'array', items: { type: 'string' }, description: 'Mission ids that must merge before this one.' },
|
|
173
823
|
kind: { type: 'string', enum: ['build', 'survey'], description: 'survey = read-only investigation that reserves no scope.' },
|
|
174
824
|
context: { type: 'string', description: "The user's own key sentences, verbatim (max 4096 chars)." },
|
|
825
|
+
references: {
|
|
826
|
+
type: 'array',
|
|
827
|
+
maxItems: 20,
|
|
828
|
+
items: { type: 'string' },
|
|
829
|
+
description: 'Read-only pointers/files the worker should read (max 20); copied into the briefing.',
|
|
830
|
+
},
|
|
831
|
+
reviewPolicy: {
|
|
832
|
+
type: 'string',
|
|
833
|
+
enum: ['required-clean', 'none'],
|
|
834
|
+
description: 'required-clean (default) requires a clean review before merge; none needs reviewExemptionReason and gates.allowReviewExemption.',
|
|
835
|
+
},
|
|
836
|
+
reviewExemptionReason: {
|
|
837
|
+
type: 'string',
|
|
838
|
+
description: 'Non-empty justification when reviewPolicy is "none".',
|
|
839
|
+
},
|
|
175
840
|
},
|
|
176
841
|
required: ['title'],
|
|
177
842
|
additionalProperties: false,
|
|
@@ -181,9 +846,12 @@ export function registerTowerTools(ctx) {
|
|
|
181
846
|
required: ['repoRoot', 'missions'],
|
|
182
847
|
additionalProperties: false,
|
|
183
848
|
},
|
|
184
|
-
|
|
185
|
-
|
|
849
|
+
board: true,
|
|
850
|
+
output: output(SCHEMAS.plan),
|
|
851
|
+
async execute(args, exec) {
|
|
186
852
|
const tower = await Tower.open(args.repoRoot)
|
|
853
|
+
const actor = await actorFor(tower, exec)
|
|
854
|
+
requireTowerActor(actor, 'plan missions')
|
|
187
855
|
return tower.plan({ missions: args.missions })
|
|
188
856
|
},
|
|
189
857
|
})
|
|
@@ -196,7 +864,8 @@ export function registerTowerTools(ctx) {
|
|
|
196
864
|
type: 'object',
|
|
197
865
|
properties: {
|
|
198
866
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
199
|
-
id: { type: 'string', description: 'Mission id, e.g. M1.' },
|
|
867
|
+
id: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: missionId).' },
|
|
868
|
+
missionId: { type: 'string', description: 'Alias for id; both are accepted.' },
|
|
200
869
|
status: {
|
|
201
870
|
type: 'string',
|
|
202
871
|
enum: ['planned', 'active', 'completed', 'blocked', 'paused', 'abandoned'],
|
|
@@ -205,18 +874,32 @@ export function registerTowerTools(ctx) {
|
|
|
205
874
|
note: { type: 'string', description: 'Append a decision-log note.' },
|
|
206
875
|
blocker: { type: 'string', description: 'Report a blocker (also sets status to blocked).' },
|
|
207
876
|
clear_blockers: { type: 'boolean', description: 'Clear all recorded blockers.' },
|
|
877
|
+
clearBlockers: { type: 'boolean', description: 'Alias for clear_blockers.' },
|
|
208
878
|
task_done: { type: 'string', description: 'Mark the first open task containing this text as done.' },
|
|
879
|
+
taskDone: { type: 'string', description: 'Alias for task_done.' },
|
|
209
880
|
task_drop: { type: 'string', description: 'Mark the first open task containing this text as dropped.' },
|
|
881
|
+
taskDrop: { type: 'string', description: 'Alias for task_drop.' },
|
|
210
882
|
task_drop_reason: { type: 'string', description: 'Required with task_drop: why the task is legitimately descoped.' },
|
|
883
|
+
taskDropReason: { type: 'string', description: 'Alias for task_drop_reason.' },
|
|
211
884
|
scope: { type: 'array', items: { type: 'string' }, description: 'Tower only: replacement scope globs; bumps scopeGeneration.' },
|
|
885
|
+
pause_cause: {
|
|
886
|
+
type: 'string',
|
|
887
|
+
enum: ['user', 'failure', 'resource', 'dependency'],
|
|
888
|
+
description: 'With status=paused: why the mission was paused (default user).',
|
|
889
|
+
},
|
|
890
|
+
authorization: { type: 'string', description: 'Authorization reference recorded when resuming a user-paused mission.' },
|
|
212
891
|
},
|
|
213
|
-
required: ['repoRoot'
|
|
892
|
+
required: ['repoRoot'],
|
|
214
893
|
additionalProperties: false,
|
|
215
894
|
},
|
|
216
|
-
|
|
217
|
-
|
|
895
|
+
aliases: {"id":["missionId"],"task_done":["taskDone"],"task_drop":["taskDrop"],"task_drop_reason":["taskDropReason"],"clear_blockers":["clearBlockers"]},
|
|
896
|
+
board: true,
|
|
897
|
+
output: output(SCHEMAS.mission),
|
|
898
|
+
async execute(args, exec) {
|
|
218
899
|
const tower = await Tower.open(args.repoRoot)
|
|
900
|
+
const actor = await actorFor(tower, exec)
|
|
219
901
|
return tower.mission({
|
|
902
|
+
actor,
|
|
220
903
|
id: args.id,
|
|
221
904
|
status: args.status,
|
|
222
905
|
note: args.note,
|
|
@@ -226,6 +909,8 @@ export function registerTowerTools(ctx) {
|
|
|
226
909
|
task_drop: args.task_drop,
|
|
227
910
|
task_drop_reason: args.task_drop_reason,
|
|
228
911
|
scope: args.scope,
|
|
912
|
+
pause_cause: args.pause_cause,
|
|
913
|
+
authorization: args.authorization,
|
|
229
914
|
})
|
|
230
915
|
},
|
|
231
916
|
})
|
|
@@ -233,19 +918,77 @@ export function registerTowerTools(ctx) {
|
|
|
233
918
|
define(ctx, {
|
|
234
919
|
name: 'tower_status',
|
|
235
920
|
description:
|
|
236
|
-
'Read the Tower dashboard for a repository: state path, base branch, revision, mission status counts,
|
|
921
|
+
'Read the Tower dashboard for a repository: state path, base branch, revision, mission status counts, paged mission summaries (scope/deps/tasks/worktree/owner), roster size, findings, config path and the activity tail. Pass missionId for one full mission (tasks, notes, reviews, findings, completion report); offset/limit page the mission list (default 20, max 100); includeGate adds preflight gate digests for the page; format:"markdown" returns the human-readable mission board. Read-only.',
|
|
237
922
|
parameters: {
|
|
238
923
|
type: 'object',
|
|
239
924
|
properties: {
|
|
240
925
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
926
|
+
missionId: { type: 'string', description: 'Return full detail for one mission instead of the dashboard list.' },
|
|
927
|
+
offset: { type: 'integer', minimum: 0, description: 'Mission list offset (default 0).' },
|
|
928
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, description: 'Mission list page size (default 20).' },
|
|
929
|
+
includeGate: { type: 'boolean', description: 'Compute tower_preflight-style gate digests for missions on this page.' },
|
|
930
|
+
format: { type: 'string', enum: ['json', 'markdown'], description: 'markdown returns the MISSIONS.md board text.' },
|
|
241
931
|
},
|
|
242
932
|
required: ['repoRoot'],
|
|
243
933
|
additionalProperties: false,
|
|
244
934
|
},
|
|
245
|
-
|
|
935
|
+
aliases: { mission_id: ['missionId'], include_gate: ['includeGate'] },
|
|
936
|
+
output: output(SCHEMAS.status),
|
|
246
937
|
async execute(args) {
|
|
247
938
|
const tower = await Tower.open(args.repoRoot)
|
|
248
|
-
|
|
939
|
+
const boardPath = tower.boardFile()
|
|
940
|
+
if (args.missionId) {
|
|
941
|
+
const [status, detail] = await Promise.all([
|
|
942
|
+
tower.status({ missionIds: [args.missionId] }),
|
|
943
|
+
tower.missionDetail(args.missionId),
|
|
944
|
+
])
|
|
945
|
+
return {
|
|
946
|
+
...status,
|
|
947
|
+
missions: status.missions.filter((mission) => mission.id === args.missionId),
|
|
948
|
+
missionId: args.missionId,
|
|
949
|
+
detail,
|
|
950
|
+
boardPath,
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
const state = await tower.load()
|
|
954
|
+
const total = state.missions.length
|
|
955
|
+
const offset = Number.isInteger(args.offset) && args.offset > 0 ? args.offset : 0
|
|
956
|
+
const limit = Number.isInteger(args.limit) ? Math.max(1, Math.min(100, args.limit)) : 20
|
|
957
|
+
const pageIds = state.missions.slice(offset, offset + limit).map((mission) => mission.id)
|
|
958
|
+
const status = await tower.status({ missionIds: pageIds })
|
|
959
|
+
let page = status.missions
|
|
960
|
+
if (args.includeGate === true) {
|
|
961
|
+
const { activity, parentStatus } = await observeActivity(ctx, tower)
|
|
962
|
+
page = await Promise.all(
|
|
963
|
+
page.map(async (mission) => {
|
|
964
|
+
try {
|
|
965
|
+
const gate = await tower.evaluateGate({ missionId: mission.id, activity, parentStatus })
|
|
966
|
+
return {
|
|
967
|
+
...mission,
|
|
968
|
+
gate: {
|
|
969
|
+
ok: gate.ok,
|
|
970
|
+
blockers: (gate.blockers ?? []).map((blocker) => ({ code: blocker.code, detail: blocker.detail })),
|
|
971
|
+
},
|
|
972
|
+
}
|
|
973
|
+
} catch (error) {
|
|
974
|
+
return { ...mission, gate: { ok: false, error: classifyError(error).message } }
|
|
975
|
+
}
|
|
976
|
+
}),
|
|
977
|
+
)
|
|
978
|
+
}
|
|
979
|
+
const result = {
|
|
980
|
+
...status,
|
|
981
|
+
missions: page,
|
|
982
|
+
total,
|
|
983
|
+
offset,
|
|
984
|
+
nextOffset: offset + page.length < total ? offset + page.length : null,
|
|
985
|
+
boardPath,
|
|
986
|
+
}
|
|
987
|
+
if (args.format === 'markdown') {
|
|
988
|
+
const state = await tower.load()
|
|
989
|
+
result.markdown = tower.boardMarkdown(state)
|
|
990
|
+
}
|
|
991
|
+
return result
|
|
249
992
|
},
|
|
250
993
|
})
|
|
251
994
|
|
|
@@ -257,23 +1000,33 @@ export function registerTowerTools(ctx) {
|
|
|
257
1000
|
type: 'object',
|
|
258
1001
|
properties: {
|
|
259
1002
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
260
|
-
missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
|
|
1003
|
+
missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
|
|
1004
|
+
id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
|
|
261
1005
|
role: { type: 'string', enum: ROLE_ENUM, description: 'worker executes; reviewer audits the mission branch.' },
|
|
262
1006
|
name: { type: 'string', description: 'Unique roster name; defaults to <role>-<missionId>.' },
|
|
263
1007
|
instructions: { type: 'string', description: 'Extra tower instructions appended to the briefing.' },
|
|
264
1008
|
worktreeRoot: { type: 'string', description: 'Optional absolute parent directory for the worktree.' },
|
|
265
1009
|
wait: { type: 'boolean', description: 'Wait for the child result instead of returning immediately (testing/debug).' },
|
|
266
|
-
waitMs: { type: 'number', description: 'Max wait with wait=true, default 180000.' },
|
|
1010
|
+
waitMs: { type: 'number', description: 'Max wait with wait=true, default 180000; clamped to spawn.maxWaitMs (default 90000).' },
|
|
1011
|
+
wait_ms: { type: 'number', description: 'Alias for waitMs.' },
|
|
1012
|
+
takeover: { type: 'boolean', description: 'Replace a live worker entry that is no longer running (increments ownerGeneration).' },
|
|
1013
|
+
includeDirtyBase: { type: 'boolean', description: 'Use the recorded snapshotCommit baseline when true (default true).' },
|
|
267
1014
|
},
|
|
268
|
-
required: ['repoRoot'
|
|
1015
|
+
required: ['repoRoot'],
|
|
269
1016
|
additionalProperties: false,
|
|
270
1017
|
},
|
|
271
|
-
|
|
1018
|
+
aliases: {"missionId":["id"],"waitMs":["wait_ms"]},
|
|
1019
|
+
requiresMissionId: true,
|
|
1020
|
+
board: true,
|
|
1021
|
+
output: output(SCHEMAS.spawn),
|
|
272
1022
|
async execute(args, exec) {
|
|
273
1023
|
const tower = await Tower.open(args.repoRoot)
|
|
1024
|
+
const actor = await actorFor(tower, exec)
|
|
1025
|
+
requireTowerActor(actor, 'spawn tower agents')
|
|
274
1026
|
const role = args.role ?? 'worker'
|
|
275
1027
|
if (!ROLE_ENUM.includes(role)) fail(`role must be one of ${ROLE_ENUM.join('|')}`)
|
|
276
|
-
|
|
1028
|
+
// Repository config (if any) already overrides the profile config here.
|
|
1029
|
+
const config = tower.config
|
|
277
1030
|
const preset = role === 'reviewer' ? config.spawn.reviewerPreset : config.spawn.workerPreset
|
|
278
1031
|
const model = role === 'reviewer' ? config.spawn.reviewerModel : config.spawn.workerModel
|
|
279
1032
|
const context = await tower.missionContext(args.missionId)
|
|
@@ -285,137 +1038,278 @@ export function registerTowerTools(ctx) {
|
|
|
285
1038
|
fail(`reviewer requires a completed mission awaiting review; ${context.mission.id} is ${context.mission.status}`)
|
|
286
1039
|
}
|
|
287
1040
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const { mission, worktree } = provisioned
|
|
293
|
-
const contextWithWorktree = {
|
|
294
|
-
...context,
|
|
295
|
-
mission: {
|
|
296
|
-
...context.mission,
|
|
297
|
-
worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
|
|
298
|
-
},
|
|
1041
|
+
if (role === 'worker' && ['merged', 'abandoned'].includes(context.mission.status)) {
|
|
1042
|
+
fail(
|
|
1043
|
+
`mission ${context.mission.id} is ${context.mission.status}; a worker can only be spawned for a planned or active mission`,
|
|
1044
|
+
)
|
|
299
1045
|
}
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
1046
|
+
const spawned = await enqueueSpawn(tower.root, async () => {
|
|
1047
|
+
const view = await tower.rosterView()
|
|
1048
|
+
const inactiveStates = ROSTER_INACTIVE_STATES
|
|
1049
|
+
// Resolve a unique roster name BEFORE any activation. The default name
|
|
1050
|
+
// is derived per role/mission and gets a numeric suffix on rework rounds.
|
|
1051
|
+
let name
|
|
1052
|
+
if (typeof args.name === 'string' && args.name.trim()) {
|
|
1053
|
+
name = args.name.trim()
|
|
1054
|
+
if (view.roster.some((entry) => entry.name === name)) {
|
|
1055
|
+
throw new TowerToolError('invalid-argument', 'roster name already used: ' + name)
|
|
1056
|
+
}
|
|
1057
|
+
} else {
|
|
1058
|
+
const base = role + '-' + args.missionId.toLowerCase()
|
|
1059
|
+
let candidate = base
|
|
1060
|
+
let suffix = 2
|
|
1061
|
+
while (view.roster.some((entry) => entry.name === candidate)) {
|
|
1062
|
+
candidate = base + '-' + suffix
|
|
1063
|
+
suffix += 1
|
|
1064
|
+
}
|
|
1065
|
+
name = candidate
|
|
1066
|
+
}
|
|
1067
|
+
let takeover = args.takeover === true
|
|
1068
|
+
if (role === 'worker') {
|
|
1069
|
+
// Pre-check the single-owner and live-worker rules BEFORE provisioning
|
|
1070
|
+
// or starting a child: a rejection must not leak an activation/worktree.
|
|
1071
|
+
const liveWorkers = view.roster.filter(
|
|
1072
|
+
(entry) => entry.role === 'worker' && !inactiveStates.includes(entry.state),
|
|
1073
|
+
)
|
|
1074
|
+
const sameMission = liveWorkers.filter((entry) => entry.missionId === args.missionId)
|
|
1075
|
+
if (sameMission.length > 1) {
|
|
1076
|
+
throw new TowerToolError(
|
|
1077
|
+
'invalid-transition',
|
|
1078
|
+
'mission ' + args.missionId + ' has ' + sameMission.length + ' live worker entries; inspect tower_roster before spawning',
|
|
1079
|
+
)
|
|
1080
|
+
}
|
|
1081
|
+
if (sameMission.length === 1 && !takeover) {
|
|
1082
|
+
throw new TowerToolError(
|
|
1083
|
+
'invalid-transition',
|
|
1084
|
+
'mission ' + args.missionId + ' already has a live worker "' + sameMission[0].name + '"; stop it with tower_interrupt, or pass takeover:true after it is no longer running',
|
|
1085
|
+
)
|
|
1086
|
+
}
|
|
1087
|
+
if (takeover) {
|
|
1088
|
+
if (sameMission.length === 1) {
|
|
1089
|
+
const worker = sameMission[0]
|
|
1090
|
+
const observed = await observeActivity(ctx, tower)
|
|
1091
|
+
if (observed.parentStatus[worker.parentSessionId] !== 'ok') {
|
|
1092
|
+
throw new TowerToolError(
|
|
1093
|
+
'takeover-refused',
|
|
1094
|
+
'cannot verify that worker ' + worker.name + ' stopped: its parent catalog is unavailable',
|
|
1095
|
+
)
|
|
1096
|
+
}
|
|
1097
|
+
const activity = observed.activity[worker.agentId]
|
|
1098
|
+
if (activity === 'running') {
|
|
1099
|
+
throw new TowerToolError(
|
|
1100
|
+
'takeover-refused',
|
|
1101
|
+
'worker ' + worker.name + ' is still running; call tower_interrupt (or wait) before takeover',
|
|
1102
|
+
)
|
|
1103
|
+
}
|
|
1104
|
+
if (activity === undefined) {
|
|
1105
|
+
throw new TowerToolError(
|
|
1106
|
+
'takeover-refused',
|
|
1107
|
+
'worker ' + worker.name + ' is not in the live catalog; run tower_adopt before takeover',
|
|
1108
|
+
)
|
|
1109
|
+
}
|
|
1110
|
+
} else {
|
|
1111
|
+
takeover = false
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
const replacing = takeover ? sameMission : []
|
|
1115
|
+
const remaining = liveWorkers.filter((entry) => !replacing.includes(entry))
|
|
1116
|
+
const max = Number(config.spawn.maxLiveWorkers) || 4
|
|
1117
|
+
if (remaining.length >= max) {
|
|
1118
|
+
throw new TowerToolError(
|
|
1119
|
+
'worker-limit',
|
|
1120
|
+
'live worker limit reached (' + max + '); stop a worker with tower_interrupt or raise spawn.maxLiveWorkers',
|
|
1121
|
+
)
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
319
1124
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
if (
|
|
329
|
-
|
|
1125
|
+
// Service/provider/preset checks happen before provisioning, so a failed
|
|
1126
|
+
// precheck cannot leave a worktree or roster revision behind.
|
|
1127
|
+
const subagents = ctx.get('subagents')
|
|
1128
|
+
if (!subagents) fail('subagents service is unavailable; cannot spawn')
|
|
1129
|
+
const providers = subagents.list()
|
|
1130
|
+
const provider = config.spawn.provider || (providers.includes('spawn') ? 'spawn' : providers[0])
|
|
1131
|
+
if (!provider) fail('no subagent provider is registered')
|
|
1132
|
+
const presets = ctx.get('agentPresets')
|
|
1133
|
+
if (presets && typeof presets.list === 'function') {
|
|
1134
|
+
const available = await presets.list()
|
|
1135
|
+
if (!available.some((entry) => String(entry.id) === preset)) {
|
|
1136
|
+
fail('agent preset "' + preset + '" is not registered; check the Tower bundle preset rows and restart DSH')
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
if (typeof config.spawn.requireLease === 'string' && config.spawn.requireLease.trim()) {
|
|
1140
|
+
const requiredLease = config.spawn.requireLease.trim()
|
|
1141
|
+
const info = await inspectLeases(tower.commonDir)
|
|
1142
|
+
const lease = info.leases.find((entry) => entry.name === requiredLease && entry.expired !== true)
|
|
1143
|
+
if (!lease) {
|
|
1144
|
+
throw new TowerToolError(
|
|
1145
|
+
'lease-busy',
|
|
1146
|
+
`spawn.requireLease="${requiredLease}" is not held; acquire it with tower_resource before spawning`,
|
|
1147
|
+
)
|
|
1148
|
+
}
|
|
1149
|
+
if (lease.holder !== actor.name && lease.holder !== String(exec.agent.id)) {
|
|
1150
|
+
throw new TowerToolError(
|
|
1151
|
+
'lease-busy',
|
|
1152
|
+
`spawn.requireLease="${requiredLease}" is held by "${lease.holder}", not by this tower`,
|
|
1153
|
+
)
|
|
1154
|
+
}
|
|
330
1155
|
}
|
|
331
|
-
}
|
|
332
1156
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
1157
|
+
const provisioned = await tower.provision({
|
|
1158
|
+
missionId: args.missionId,
|
|
1159
|
+
worktreeRoot: args.worktreeRoot,
|
|
1160
|
+
includeDirtyBase: args.includeDirtyBase !== false,
|
|
1161
|
+
})
|
|
1162
|
+
const { mission, worktree } = provisioned
|
|
1163
|
+
const contextWithWorktree = {
|
|
1164
|
+
...context,
|
|
1165
|
+
mission: {
|
|
1166
|
+
...context.mission,
|
|
1167
|
+
worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
|
|
1168
|
+
},
|
|
1169
|
+
}
|
|
1170
|
+
const briefing =
|
|
1171
|
+
role === 'worker'
|
|
1172
|
+
? workerBriefing({
|
|
1173
|
+
repoRoot: tower.root,
|
|
1174
|
+
base: context.base,
|
|
1175
|
+
mission: contextWithWorktree.mission,
|
|
1176
|
+
deps: context.deps,
|
|
1177
|
+
worktree,
|
|
1178
|
+
instructions: args.instructions,
|
|
1179
|
+
reviews: context.reviews,
|
|
1180
|
+
findings: context.findings,
|
|
1181
|
+
completionReport: context.completionReport,
|
|
1182
|
+
})
|
|
1183
|
+
: reviewerBriefing({
|
|
1184
|
+
repoRoot: tower.root,
|
|
1185
|
+
base: context.base,
|
|
1186
|
+
mission: contextWithWorktree.mission,
|
|
1187
|
+
deps: context.deps,
|
|
1188
|
+
worktree,
|
|
1189
|
+
instructions: args.instructions,
|
|
1190
|
+
reviews: context.reviews,
|
|
1191
|
+
findings: context.findings,
|
|
1192
|
+
completionReport: context.completionReport,
|
|
1193
|
+
})
|
|
337
1194
|
|
|
338
|
-
|
|
339
|
-
provider,
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
cwd: worktree.path,
|
|
343
|
-
agentPreset: preset,
|
|
344
|
-
prompt: [{ type: 'text', text: briefing }],
|
|
345
|
-
parent: exec.agent,
|
|
346
|
-
maxDepth: config.spawn.maxDepth ?? 1,
|
|
347
|
-
...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
|
|
348
|
-
},
|
|
349
|
-
signal: exec.signal,
|
|
350
|
-
delivery: 'parent',
|
|
351
|
-
})
|
|
1195
|
+
const agentOptions = {}
|
|
1196
|
+
for (const key of ['provider', 'model', 'reasoningEffort', 'maxTokens']) {
|
|
1197
|
+
if (model && model[key] !== undefined && model[key] !== null && model[key] !== '') agentOptions[key] = model[key]
|
|
1198
|
+
}
|
|
352
1199
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
})
|
|
1200
|
+
if (role === 'worker' && tower.config.tests?.admission === 'spawn') {
|
|
1201
|
+
const discovery = await runMissionDiscovery(tower, mission)
|
|
1202
|
+
if (discovery.configured && discovery.receipt.ok !== true) {
|
|
1203
|
+
throw new TowerToolError(
|
|
1204
|
+
'tests-failed',
|
|
1205
|
+
`test admission failed for ${mission.id}: ${discovery.receipt.error}; no child was started`,
|
|
1206
|
+
)
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
363
1209
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const report = {
|
|
368
|
-
name,
|
|
369
|
-
agentId,
|
|
370
|
-
role,
|
|
371
|
-
missionId: mission.id,
|
|
372
|
-
preset,
|
|
373
|
-
provider,
|
|
374
|
-
maxDepth: config.spawn.maxDepth ?? 1,
|
|
375
|
-
...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
|
|
376
|
-
worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
|
|
377
|
-
created: provisioned.created,
|
|
378
|
-
briefingChars: briefing.length,
|
|
379
|
-
rosterEntry: entry,
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
if (args.wait === true) {
|
|
383
|
-
const waitMs = Math.max(1000, Math.min(600000, args.waitMs ?? 180000))
|
|
384
|
-
let timer
|
|
385
|
-
const timeout = new Promise((resolve) => {
|
|
386
|
-
timer = setTimeout(() => resolve({ __timeout: true }), waitMs)
|
|
387
|
-
})
|
|
388
|
-
let result
|
|
1210
|
+
let activation = null
|
|
1211
|
+
let agentId = null
|
|
1212
|
+
let entry = null
|
|
389
1213
|
try {
|
|
390
|
-
|
|
1214
|
+
activation = await subagents.startActivation({
|
|
1215
|
+
provider,
|
|
1216
|
+
label: name,
|
|
1217
|
+
request: {
|
|
1218
|
+
cwd: worktree.path,
|
|
1219
|
+
agentPreset: preset,
|
|
1220
|
+
prompt: [{ type: 'text', text: briefing }],
|
|
1221
|
+
parent: exec.agent,
|
|
1222
|
+
maxDepth: config.spawn.maxDepth ?? 1,
|
|
1223
|
+
...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
|
|
1224
|
+
},
|
|
1225
|
+
signal: exec.signal,
|
|
1226
|
+
delivery: 'parent',
|
|
1227
|
+
})
|
|
1228
|
+
agentId = String(activation.childId)
|
|
1229
|
+
entry = await tower.recordSpawn({
|
|
1230
|
+
missionId: mission.id,
|
|
1231
|
+
name,
|
|
1232
|
+
role,
|
|
1233
|
+
agentId,
|
|
1234
|
+
parentSessionId: String(exec.agent.id),
|
|
1235
|
+
cwd: worktree.path,
|
|
1236
|
+
branch: worktree.branch,
|
|
1237
|
+
takeover,
|
|
1238
|
+
})
|
|
391
1239
|
} catch (error) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
1240
|
+
// The child may already be running even when recordSpawn failed (for
|
|
1241
|
+
// example a race on a roster name): stop it so it cannot keep writing
|
|
1242
|
+
// unobserved, then surface the real error.
|
|
1243
|
+
if (agentId) {
|
|
1244
|
+
try {
|
|
1245
|
+
subagents.interrupt(agentId, { kind: 'ancestor', agent: exec.agent })
|
|
1246
|
+
} catch {
|
|
1247
|
+
// best effort; the error below is the actionable one
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
395
1250
|
throw error
|
|
396
|
-
} finally {
|
|
397
|
-
clearTimeout(timer)
|
|
398
1251
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
1252
|
+
|
|
1253
|
+
const recordOutcome = (state, detail) =>
|
|
1254
|
+
tower.recordSpawnOutcome({ missionId: mission.id, name, state, detail }).catch(() => {})
|
|
1255
|
+
|
|
1256
|
+
const report = {
|
|
1257
|
+
name,
|
|
1258
|
+
agentId,
|
|
1259
|
+
role,
|
|
1260
|
+
missionId: mission.id,
|
|
1261
|
+
preset,
|
|
1262
|
+
provider,
|
|
1263
|
+
maxDepth: config.spawn.maxDepth ?? 1,
|
|
1264
|
+
...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
|
|
1265
|
+
worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
|
|
1266
|
+
created: provisioned.created,
|
|
1267
|
+
briefingChars: briefing.length,
|
|
1268
|
+
takeover,
|
|
1269
|
+
rosterEntry: entry,
|
|
409
1270
|
}
|
|
410
|
-
|
|
1271
|
+
|
|
1272
|
+
return { report, activation, recordOutcome }
|
|
1273
|
+
})
|
|
1274
|
+
const { report, activation, recordOutcome } = spawned
|
|
1275
|
+
if (args.wait !== true) {
|
|
411
1276
|
activation.result
|
|
412
1277
|
.then((result) => {
|
|
413
1278
|
const state = result?.stopReason === 'completed' ? 'inactive' : 'failed'
|
|
414
1279
|
return recordOutcome(state, result?.diagnostic ?? result?.stopReason ?? null)
|
|
415
1280
|
})
|
|
416
1281
|
.catch((error) => recordOutcome('failed', String(error?.message ?? error)))
|
|
1282
|
+
return report
|
|
1283
|
+
}
|
|
1284
|
+
const waitMs = Math.max(1000, Math.min(config.spawn.maxWaitMs ?? 90000, args.waitMs ?? 180000))
|
|
1285
|
+
let timer
|
|
1286
|
+
const timeout = new Promise((resolve) => {
|
|
1287
|
+
timer = setTimeout(() => resolve({ __timeout: true }), waitMs)
|
|
1288
|
+
})
|
|
1289
|
+
let result
|
|
1290
|
+
try {
|
|
1291
|
+
result = await Promise.race([activation.result, timeout])
|
|
1292
|
+
} catch (error) {
|
|
1293
|
+
report.stopReason = 'failed'
|
|
1294
|
+
report.diagnostic = String(error?.message ?? error)
|
|
1295
|
+
await recordOutcome('failed', report.diagnostic)
|
|
1296
|
+
throw error
|
|
1297
|
+
} finally {
|
|
1298
|
+
clearTimeout(timer)
|
|
1299
|
+
}
|
|
1300
|
+
if (result && result.__timeout) {
|
|
1301
|
+
report.timedOut = true
|
|
1302
|
+
recordOutcome('unknown', 'spawn wait timed out; check tower_roster')
|
|
1303
|
+
} else {
|
|
1304
|
+
report.stopReason = result?.stopReason ?? null
|
|
1305
|
+
report.output = textOf(result?.output)
|
|
1306
|
+
report.diagnostic = result?.diagnostic ?? null
|
|
1307
|
+
const state = report.stopReason === 'completed' ? 'inactive' : 'failed'
|
|
1308
|
+
if (state === 'failed') report.diagnostic = report.diagnostic ?? report.stopReason
|
|
1309
|
+
await recordOutcome(state, report.diagnostic ?? null)
|
|
417
1310
|
}
|
|
418
1311
|
return report
|
|
1312
|
+
|
|
419
1313
|
},
|
|
420
1314
|
})
|
|
421
1315
|
|
|
@@ -431,7 +1325,7 @@ export function registerTowerTools(ctx) {
|
|
|
431
1325
|
required: ['repoRoot'],
|
|
432
1326
|
additionalProperties: false,
|
|
433
1327
|
},
|
|
434
|
-
output:
|
|
1328
|
+
output: output(SCHEMAS.roster),
|
|
435
1329
|
async execute(args) {
|
|
436
1330
|
const tower = await Tower.open(args.repoRoot)
|
|
437
1331
|
const view = await tower.rosterView()
|
|
@@ -451,19 +1345,60 @@ export function registerTowerTools(ctx) {
|
|
|
451
1345
|
}
|
|
452
1346
|
}
|
|
453
1347
|
|
|
1348
|
+
const staleMs = Number(tower.config.spawn?.staleMs) || 0
|
|
1349
|
+
const now = Date.now()
|
|
454
1350
|
const roster = view.roster.map((entry) => {
|
|
455
1351
|
const observed = activity.get(entry.agentId)
|
|
456
1352
|
const known = parentStatus[entry.parentSessionId] === 'ok'
|
|
1353
|
+
const stale =
|
|
1354
|
+
staleMs > 0 &&
|
|
1355
|
+
observed === undefined &&
|
|
1356
|
+
known &&
|
|
1357
|
+
!['retired', 'failed', 'interrupted', 'missing', 'stale'].includes(entry.state) &&
|
|
1358
|
+
now - Number(entry.spawnedAt ?? 0) > staleMs
|
|
457
1359
|
return {
|
|
458
1360
|
...entry,
|
|
459
1361
|
activity: observed ?? (known ? 'missing' : 'unknown'),
|
|
460
1362
|
observed: observed !== undefined,
|
|
1363
|
+
stale,
|
|
461
1364
|
}
|
|
462
1365
|
})
|
|
463
1366
|
return { revision: view.revision, roster, parentStatus, missions: view.missions }
|
|
464
1367
|
},
|
|
465
1368
|
})
|
|
466
1369
|
|
|
1370
|
+
define(ctx, {
|
|
1371
|
+
name: 'tower_interrupt',
|
|
1372
|
+
description:
|
|
1373
|
+
'Interrupt one running tower roster agent (worker or reviewer) and record it as interrupted in the roster. Only the tower may interrupt. Idempotent: an agent that is not running returns interrupted:false. Use it before takeover after a runaway worker, or to stop work that is no longer wanted.',
|
|
1374
|
+
parameters: {
|
|
1375
|
+
type: 'object',
|
|
1376
|
+
properties: {
|
|
1377
|
+
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
1378
|
+
to: { type: 'string', description: 'Roster name to interrupt.' },
|
|
1379
|
+
agentId: { type: 'string', description: 'Agent/session id to interrupt (alternative to to).' },
|
|
1380
|
+
agent_id: { type: 'string', description: 'Alias for agentId.' },
|
|
1381
|
+
reason: { type: 'string', description: 'Why the agent is being interrupted (recorded in the roster).' },
|
|
1382
|
+
},
|
|
1383
|
+
required: ['repoRoot'],
|
|
1384
|
+
additionalProperties: false,
|
|
1385
|
+
},
|
|
1386
|
+
aliases: { agentId: ['agent_id'] },
|
|
1387
|
+
output: output(SCHEMAS.interrupt),
|
|
1388
|
+
async execute(args, exec) {
|
|
1389
|
+
const tower = await Tower.open(args.repoRoot)
|
|
1390
|
+
const actor = await actorFor(tower, exec)
|
|
1391
|
+
requireTowerActor(actor, 'interrupt tower agents')
|
|
1392
|
+
if (!args.to && !args.agentId) throw new TowerToolError('invalid-argument', 'tower_interrupt requires to (roster name) or agentId')
|
|
1393
|
+
const result = await interruptRosterAgent(ctx, tower, exec, {
|
|
1394
|
+
to: args.to,
|
|
1395
|
+
agentId: args.agentId,
|
|
1396
|
+
reason: args.reason,
|
|
1397
|
+
})
|
|
1398
|
+
return { ok: true, ...result }
|
|
1399
|
+
},
|
|
1400
|
+
})
|
|
1401
|
+
|
|
467
1402
|
define(ctx, {
|
|
468
1403
|
name: 'tower_diff',
|
|
469
1404
|
description:
|
|
@@ -472,13 +1407,16 @@ export function registerTowerTools(ctx) {
|
|
|
472
1407
|
type: 'object',
|
|
473
1408
|
properties: {
|
|
474
1409
|
repoRoot: { type: 'string', description: 'Absolute path to the main checkout root.' },
|
|
475
|
-
missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
|
|
1410
|
+
missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
|
|
1411
|
+
id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
|
|
476
1412
|
maxBytes: { type: 'integer', minimum: 1024, description: 'Patch cap in bytes (default 65536).' },
|
|
477
1413
|
},
|
|
478
|
-
required: ['repoRoot'
|
|
1414
|
+
required: ['repoRoot'],
|
|
479
1415
|
additionalProperties: false,
|
|
480
1416
|
},
|
|
481
|
-
|
|
1417
|
+
aliases: {"missionId":["id"]},
|
|
1418
|
+
requiresMissionId: true,
|
|
1419
|
+
output: output(SCHEMAS.diff),
|
|
482
1420
|
async execute(args) {
|
|
483
1421
|
const tower = await Tower.open(args.repoRoot)
|
|
484
1422
|
return tower.diff({ missionId: args.missionId, maxBytes: args.maxBytes })
|
|
@@ -488,18 +1426,22 @@ export function registerTowerTools(ctx) {
|
|
|
488
1426
|
define(ctx, {
|
|
489
1427
|
name: 'tower_adopt',
|
|
490
1428
|
description:
|
|
491
|
-
'Reconcile the roster with the durable subagent catalog after a restart or new session. Agents that are provably gone are marked retired (records are kept); agents whose parent catalog cannot be read stay unknown and are never
|
|
1429
|
+
'Reconcile the roster with the durable subagent catalog after a restart or new session. Agents that are provably gone are marked retired (records are kept) or missing (retireMissing:false, so the tower can inspect/take over); agents whose parent catalog cannot be read stay unknown and are never changed. Only the tower may adopt.',
|
|
492
1430
|
parameters: {
|
|
493
1431
|
type: 'object',
|
|
494
1432
|
properties: {
|
|
495
1433
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
1434
|
+
retireMissing: { type: 'boolean', description: 'Retire provably gone agents (default true); false marks them missing instead.' },
|
|
496
1435
|
},
|
|
497
1436
|
required: ['repoRoot'],
|
|
498
1437
|
additionalProperties: false,
|
|
499
1438
|
},
|
|
500
|
-
|
|
501
|
-
|
|
1439
|
+
aliases: { retire_missing: ['retireMissing'] },
|
|
1440
|
+
output: output(SCHEMAS.adopt),
|
|
1441
|
+
async execute(args, exec) {
|
|
502
1442
|
const tower = await Tower.open(args.repoRoot)
|
|
1443
|
+
const actor = await actorFor(tower, exec)
|
|
1444
|
+
requireTowerActor(actor, 'adopt the roster')
|
|
503
1445
|
const view = await tower.rosterView()
|
|
504
1446
|
const subagents = ctx.get('subagents')
|
|
505
1447
|
if (!subagents) fail('subagents service is unavailable; cannot adopt')
|
|
@@ -523,10 +1465,13 @@ export function registerTowerTools(ctx) {
|
|
|
523
1465
|
if (parentStatus[entry.parentSessionId] !== 'ok') continue
|
|
524
1466
|
if (!activity.has(entry.agentId)) retiredCandidates.push(entry.agentId)
|
|
525
1467
|
}
|
|
526
|
-
const
|
|
1468
|
+
const retireMissing = args.retireMissing !== false
|
|
1469
|
+
const { retired } = retireMissing ? await tower.markRetired(retiredCandidates) : { retired: [] }
|
|
1470
|
+
const { missing } = retireMissing ? { missing: [] } : await tower.markMissing(retiredCandidates)
|
|
527
1471
|
const after = await tower.rosterView()
|
|
528
1472
|
return {
|
|
529
1473
|
retired,
|
|
1474
|
+
missing,
|
|
530
1475
|
skippedUnknownParents: Object.entries(parentStatus)
|
|
531
1476
|
.filter(([, status]) => status !== 'ok')
|
|
532
1477
|
.map(([parent]) => parent),
|
|
@@ -548,16 +1493,24 @@ export function registerTowerTools(ctx) {
|
|
|
548
1493
|
subject: { type: 'string', description: 'One-line subject.' },
|
|
549
1494
|
body: { type: 'string', description: 'Full message body (markdown).' },
|
|
550
1495
|
scope: { type: 'string', description: 'Optional scope tag, e.g. the mission id.' },
|
|
1496
|
+
interrupt: {
|
|
1497
|
+
type: 'boolean',
|
|
1498
|
+
description: 'Interrupt the recipient\'s current turn before delivering (Kimi-style "message interrupts the running turn"); default false = queue at the next step boundary.',
|
|
1499
|
+
},
|
|
1500
|
+
consentRef: { type: 'string', description: 'Optional audit reference recording who consented to this instruction.' },
|
|
1501
|
+
consent_ref: { type: 'string', description: 'Alias for consentRef.' },
|
|
551
1502
|
},
|
|
552
1503
|
required: ['repoRoot', 'to', 'subject', 'body'],
|
|
553
1504
|
additionalProperties: false,
|
|
554
1505
|
},
|
|
555
|
-
|
|
1506
|
+
aliases: { consentRef: ['consent_ref'] },
|
|
1507
|
+
output: output(SCHEMAS.send),
|
|
556
1508
|
async execute(args, exec) {
|
|
557
1509
|
const tower = await Tower.open(args.repoRoot)
|
|
1510
|
+
checkSendRate(tower.config, args.repoRoot)
|
|
1511
|
+
const actor = await actorFor(tower, exec)
|
|
558
1512
|
const view = await tower.rosterView()
|
|
559
|
-
const
|
|
560
|
-
const from = self ? self.name : 'tower'
|
|
1513
|
+
const from = actor.role === 'tower' ? 'tower' : actor.name
|
|
561
1514
|
if (typeof args.subject !== 'string' || args.subject.trim().length === 0) {
|
|
562
1515
|
fail('tower_send requires a subject')
|
|
563
1516
|
}
|
|
@@ -570,6 +1523,7 @@ export function registerTowerTools(ctx) {
|
|
|
570
1523
|
subject: args.subject.trim(),
|
|
571
1524
|
body: args.body,
|
|
572
1525
|
scope: args.scope,
|
|
1526
|
+
consentRef: args.consentRef,
|
|
573
1527
|
})
|
|
574
1528
|
|
|
575
1529
|
const subagents = ctx.get('subagents')
|
|
@@ -588,6 +1542,9 @@ export function registerTowerTools(ctx) {
|
|
|
588
1542
|
// an unreadable parent catalog only means "do not wake it"
|
|
589
1543
|
}
|
|
590
1544
|
}
|
|
1545
|
+
const sendConfig = tower.config.send ?? {}
|
|
1546
|
+
const interrupt =
|
|
1547
|
+
args.interrupt === undefined ? sendConfig.interruptDefault === true : args.interrupt === true
|
|
591
1548
|
const targets =
|
|
592
1549
|
args.to === 'all' ? roster.map((entry) => entry.name) : [args.to]
|
|
593
1550
|
for (const targetName of targets) {
|
|
@@ -604,6 +1561,16 @@ export function registerTowerTools(ctx) {
|
|
|
604
1561
|
results.push({ target: targetName, pushed: false, reason: 'not running; queued in inbox' })
|
|
605
1562
|
continue
|
|
606
1563
|
}
|
|
1564
|
+
let interrupted = false
|
|
1565
|
+
if (interrupt) {
|
|
1566
|
+
try {
|
|
1567
|
+
subagents.interrupt(entry.agentId, { kind: 'ancestor', agent: exec.agent })
|
|
1568
|
+
interrupted = true
|
|
1569
|
+
} catch (error) {
|
|
1570
|
+
results.push({ target: targetName, pushed: false, reason: `interrupt failed: ${String(error?.message ?? error)}` })
|
|
1571
|
+
continue
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
607
1574
|
try {
|
|
608
1575
|
await subagents.sendMessage(
|
|
609
1576
|
exec.agent,
|
|
@@ -616,7 +1583,7 @@ export function registerTowerTools(ctx) {
|
|
|
616
1583
|
],
|
|
617
1584
|
{ signal: exec.signal },
|
|
618
1585
|
)
|
|
619
|
-
results.push({ target: targetName, pushed: true })
|
|
1586
|
+
results.push({ target: targetName, pushed: true, interrupted })
|
|
620
1587
|
} catch (error) {
|
|
621
1588
|
results.push({ target: targetName, pushed: false, reason: String(error?.message ?? error) })
|
|
622
1589
|
}
|
|
@@ -640,11 +1607,13 @@ export function registerTowerTools(ctx) {
|
|
|
640
1607
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
641
1608
|
limit: { type: 'number', description: 'Max messages to return (default 20), newest first.' },
|
|
642
1609
|
include_read: { type: 'boolean', description: 'Include previously acknowledged messages for history lookup.' },
|
|
1610
|
+
includeRead: { type: 'boolean', description: 'Alias for include_read.' },
|
|
643
1611
|
},
|
|
644
1612
|
required: ['repoRoot'],
|
|
645
1613
|
additionalProperties: false,
|
|
646
1614
|
},
|
|
647
|
-
|
|
1615
|
+
aliases: {"include_read":["includeRead"]},
|
|
1616
|
+
output: output(SCHEMAS.inbox),
|
|
648
1617
|
async execute(args, exec) {
|
|
649
1618
|
const tower = await Tower.open(args.repoRoot)
|
|
650
1619
|
const view = await tower.rosterView()
|
|
@@ -657,24 +1626,64 @@ export function registerTowerTools(ctx) {
|
|
|
657
1626
|
define(ctx, {
|
|
658
1627
|
name: 'tower_complete',
|
|
659
1628
|
description:
|
|
660
|
-
'Worker-only: report mission completion. Enforced by the store: every task done or dropped, no blockers, the mission branch has at least one commit since its spawn base, and no unread inbox messages. Stores the full report; completion means awaiting review, not merged.',
|
|
1629
|
+
'Worker-only: report mission completion. Enforced by the store: every task done or dropped, no blockers, the current owner, the mission branch has at least one commit since its spawn base, and no unread inbox messages. checks records verifiable evidence (name/command/exitCode/evidence); gates.requireChecks can require at least one before merge. Stores the full report; completion means awaiting review, not merged.',
|
|
661
1630
|
parameters: {
|
|
662
1631
|
type: 'object',
|
|
663
1632
|
properties: {
|
|
664
1633
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
665
|
-
missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
|
|
1634
|
+
missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
|
|
1635
|
+
id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
|
|
666
1636
|
report: { type: 'string', description: 'Full completion report for the tower and the next review round.' },
|
|
1637
|
+
checks: {
|
|
1638
|
+
type: 'array',
|
|
1639
|
+
description: 'Optional evidence ledger entries: {name, command?, exitCode?, evidence?}.',
|
|
1640
|
+
items: {
|
|
1641
|
+
type: 'object',
|
|
1642
|
+
properties: {
|
|
1643
|
+
name: { type: 'string', description: 'What was checked.' },
|
|
1644
|
+
command: { type: 'string', description: 'Command or method used.' },
|
|
1645
|
+
exitCode: { type: 'integer', description: 'Observed exit code.' },
|
|
1646
|
+
evidence: { type: 'string', description: 'Short evidence line/log reference.' },
|
|
1647
|
+
},
|
|
1648
|
+
required: ['name'],
|
|
1649
|
+
additionalProperties: false,
|
|
1650
|
+
},
|
|
1651
|
+
},
|
|
667
1652
|
},
|
|
668
|
-
required: ['repoRoot', '
|
|
1653
|
+
required: ['repoRoot', 'report'],
|
|
669
1654
|
additionalProperties: false,
|
|
670
1655
|
},
|
|
671
|
-
|
|
1656
|
+
aliases: {"missionId":["id"]},
|
|
1657
|
+
requiresMissionId: true,
|
|
1658
|
+
board: true,
|
|
1659
|
+
output: output(SCHEMAS.complete),
|
|
672
1660
|
async execute(args, exec) {
|
|
673
1661
|
const tower = await Tower.open(args.repoRoot)
|
|
1662
|
+
// Validate authorization/state before executing repository-controlled
|
|
1663
|
+
// test code or writing a test receipt.
|
|
1664
|
+
await tower.checkCompletion({
|
|
1665
|
+
missionId: args.missionId,
|
|
1666
|
+
agentId: String(exec.agent.id),
|
|
1667
|
+
report: args.report,
|
|
1668
|
+
})
|
|
1669
|
+
const context = await tower.missionContext(args.missionId)
|
|
1670
|
+
const checks = [...(Array.isArray(args.checks) ? args.checks : [])]
|
|
1671
|
+
const testsConfig = tower.config.tests ?? {}
|
|
1672
|
+
if (testsConfig.enabled !== false && testsConfig.admission === 'complete') {
|
|
1673
|
+
const discovery = await runMissionDiscovery(tower, { id: args.missionId, worktree: context.mission.worktree })
|
|
1674
|
+
if (discovery.configured && discovery.receipt.ok !== true) {
|
|
1675
|
+
throw new TowerToolError('tests-failed', `test discovery failed for ${args.missionId}: ${discovery.receipt.error}`)
|
|
1676
|
+
}
|
|
1677
|
+
if (discovery.configured && testsConfig.autoChecks !== false && discovery.check) {
|
|
1678
|
+
if (checks.length >= 20) checks[19] = discovery.check
|
|
1679
|
+
else checks.push(discovery.check)
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
674
1682
|
return tower.complete({
|
|
675
1683
|
missionId: args.missionId,
|
|
676
1684
|
agentId: String(exec.agent.id),
|
|
677
1685
|
report: args.report,
|
|
1686
|
+
checks,
|
|
678
1687
|
})
|
|
679
1688
|
},
|
|
680
1689
|
})
|
|
@@ -682,7 +1691,7 @@ export function registerTowerTools(ctx) {
|
|
|
682
1691
|
define(ctx, {
|
|
683
1692
|
name: 'tower_finding',
|
|
684
1693
|
description:
|
|
685
|
-
'File a structured finding (bug / improve / vuln / idea) for the tower to route. Use this for anything notable OUTSIDE your mission scope instead of editing out-of-scope files. Severity is one of low|medium|high|critical.',
|
|
1694
|
+
'File a structured finding (bug / improve / vuln / idea) for the tower to route. Use this for anything notable OUTSIDE your mission scope instead of editing out-of-scope files. Severity is one of low|medium|high|critical. Use tower_findings to list, read, or close findings later.',
|
|
686
1695
|
parameters: {
|
|
687
1696
|
type: 'object',
|
|
688
1697
|
properties: {
|
|
@@ -694,12 +1703,15 @@ export function registerTowerTools(ctx) {
|
|
|
694
1703
|
location: { type: 'string', description: 'File/symbol the finding concerns.' },
|
|
695
1704
|
details: { type: 'string', description: 'Full details: evidence, reproduction, impact.' },
|
|
696
1705
|
suggested_fix: { type: 'string', description: 'What you would do about it.' },
|
|
697
|
-
missionId: { type: 'string', description: 'Optional mission this finding relates to.' },
|
|
1706
|
+
missionId: { type: 'string', description: 'Optional mission this finding relates to (alias: mission_id).' },
|
|
1707
|
+
mission_id: { type: 'string', description: 'Alias for missionId.' },
|
|
698
1708
|
},
|
|
699
1709
|
required: ['repoRoot', 'type', 'title', 'summary', 'details', 'suggested_fix'],
|
|
700
1710
|
additionalProperties: false,
|
|
701
1711
|
},
|
|
702
|
-
|
|
1712
|
+
aliases: {"missionId":["mission_id"]},
|
|
1713
|
+
board: true,
|
|
1714
|
+
output: output(SCHEMAS.finding),
|
|
703
1715
|
async execute(args, exec) {
|
|
704
1716
|
const tower = await Tower.open(args.repoRoot)
|
|
705
1717
|
const view = await tower.rosterView()
|
|
@@ -718,6 +1730,51 @@ export function registerTowerTools(ctx) {
|
|
|
718
1730
|
},
|
|
719
1731
|
})
|
|
720
1732
|
|
|
1733
|
+
define(ctx, {
|
|
1734
|
+
name: 'tower_findings',
|
|
1735
|
+
description:
|
|
1736
|
+
'List, read, or update findings for a repository. Without id it lists findings newest-first with counts by status (filters: status, type, severity, missionId, open, limit). With id it returns that finding; with id plus status and/or note it records a lifecycle transition. Statuses are open -> triaged -> fixed|wontfix, and fixed/wontfix can be reopened to open. Only the tower or a worker may change a finding; reviewers may only list and read. Read-only when no id is given.',
|
|
1737
|
+
parameters: {
|
|
1738
|
+
type: 'object',
|
|
1739
|
+
properties: {
|
|
1740
|
+
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
1741
|
+
id: { type: 'string', description: 'Finding id, e.g. F1. When present the call reads or updates that finding.' },
|
|
1742
|
+
status: {
|
|
1743
|
+
type: 'string',
|
|
1744
|
+
enum: ['open', 'triaged', 'fixed', 'wontfix', 'all'],
|
|
1745
|
+
description: 'With id: target status. Without id: filter (default all).',
|
|
1746
|
+
},
|
|
1747
|
+
note: { type: 'string', description: 'Optional note recorded together with an update.' },
|
|
1748
|
+
type: { type: 'string', enum: ['bug', 'improve', 'vuln', 'idea'], description: 'Filter by finding type.' },
|
|
1749
|
+
severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'], description: 'Filter by severity.' },
|
|
1750
|
+
missionId: { type: 'string', description: 'Filter by mission id (alias: mission_id).' },
|
|
1751
|
+
mission_id: { type: 'string', description: 'Alias for missionId.' },
|
|
1752
|
+
open: { type: 'boolean', description: 'Only findings that are not fixed or wontfix.' },
|
|
1753
|
+
limit: { type: 'integer', minimum: 1, maximum: 200, description: 'Max findings to list (default 50).' },
|
|
1754
|
+
},
|
|
1755
|
+
required: ['repoRoot'],
|
|
1756
|
+
additionalProperties: false,
|
|
1757
|
+
},
|
|
1758
|
+
aliases: {"missionId":["mission_id"]},
|
|
1759
|
+
output: output(SCHEMAS.findings),
|
|
1760
|
+
async execute(args, exec) {
|
|
1761
|
+
const tower = await Tower.open(args.repoRoot)
|
|
1762
|
+
const view = await tower.rosterView()
|
|
1763
|
+
const self = view.roster.find((entry) => entry.agentId === String(exec.agent.id))
|
|
1764
|
+
return tower.findings({
|
|
1765
|
+
id: args.id,
|
|
1766
|
+
status: args.status,
|
|
1767
|
+
note: args.note,
|
|
1768
|
+
type: args.type,
|
|
1769
|
+
severity: args.severity,
|
|
1770
|
+
missionId: args.missionId,
|
|
1771
|
+
open: args.open,
|
|
1772
|
+
limit: args.limit,
|
|
1773
|
+
actor: { name: self ? self.name : 'tower', role: self ? self.role : 'tower' },
|
|
1774
|
+
})
|
|
1775
|
+
},
|
|
1776
|
+
})
|
|
1777
|
+
|
|
721
1778
|
define(ctx, {
|
|
722
1779
|
name: 'tower_review',
|
|
723
1780
|
description:
|
|
@@ -726,7 +1783,8 @@ export function registerTowerTools(ctx) {
|
|
|
726
1783
|
type: 'object',
|
|
727
1784
|
properties: {
|
|
728
1785
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
729
|
-
missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
|
|
1786
|
+
missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
|
|
1787
|
+
id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
|
|
730
1788
|
status: { type: 'string', description: 'Verdict: "clean", or "p1-Nitems" / "p2-Nitems" with the number of findings at that priority.' },
|
|
731
1789
|
merge: { type: 'string', enum: ['merge', 'fix-then-merge', 'hold'], description: 'Merge recommendation for the tower.' },
|
|
732
1790
|
findings: { type: 'string', description: 'Full findings text (markdown); write "none" when clean.' },
|
|
@@ -734,10 +1792,13 @@ export function registerTowerTools(ctx) {
|
|
|
734
1792
|
decision: { type: 'string', description: 'The reasoning behind your verdict.' },
|
|
735
1793
|
breakGlassReason: { type: 'string', description: 'Tower-only exceptional self-review: why no independent reviewer was used.' },
|
|
736
1794
|
},
|
|
737
|
-
required: ['repoRoot', '
|
|
1795
|
+
required: ['repoRoot', 'status', 'merge', 'findings', 'decision'],
|
|
738
1796
|
additionalProperties: false,
|
|
739
1797
|
},
|
|
740
|
-
|
|
1798
|
+
aliases: {"missionId":["id"]},
|
|
1799
|
+
requiresMissionId: true,
|
|
1800
|
+
board: true,
|
|
1801
|
+
output: output(SCHEMAS.review),
|
|
741
1802
|
async execute(args, exec) {
|
|
742
1803
|
const tower = await Tower.open(args.repoRoot)
|
|
743
1804
|
return tower.submitReview({
|
|
@@ -761,23 +1822,26 @@ export function registerTowerTools(ctx) {
|
|
|
761
1822
|
type: 'object',
|
|
762
1823
|
properties: {
|
|
763
1824
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
764
|
-
missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
|
|
1825
|
+
missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
|
|
1826
|
+
id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
|
|
765
1827
|
},
|
|
766
|
-
required: ['repoRoot'
|
|
1828
|
+
required: ['repoRoot'],
|
|
767
1829
|
additionalProperties: false,
|
|
768
1830
|
},
|
|
769
|
-
|
|
1831
|
+
aliases: {"missionId":["id"]},
|
|
1832
|
+
requiresMissionId: true,
|
|
1833
|
+
output: output(SCHEMAS.preflight),
|
|
770
1834
|
async execute(args) {
|
|
771
1835
|
const tower = await Tower.open(args.repoRoot)
|
|
772
|
-
const { activity } = await observeActivity(ctx, tower)
|
|
773
|
-
const gate = await tower.evaluateGate({ missionId: args.missionId, activity })
|
|
1836
|
+
const { activity, parentStatus } = await observeActivity(ctx, tower)
|
|
1837
|
+
const gate = await tower.evaluateGate({ missionId: args.missionId, activity, parentStatus })
|
|
774
1838
|
return {
|
|
775
1839
|
ok: gate.ok,
|
|
776
1840
|
blockers: gate.blockers,
|
|
777
1841
|
missionId: args.missionId,
|
|
778
|
-
branchTip: gate.branchTip,
|
|
779
|
-
baseTip: gate.baseTip,
|
|
780
|
-
changedFiles: gate.changedFiles,
|
|
1842
|
+
branchTip: gate.branchTip ?? null,
|
|
1843
|
+
baseTip: gate.baseTip ?? null,
|
|
1844
|
+
changedFiles: gate.changedFiles ?? [],
|
|
781
1845
|
review: gate.review
|
|
782
1846
|
? {
|
|
783
1847
|
round: gate.review.round,
|
|
@@ -799,21 +1863,32 @@ export function registerTowerTools(ctx) {
|
|
|
799
1863
|
type: 'object',
|
|
800
1864
|
properties: {
|
|
801
1865
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
802
|
-
missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
|
|
1866
|
+
missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
|
|
1867
|
+
id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
|
|
803
1868
|
timeoutMs: {
|
|
804
1869
|
type: 'integer',
|
|
805
1870
|
minimum: 1000,
|
|
806
1871
|
description: 'Optional timeout for the merge command in milliseconds, including pre-merge hooks (default 15 minutes).',
|
|
807
1872
|
},
|
|
1873
|
+
timeout_ms: {
|
|
1874
|
+
type: 'integer',
|
|
1875
|
+
minimum: 1000,
|
|
1876
|
+
description: 'Alias for timeoutMs.',
|
|
1877
|
+
},
|
|
808
1878
|
},
|
|
809
|
-
required: ['repoRoot'
|
|
1879
|
+
required: ['repoRoot'],
|
|
810
1880
|
additionalProperties: false,
|
|
811
1881
|
},
|
|
812
|
-
|
|
813
|
-
|
|
1882
|
+
aliases: {"missionId":["id"],"timeoutMs":["timeout_ms"]},
|
|
1883
|
+
requiresMissionId: true,
|
|
1884
|
+
board: true,
|
|
1885
|
+
output: output(SCHEMAS.merge),
|
|
1886
|
+
async execute(args, exec) {
|
|
814
1887
|
const tower = await Tower.open(args.repoRoot)
|
|
815
|
-
const
|
|
816
|
-
|
|
1888
|
+
const actor = await actorFor(tower, exec)
|
|
1889
|
+
requireTowerActor(actor, 'merge missions')
|
|
1890
|
+
const { activity, parentStatus } = await observeActivity(ctx, tower)
|
|
1891
|
+
return tower.merge({ missionId: args.missionId, activity, parentStatus, timeoutMs: args.timeoutMs })
|
|
817
1892
|
},
|
|
818
1893
|
})
|
|
819
1894
|
|
|
@@ -825,14 +1900,20 @@ export function registerTowerTools(ctx) {
|
|
|
825
1900
|
type: 'object',
|
|
826
1901
|
properties: {
|
|
827
1902
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
828
|
-
missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
|
|
1903
|
+
missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
|
|
1904
|
+
id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
|
|
829
1905
|
},
|
|
830
|
-
required: ['repoRoot'
|
|
1906
|
+
required: ['repoRoot'],
|
|
831
1907
|
additionalProperties: false,
|
|
832
1908
|
},
|
|
833
|
-
|
|
834
|
-
|
|
1909
|
+
aliases: {"missionId":["id"]},
|
|
1910
|
+
requiresMissionId: true,
|
|
1911
|
+
board: true,
|
|
1912
|
+
output: output(SCHEMAS.rebase),
|
|
1913
|
+
async execute(args, exec) {
|
|
835
1914
|
const tower = await Tower.open(args.repoRoot)
|
|
1915
|
+
const actor = await actorFor(tower, exec)
|
|
1916
|
+
requireTowerActor(actor, 'rebase missions')
|
|
836
1917
|
const { activity } = await observeActivity(ctx, tower)
|
|
837
1918
|
return tower.rebase({ missionId: args.missionId, activity })
|
|
838
1919
|
},
|
|
@@ -849,13 +1930,18 @@ export function registerTowerTools(ctx) {
|
|
|
849
1930
|
force: { type: 'boolean', description: 'Remove worktrees that contain uncommitted changes (never a live agent).' },
|
|
850
1931
|
exclude: { type: 'array', items: { type: 'string' }, description: 'Mission ids to keep.' },
|
|
851
1932
|
dry_run: { type: 'boolean', description: 'Preview remove/keep decisions without changing disk or state.' },
|
|
1933
|
+
dryRun: { type: 'boolean', description: 'Alias for dry_run.' },
|
|
852
1934
|
},
|
|
853
1935
|
required: ['repoRoot'],
|
|
854
1936
|
additionalProperties: false,
|
|
855
1937
|
},
|
|
856
|
-
|
|
857
|
-
|
|
1938
|
+
aliases: {"dry_run":["dryRun"]},
|
|
1939
|
+
board: true,
|
|
1940
|
+
output: output(SCHEMAS.teardown),
|
|
1941
|
+
async execute(args, exec) {
|
|
858
1942
|
const tower = await Tower.open(args.repoRoot)
|
|
1943
|
+
const actor = await actorFor(tower, exec)
|
|
1944
|
+
requireTowerActor(actor, 'tear down worktrees')
|
|
859
1945
|
const { activity } = await observeActivity(ctx, tower)
|
|
860
1946
|
return tower.teardown({
|
|
861
1947
|
force: args.force === true,
|
|
@@ -865,4 +1951,300 @@ export function registerTowerTools(ctx) {
|
|
|
865
1951
|
})
|
|
866
1952
|
},
|
|
867
1953
|
})
|
|
1954
|
+
|
|
1955
|
+
define(ctx, {
|
|
1956
|
+
name: 'tower_doctor',
|
|
1957
|
+
description:
|
|
1958
|
+
'Read-only environment and repository health check: git, state schema, config, presets/subagents/HMR, workspace lock, roster staleness, worktree ownership, test discovery, leases. Returns checks with pass/warn/fail and an environment contract. Never writes state.',
|
|
1959
|
+
parameters: {
|
|
1960
|
+
type: 'object',
|
|
1961
|
+
properties: { repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' } },
|
|
1962
|
+
required: ['repoRoot'],
|
|
1963
|
+
additionalProperties: false,
|
|
1964
|
+
},
|
|
1965
|
+
output: output(SCHEMAS.doctor),
|
|
1966
|
+
async execute(args, exec) {
|
|
1967
|
+
const tower = await Tower.open(args.repoRoot)
|
|
1968
|
+
await actorFor(tower, exec)
|
|
1969
|
+
return runDoctor({ tower, ctx, agent: exec.agent })
|
|
1970
|
+
},
|
|
1971
|
+
})
|
|
1972
|
+
|
|
1973
|
+
define(ctx, {
|
|
1974
|
+
name: 'tower_test',
|
|
1975
|
+
description:
|
|
1976
|
+
'Run the mission worktree\'s declared tower.testDiscovery (package.json) and record the receipt. Returns ok:false with diagnostics when discovery fails; does not change mission status. Use tower_complete for automatic admission checks.',
|
|
1977
|
+
parameters: {
|
|
1978
|
+
type: 'object',
|
|
1979
|
+
properties: {
|
|
1980
|
+
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
1981
|
+
missionId: { type: 'string', description: 'Mission id, e.g. M1 (alias: id).' },
|
|
1982
|
+
id: { type: 'string', description: 'Alias for missionId.' },
|
|
1983
|
+
},
|
|
1984
|
+
required: ['repoRoot', 'missionId'],
|
|
1985
|
+
additionalProperties: false,
|
|
1986
|
+
},
|
|
1987
|
+
aliases: { missionId: ['id'] },
|
|
1988
|
+
requiresMissionId: true,
|
|
1989
|
+
output: output(SCHEMAS.test),
|
|
1990
|
+
async execute(args, exec) {
|
|
1991
|
+
const tower = await Tower.open(args.repoRoot)
|
|
1992
|
+
const actor = await actorFor(tower, exec)
|
|
1993
|
+
const context = await tower.missionContext(args.missionId)
|
|
1994
|
+
if (!context.mission.worktree) fail(`mission ${args.missionId} has no worktree`)
|
|
1995
|
+
if (actor.role === 'reviewer') fail('reviewers may not run test discovery')
|
|
1996
|
+
if (actor.role === 'worker' && context.mission.owner && context.mission.owner !== actor.name) {
|
|
1997
|
+
fail(`worker ${actor.name} does not own mission ${args.missionId}`)
|
|
1998
|
+
}
|
|
1999
|
+
await tower.assertWorktree(context.mission.worktree)
|
|
2000
|
+
const discovery = await runMissionDiscovery(tower, context.mission)
|
|
2001
|
+
if (!discovery.configured) {
|
|
2002
|
+
return {
|
|
2003
|
+
ok: false,
|
|
2004
|
+
configured: false,
|
|
2005
|
+
error: discovery.error ?? 'tower.testDiscovery is not configured in the worktree package.json',
|
|
2006
|
+
tests: [],
|
|
2007
|
+
commands: [],
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
const receipt = discovery.receipt
|
|
2011
|
+
return {
|
|
2012
|
+
ok: receipt.ok === true,
|
|
2013
|
+
configured: true,
|
|
2014
|
+
treeTip: receipt.treeTip ?? null,
|
|
2015
|
+
tests: receipt.tests ?? [],
|
|
2016
|
+
commands: receipt.commands ?? [],
|
|
2017
|
+
error: receipt.error ?? undefined,
|
|
2018
|
+
receipt,
|
|
2019
|
+
checks: discovery.check ? [discovery.check] : [],
|
|
2020
|
+
}
|
|
2021
|
+
},
|
|
2022
|
+
})
|
|
2023
|
+
|
|
2024
|
+
define(ctx, {
|
|
2025
|
+
name: 'tower_batch',
|
|
2026
|
+
description:
|
|
2027
|
+
'Manage finite evidence batches: create freezes missionIds/requiredChecks/receiptPaths, status summarizes members, close verifies every required check and receipt hash, abandon retires a stale open batch. Membership and exclusions are immutable after create; close/abandon never remove missions.',
|
|
2028
|
+
parameters: {
|
|
2029
|
+
type: 'object',
|
|
2030
|
+
properties: {
|
|
2031
|
+
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
2032
|
+
action: { type: 'string', enum: ['create', 'status', 'close', 'abandon'], description: 'Batch operation.' },
|
|
2033
|
+
missionIds: { type: 'array', items: { type: 'string' }, description: 'Exact mission ids in this batch (create).' },
|
|
2034
|
+
exclusions: { type: 'array', items: { type: 'string' }, description: 'Paused legacy mission ids excluded from the batch.' },
|
|
2035
|
+
requiredChecks: { type: 'array', items: { type: 'string' }, description: 'Check names every build member must record (default ["check"]).' },
|
|
2036
|
+
receiptPaths: { type: 'array', items: { type: 'string' }, description: 'Exact relative receipt files checked at close.' },
|
|
2037
|
+
batchId: { type: 'string', description: 'Batch id (status/close/abandon).' },
|
|
2038
|
+
reason: { type: 'string', description: 'Reason recorded when abandoning a batch.' },
|
|
2039
|
+
checks: { type: 'array', items: { type: 'object' }, description: 'Close-time extra check evidence.' },
|
|
2040
|
+
treeTip: { type: 'string', description: 'Expected reviewed/tested commit for close evidence.' },
|
|
2041
|
+
receiptHashes: { type: 'object', description: 'Relative receipt path -> sha256 observed by the caller.' },
|
|
2042
|
+
},
|
|
2043
|
+
required: ['repoRoot', 'action'],
|
|
2044
|
+
additionalProperties: false,
|
|
2045
|
+
},
|
|
2046
|
+
board: true,
|
|
2047
|
+
output: output(SCHEMAS.batch),
|
|
2048
|
+
async execute(args, exec) {
|
|
2049
|
+
const tower = await Tower.open(args.repoRoot)
|
|
2050
|
+
const actor = await actorFor(tower, exec)
|
|
2051
|
+
if (args.action === 'status') {
|
|
2052
|
+
const state = await tower.load()
|
|
2053
|
+
const batch = args.batchId
|
|
2054
|
+
? (state.batches ?? []).find((entry) => entry.id === args.batchId)
|
|
2055
|
+
: null
|
|
2056
|
+
if (args.batchId && !batch) fail(`unknown batch "${args.batchId}"`)
|
|
2057
|
+
return {
|
|
2058
|
+
ok: true,
|
|
2059
|
+
action: 'status',
|
|
2060
|
+
batches: (state.batches ?? []).map((entry) => summarizeBatch(state, entry)),
|
|
2061
|
+
...(batch ? { batch: summarizeBatch(state, batch) } : {}),
|
|
2062
|
+
...(args.batchId ? { summary: summarizeBatch(state, batch) } : {}),
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
requireTowerActor(actor, args.action === 'create' ? 'create a batch' : args.action === 'abandon' ? 'abandon a batch' : 'close a batch')
|
|
2066
|
+
if (args.action === 'create') {
|
|
2067
|
+
const batch = await tower.createBatch(args, actor.name)
|
|
2068
|
+
return { ok: true, action: 'create', batch, batches: (await tower.batchStatus()).batches }
|
|
2069
|
+
}
|
|
2070
|
+
if (typeof args.batchId !== 'string' || args.batchId.trim().length === 0) {
|
|
2071
|
+
fail(`tower_batch ${args.action} requires batchId`)
|
|
2072
|
+
}
|
|
2073
|
+
if (args.action === 'abandon') {
|
|
2074
|
+
const result = await tower.abandonBatch(args.batchId.trim(), actor.name, args.reason)
|
|
2075
|
+
return { ok: true, action: 'abandon', batch: result.batch, blockers: [] }
|
|
2076
|
+
}
|
|
2077
|
+
const result = await tower.closeBatch(args.batchId.trim(), {
|
|
2078
|
+
checks: args.checks,
|
|
2079
|
+
treeTip: args.treeTip,
|
|
2080
|
+
receiptHashes: args.receiptHashes,
|
|
2081
|
+
}, actor.name)
|
|
2082
|
+
return {
|
|
2083
|
+
ok: result.ok,
|
|
2084
|
+
action: 'close',
|
|
2085
|
+
batch: result.batch,
|
|
2086
|
+
blockers: (result.blockers ?? []).map((blocker) => ({
|
|
2087
|
+
code: blocker.code ?? 'batch-invalid',
|
|
2088
|
+
detail: blocker.detail ?? blocker.message ?? String(blocker),
|
|
2089
|
+
...(blocker.cleanup ? { cleanup: blocker.cleanup } : {}),
|
|
2090
|
+
...(blocker.timeout ? { timeout: true } : {}),
|
|
2091
|
+
})),
|
|
2092
|
+
checks: result.checks ?? [],
|
|
2093
|
+
}
|
|
2094
|
+
},
|
|
2095
|
+
})
|
|
2096
|
+
|
|
2097
|
+
define(ctx, {
|
|
2098
|
+
name: 'tower_resource',
|
|
2099
|
+
description:
|
|
2100
|
+
'Advisory resource leases per repository (coordination only; no OS CPU/memory hard cap). acquire/release require a token and are tower-only; status is read-only. Expired leases are never taken over silently.',
|
|
2101
|
+
parameters: {
|
|
2102
|
+
type: 'object',
|
|
2103
|
+
properties: {
|
|
2104
|
+
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
2105
|
+
action: { type: 'string', enum: ['acquire', 'release', 'status'], description: 'Lease operation.' },
|
|
2106
|
+
name: { type: 'string', description: 'Lease name, e.g. plugin-load or heavy-ci.' },
|
|
2107
|
+
holder: { type: 'string', description: 'Holder label (defaults to the caller).' },
|
|
2108
|
+
token: { type: 'string', description: 'Release token; generated on acquire when omitted.' },
|
|
2109
|
+
ttlMs: { type: 'integer', minimum: 1000, description: 'Lease lifetime (default leases.ttlMs).' },
|
|
2110
|
+
force: { type: 'boolean', description: 'Explicitly release an expired lease (token still required).' },
|
|
2111
|
+
},
|
|
2112
|
+
required: ['repoRoot', 'action'],
|
|
2113
|
+
additionalProperties: false,
|
|
2114
|
+
},
|
|
2115
|
+
output: output(SCHEMAS.resource),
|
|
2116
|
+
async execute(args, exec) {
|
|
2117
|
+
const tower = await Tower.open(args.repoRoot)
|
|
2118
|
+
const actor = await actorFor(tower, exec)
|
|
2119
|
+
if (args.action === 'status') {
|
|
2120
|
+
const info = await inspectLeases(tower.commonDir)
|
|
2121
|
+
return { ok: true, action: 'status', leases: info.leases, ...info }
|
|
2122
|
+
}
|
|
2123
|
+
requireTowerActor(actor, args.action === 'acquire' ? 'acquire a lease' : 'release a lease')
|
|
2124
|
+
if (typeof args.name !== 'string' || args.name.trim().length === 0) fail('tower_resource requires name')
|
|
2125
|
+
const name = args.name.trim()
|
|
2126
|
+
const ttlMs = Number(args.ttlMs ?? tower.config.leases?.ttlMs ?? 900000)
|
|
2127
|
+
if (args.action === 'acquire') {
|
|
2128
|
+
const lease = await acquireLease(tower.commonDir, {
|
|
2129
|
+
name,
|
|
2130
|
+
holder: args.holder ?? actor.name,
|
|
2131
|
+
token: args.token ?? crypto.randomUUID(),
|
|
2132
|
+
pid: process.pid,
|
|
2133
|
+
ttlMs,
|
|
2134
|
+
})
|
|
2135
|
+
return { ok: true, action: 'acquire', lease }
|
|
2136
|
+
}
|
|
2137
|
+
const token = args.token
|
|
2138
|
+
if (typeof token !== 'string' || token.length === 0) fail('tower_resource release requires token')
|
|
2139
|
+
const released = await releaseLease(tower.commonDir, { name, token, force: args.force === true })
|
|
2140
|
+
return { ok: true, action: 'release', released, leases: (await inspectLeases(tower.commonDir)).leases }
|
|
2141
|
+
},
|
|
2142
|
+
})
|
|
2143
|
+
|
|
2144
|
+
define(ctx, {
|
|
2145
|
+
name: 'tower_import_legacy',
|
|
2146
|
+
description:
|
|
2147
|
+
'Read-only import of a Tower Codex (schemaVersion 1) or DSH legacy state file. Stores the full payload under <common>/dsh-tower/legacy/<sha256>.json and records only a summary in state.legacySnapshots; never activates, adopts, or resumes paused missions.',
|
|
2148
|
+
parameters: {
|
|
2149
|
+
type: 'object',
|
|
2150
|
+
properties: {
|
|
2151
|
+
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
2152
|
+
sourcePath: { type: 'string', description: 'Absolute path to the legacy JSON state file.' },
|
|
2153
|
+
},
|
|
2154
|
+
required: ['repoRoot', 'sourcePath'],
|
|
2155
|
+
additionalProperties: false,
|
|
2156
|
+
},
|
|
2157
|
+
board: true,
|
|
2158
|
+
output: output(SCHEMAS.importLegacy),
|
|
2159
|
+
async execute(args, exec) {
|
|
2160
|
+
const tower = await Tower.open(args.repoRoot)
|
|
2161
|
+
const actor = await actorFor(tower, exec)
|
|
2162
|
+
requireTowerActor(actor, 'import a legacy snapshot')
|
|
2163
|
+
const imported = await importLegacySnapshot({
|
|
2164
|
+
repoRoot: tower.root,
|
|
2165
|
+
commonDir: tower.commonDir,
|
|
2166
|
+
sourcePath: args.sourcePath,
|
|
2167
|
+
})
|
|
2168
|
+
const recorded = await tower.recordLegacySnapshot(imported.snapshot)
|
|
2169
|
+
return { ok: true, summary: imported.summary, snapshot: recorded.snapshot }
|
|
2170
|
+
},
|
|
2171
|
+
})
|
|
2172
|
+
|
|
2173
|
+
define(ctx, {
|
|
2174
|
+
name: 'tower_legacy_map',
|
|
2175
|
+
description:
|
|
2176
|
+
'Audit-only mapping between a legacy snapshot id and a current mission. map records sourceHash/oldId/newMissionId; status lists mappings. Mapping never adopts or resumes a legacy mission.',
|
|
2177
|
+
parameters: {
|
|
2178
|
+
type: 'object',
|
|
2179
|
+
properties: {
|
|
2180
|
+
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
2181
|
+
action: { type: 'string', enum: ['map', 'status'], description: 'Mapping operation.' },
|
|
2182
|
+
sourceHash: { type: 'string', description: 'Legacy snapshot sha256 (map).' },
|
|
2183
|
+
oldId: { type: 'string', description: 'Legacy mission id (map).' },
|
|
2184
|
+
newMissionId: { type: 'string', description: 'Current mission id; optional audit pointer.' },
|
|
2185
|
+
},
|
|
2186
|
+
required: ['repoRoot', 'action'],
|
|
2187
|
+
additionalProperties: false,
|
|
2188
|
+
},
|
|
2189
|
+
output: output(SCHEMAS.legacyMap),
|
|
2190
|
+
async execute(args, exec) {
|
|
2191
|
+
const tower = await Tower.open(args.repoRoot)
|
|
2192
|
+
const actor = await actorFor(tower, exec)
|
|
2193
|
+
const state = await tower.load()
|
|
2194
|
+
if (args.action === 'status') {
|
|
2195
|
+
return { ok: true, maps: state.legacyMaps ?? [] }
|
|
2196
|
+
}
|
|
2197
|
+
requireTowerActor(actor, 'map a legacy mission')
|
|
2198
|
+
const validation = validateLegacyMap(state, args)
|
|
2199
|
+
if (validation.ok !== true) fail(validation.error ?? 'invalid legacy mapping')
|
|
2200
|
+
const map = { ...validation.map, by: actor.name, at: Date.now() }
|
|
2201
|
+
const recorded = await tower.recordLegacyMap(map)
|
|
2202
|
+
return { ok: true, map: recorded.map, maps: [...(state.legacyMaps ?? []), recorded.map] }
|
|
2203
|
+
},
|
|
2204
|
+
})
|
|
2205
|
+
|
|
2206
|
+
// Dev-only hot reload. The tool is registered whenever dev.hotReload is on;
|
|
2207
|
+
// it reports reload-unavailable when the profile has no HMR service. When the
|
|
2208
|
+
// Host passed a shared bridge, its status() reflects the command's reloads too.
|
|
2209
|
+
const dev = runtimeConfig().dev ?? {}
|
|
2210
|
+
if (dev.hotReload !== false) {
|
|
2211
|
+
const bridge =
|
|
2212
|
+
sharedHmrBridge ??
|
|
2213
|
+
createHmrBridge(ctx, {
|
|
2214
|
+
requiredIds: dev.reloadRequiredIds,
|
|
2215
|
+
delayMs: dev.reloadDelayMs,
|
|
2216
|
+
logger: ctx.logger ?? console,
|
|
2217
|
+
})
|
|
2218
|
+
define(ctx, {
|
|
2219
|
+
name: 'tower_reload',
|
|
2220
|
+
description:
|
|
2221
|
+
'Dev-only: ask DSH HMR to reconcile this profile and reload the Tower Host plugin from disk. The reload is scheduled a few milliseconds after this call returns (so the result reaches the model first). Use after editing the plugin source in a linked profile; plugin_manager install/update already triggers reconciliation by itself. Registered while dev.hotReload is on; fails with reload-unavailable when the HMR service is absent.',
|
|
2222
|
+
parameters: {
|
|
2223
|
+
type: 'object',
|
|
2224
|
+
properties: {
|
|
2225
|
+
repoRoot: { type: 'string', description: 'Optional repository root; when present the caller must be the tower.' },
|
|
2226
|
+
},
|
|
2227
|
+
required: [],
|
|
2228
|
+
additionalProperties: false,
|
|
2229
|
+
},
|
|
2230
|
+
output: output(SCHEMAS.reload),
|
|
2231
|
+
async execute(args, exec) {
|
|
2232
|
+
if (typeof args.repoRoot === 'string') {
|
|
2233
|
+
const tower = await Tower.open(args.repoRoot)
|
|
2234
|
+
const actor = await actorFor(tower, exec)
|
|
2235
|
+
requireTowerActor(actor, 'reload the Tower plugin')
|
|
2236
|
+
}
|
|
2237
|
+
if (!bridge.available) {
|
|
2238
|
+
throw new TowerToolError('reload-unavailable', 'this DSH profile has no HMR service; restart DSH instead')
|
|
2239
|
+
}
|
|
2240
|
+
const outcome = bridge.schedule()
|
|
2241
|
+
return {
|
|
2242
|
+
ok: true,
|
|
2243
|
+
...outcome,
|
|
2244
|
+
...(outcome.lastError ? { error: outcome.lastError } : {}),
|
|
2245
|
+
restartHint: 'If the new code does not appear, restart DSH or check the profile hmr entry.',
|
|
2246
|
+
}
|
|
2247
|
+
},
|
|
2248
|
+
})
|
|
2249
|
+
}
|
|
868
2250
|
}
|