dsh-tower 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/CONTRIBUTING.md +36 -0
- package/README.en.md +60 -23
- package/README.md +54 -21
- package/SECURITY.md +20 -0
- package/cordis.patch.yml +32 -0
- package/docs/README.md +17 -0
- package/docs/architecture/overview.md +70 -0
- package/docs/examples/first-mission.md +58 -0
- package/docs/faq.md +74 -0
- package/docs/guide/hot-reload.md +189 -0
- package/docs/guide/install-upgrade-migration.md +59 -0
- package/docs/guide/quickstart.md +97 -0
- package/docs/operations/runbook.md +80 -0
- package/docs/operations/security.md +62 -0
- package/docs/reference/blockers.md +58 -0
- package/docs/reference/config.md +114 -0
- package/docs/reference/doctor.md +31 -0
- package/docs/reference/errors.md +50 -0
- package/docs/reference/events.md +45 -0
- package/docs/reference/operations.md +52 -0
- package/docs/reference/testing.md +61 -0
- package/docs/reference/tools.md +75 -0
- package/docs/zh/README.md +58 -0
- package/docs/zh/faq.md +72 -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/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 +30 -0
- package/docs/zh/reference/errors.md +52 -0
- package/docs/zh/reference/events.md +46 -0
- package/docs/zh/reference/operations.md +48 -0
- package/docs/zh/reference/testing.md +60 -0
- package/docs/zh/reference/tools.md +75 -0
- package/index.js +293 -49
- package/lib/batch.js +578 -0
- package/lib/config.js +381 -0
- package/lib/doctor.js +165 -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 +47 -16
- package/lib/store.js +24 -1
- package/lib/tests.js +628 -0
- package/lib/tools.js +1594 -160
- package/lib/tower.js +1025 -101
- package/lib/workspaceLock.js +117 -0
- package/lib/worktreeOwnership.js +166 -0
- package/package.json +21 -4
- package/reviewer.js +40 -0
- package/scripts/check-docs.mjs +135 -0
- package/scripts/enable-dev-hmr.mjs +347 -0
- package/skills/tower-recovery.md +40 -0
- package/skills/tower-review.md +16 -0
- package/skills/tower-worker-protocol.md +15 -0
- package/skills.js +63 -0
- package/worker.js +7 -4
package/lib/tools.js
CHANGED
|
@@ -1,21 +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'
|
|
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'
|
|
3
15
|
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
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 }
|
|
7
282
|
}
|
|
8
283
|
|
|
9
284
|
const ROLE_ENUM = ['worker', 'reviewer']
|
|
10
285
|
|
|
11
|
-
function
|
|
12
|
-
|
|
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
|
|
13
299
|
}
|
|
14
300
|
|
|
15
301
|
function fail(message) {
|
|
16
302
|
throw new Error(message)
|
|
17
303
|
}
|
|
18
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
|
+
|
|
19
487
|
function textOf(blocks) {
|
|
20
488
|
if (!Array.isArray(blocks)) return null
|
|
21
489
|
return blocks
|
|
@@ -23,6 +491,52 @@ function textOf(blocks) {
|
|
|
23
491
|
.join('\n')
|
|
24
492
|
}
|
|
25
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
|
+
|
|
26
540
|
async function observeActivity(ctx, tower) {
|
|
27
541
|
const view = await tower.rosterView()
|
|
28
542
|
const subagents = ctx.get('subagents')
|
|
@@ -42,7 +556,22 @@ async function observeActivity(ctx, tower) {
|
|
|
42
556
|
return { activity, parentStatus }
|
|
43
557
|
}
|
|
44
558
|
|
|
45
|
-
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
|
+
}) {
|
|
46
575
|
const lines = []
|
|
47
576
|
lines.push(`# Tower mission ${mission.id} — ${mission.title}`)
|
|
48
577
|
lines.push('')
|
|
@@ -67,6 +596,36 @@ function workerBriefing({ repoRoot, base, mission, deps, worktree, instructions
|
|
|
67
596
|
lines.push('Context (verbatim from the user):')
|
|
68
597
|
lines.push(mission.context)
|
|
69
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
|
+
}
|
|
70
629
|
lines.push('')
|
|
71
630
|
lines.push('Rules:')
|
|
72
631
|
lines.push(`- Pass workdir="${worktree.path}" on every shell command; never edit the main checkout.`)
|
|
@@ -76,7 +635,7 @@ function workerBriefing({ repoRoot, base, mission, deps, worktree, instructions
|
|
|
76
635
|
`- Update progress: tower_mission({repoRoot:"${repoRoot}",id:"${mission.id}",task_done:"..."}) or blocker:"...".`,
|
|
77
636
|
)
|
|
78
637
|
lines.push(
|
|
79
|
-
`- 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.`,
|
|
80
639
|
)
|
|
81
640
|
lines.push('- Your final message is the full handoff: files changed, how you verified, what remains.')
|
|
82
641
|
if (instructions) {
|
|
@@ -87,7 +646,17 @@ function workerBriefing({ repoRoot, base, mission, deps, worktree, instructions
|
|
|
87
646
|
return lines.join('\n')
|
|
88
647
|
}
|
|
89
648
|
|
|
90
|
-
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
|
+
}) {
|
|
91
660
|
const lines = []
|
|
92
661
|
lines.push(`# Tower review — mission ${mission.id} — ${mission.title}`)
|
|
93
662
|
lines.push('')
|
|
@@ -110,6 +679,36 @@ function reviewerBriefing({ repoRoot, base, mission, deps, worktree, instruction
|
|
|
110
679
|
lines.push('Context (verbatim from the user):')
|
|
111
680
|
lines.push(mission.context)
|
|
112
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
|
+
}
|
|
113
712
|
lines.push('')
|
|
114
713
|
lines.push('Rules:')
|
|
115
714
|
lines.push('- Read-only: do not modify files, commit, or merge.')
|
|
@@ -130,24 +729,76 @@ function reviewerBriefing({ repoRoot, base, mission, deps, worktree, instruction
|
|
|
130
729
|
return lines.join('\n')
|
|
131
730
|
}
|
|
132
731
|
|
|
133
|
-
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))
|
|
134
765
|
define(ctx, {
|
|
135
766
|
name: 'tower_init',
|
|
136
767
|
description:
|
|
137
|
-
'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.',
|
|
138
769
|
parameters: {
|
|
139
770
|
type: 'object',
|
|
140
771
|
properties: {
|
|
141
772
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
142
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.' },
|
|
143
777
|
},
|
|
144
778
|
required: ['repoRoot'],
|
|
145
779
|
additionalProperties: false,
|
|
146
780
|
},
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 })
|
|
151
802
|
},
|
|
152
803
|
})
|
|
153
804
|
|
|
@@ -171,6 +822,21 @@ export function registerTowerTools(ctx) {
|
|
|
171
822
|
deps: { type: 'array', items: { type: 'string' }, description: 'Mission ids that must merge before this one.' },
|
|
172
823
|
kind: { type: 'string', enum: ['build', 'survey'], description: 'survey = read-only investigation that reserves no scope.' },
|
|
173
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
|
+
},
|
|
174
840
|
},
|
|
175
841
|
required: ['title'],
|
|
176
842
|
additionalProperties: false,
|
|
@@ -180,9 +846,12 @@ export function registerTowerTools(ctx) {
|
|
|
180
846
|
required: ['repoRoot', 'missions'],
|
|
181
847
|
additionalProperties: false,
|
|
182
848
|
},
|
|
183
|
-
|
|
184
|
-
|
|
849
|
+
board: true,
|
|
850
|
+
output: output(SCHEMAS.plan),
|
|
851
|
+
async execute(args, exec) {
|
|
185
852
|
const tower = await Tower.open(args.repoRoot)
|
|
853
|
+
const actor = await actorFor(tower, exec)
|
|
854
|
+
requireTowerActor(actor, 'plan missions')
|
|
186
855
|
return tower.plan({ missions: args.missions })
|
|
187
856
|
},
|
|
188
857
|
})
|
|
@@ -195,7 +864,8 @@ export function registerTowerTools(ctx) {
|
|
|
195
864
|
type: 'object',
|
|
196
865
|
properties: {
|
|
197
866
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
198
|
-
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.' },
|
|
199
869
|
status: {
|
|
200
870
|
type: 'string',
|
|
201
871
|
enum: ['planned', 'active', 'completed', 'blocked', 'paused', 'abandoned'],
|
|
@@ -204,18 +874,32 @@ export function registerTowerTools(ctx) {
|
|
|
204
874
|
note: { type: 'string', description: 'Append a decision-log note.' },
|
|
205
875
|
blocker: { type: 'string', description: 'Report a blocker (also sets status to blocked).' },
|
|
206
876
|
clear_blockers: { type: 'boolean', description: 'Clear all recorded blockers.' },
|
|
877
|
+
clearBlockers: { type: 'boolean', description: 'Alias for clear_blockers.' },
|
|
207
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.' },
|
|
208
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.' },
|
|
209
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.' },
|
|
210
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.' },
|
|
211
891
|
},
|
|
212
|
-
required: ['repoRoot'
|
|
892
|
+
required: ['repoRoot'],
|
|
213
893
|
additionalProperties: false,
|
|
214
894
|
},
|
|
215
|
-
|
|
216
|
-
|
|
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) {
|
|
217
899
|
const tower = await Tower.open(args.repoRoot)
|
|
900
|
+
const actor = await actorFor(tower, exec)
|
|
218
901
|
return tower.mission({
|
|
902
|
+
actor,
|
|
219
903
|
id: args.id,
|
|
220
904
|
status: args.status,
|
|
221
905
|
note: args.note,
|
|
@@ -225,6 +909,8 @@ export function registerTowerTools(ctx) {
|
|
|
225
909
|
task_drop: args.task_drop,
|
|
226
910
|
task_drop_reason: args.task_drop_reason,
|
|
227
911
|
scope: args.scope,
|
|
912
|
+
pause_cause: args.pause_cause,
|
|
913
|
+
authorization: args.authorization,
|
|
228
914
|
})
|
|
229
915
|
},
|
|
230
916
|
})
|
|
@@ -232,149 +918,393 @@ export function registerTowerTools(ctx) {
|
|
|
232
918
|
define(ctx, {
|
|
233
919
|
name: 'tower_status',
|
|
234
920
|
description:
|
|
235
|
-
'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.',
|
|
236
922
|
parameters: {
|
|
237
923
|
type: 'object',
|
|
238
924
|
properties: {
|
|
239
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.' },
|
|
240
931
|
},
|
|
241
932
|
required: ['repoRoot'],
|
|
242
933
|
additionalProperties: false,
|
|
243
934
|
},
|
|
244
|
-
|
|
935
|
+
aliases: { mission_id: ['missionId'], include_gate: ['includeGate'] },
|
|
936
|
+
output: output(SCHEMAS.status),
|
|
245
937
|
async execute(args) {
|
|
246
938
|
const tower = await Tower.open(args.repoRoot)
|
|
247
|
-
|
|
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
|
|
248
992
|
},
|
|
249
993
|
})
|
|
250
994
|
|
|
251
995
|
define(ctx, {
|
|
252
996
|
name: 'tower_spawn',
|
|
253
997
|
description:
|
|
254
|
-
'Spawn a worker or reviewer for a mission. Provisions (or reuses) the mission git worktree and branch, generates a self-contained briefing
|
|
998
|
+
'Spawn a worker or reviewer for a mission. Workers use the tower-worker preset; reviewers require a completed mission and use the read-only tower-reviewer preset. Provisions (or reuses) the mission git worktree and branch, generates a self-contained briefing, and starts a background subagent in that worktree. The child final report is delivered back to this session; tower_roster shows authoritative activity. Records the agent in the roster; reviewer spawns record the branch tip so a reviewer that modifies the branch cannot submit a review. Returns immediately unless wait=true.',
|
|
255
999
|
parameters: {
|
|
256
1000
|
type: 'object',
|
|
257
1001
|
properties: {
|
|
258
1002
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
259
|
-
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.' },
|
|
260
1005
|
role: { type: 'string', enum: ROLE_ENUM, description: 'worker executes; reviewer audits the mission branch.' },
|
|
261
1006
|
name: { type: 'string', description: 'Unique roster name; defaults to <role>-<missionId>.' },
|
|
262
1007
|
instructions: { type: 'string', description: 'Extra tower instructions appended to the briefing.' },
|
|
263
1008
|
worktreeRoot: { type: 'string', description: 'Optional absolute parent directory for the worktree.' },
|
|
264
1009
|
wait: { type: 'boolean', description: 'Wait for the child result instead of returning immediately (testing/debug).' },
|
|
265
|
-
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).' },
|
|
266
1014
|
},
|
|
267
|
-
required: ['repoRoot'
|
|
1015
|
+
required: ['repoRoot'],
|
|
268
1016
|
additionalProperties: false,
|
|
269
1017
|
},
|
|
270
|
-
|
|
1018
|
+
aliases: {"missionId":["id"],"waitMs":["wait_ms"]},
|
|
1019
|
+
requiresMissionId: true,
|
|
1020
|
+
board: true,
|
|
1021
|
+
output: output(SCHEMAS.spawn),
|
|
271
1022
|
async execute(args, exec) {
|
|
272
1023
|
const tower = await Tower.open(args.repoRoot)
|
|
1024
|
+
const actor = await actorFor(tower, exec)
|
|
1025
|
+
requireTowerActor(actor, 'spawn tower agents')
|
|
273
1026
|
const role = args.role ?? 'worker'
|
|
274
1027
|
if (!ROLE_ENUM.includes(role)) fail(`role must be one of ${ROLE_ENUM.join('|')}`)
|
|
1028
|
+
// Repository config (if any) already overrides the profile config here.
|
|
1029
|
+
const config = tower.config
|
|
1030
|
+
const preset = role === 'reviewer' ? config.spawn.reviewerPreset : config.spawn.workerPreset
|
|
1031
|
+
const model = role === 'reviewer' ? config.spawn.reviewerModel : config.spawn.workerModel
|
|
275
1032
|
const context = await tower.missionContext(args.missionId)
|
|
276
|
-
if (role === 'reviewer'
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const { mission, worktree } = provisioned
|
|
284
|
-
const contextWithWorktree = {
|
|
285
|
-
...context,
|
|
286
|
-
mission: {
|
|
287
|
-
...context.mission,
|
|
288
|
-
worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
|
|
289
|
-
},
|
|
1033
|
+
if (role === 'reviewer') {
|
|
1034
|
+
if (!context.mission.worktree) {
|
|
1035
|
+
fail('reviewer needs an existing mission worktree; spawn a worker first')
|
|
1036
|
+
}
|
|
1037
|
+
if (context.mission.status !== 'completed') {
|
|
1038
|
+
fail(`reviewer requires a completed mission awaiting review; ${context.mission.id} is ${context.mission.status}`)
|
|
1039
|
+
}
|
|
290
1040
|
}
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
1041
|
+
const spawned = await enqueueSpawn(tower.root, async () => {
|
|
1042
|
+
const view = await tower.rosterView()
|
|
1043
|
+
const inactiveStates = ROSTER_INACTIVE_STATES
|
|
1044
|
+
// Resolve a unique roster name BEFORE any activation. The default name
|
|
1045
|
+
// is derived per role/mission and gets a numeric suffix on rework rounds.
|
|
1046
|
+
let name
|
|
1047
|
+
if (typeof args.name === 'string' && args.name.trim()) {
|
|
1048
|
+
name = args.name.trim()
|
|
1049
|
+
if (view.roster.some((entry) => entry.name === name)) {
|
|
1050
|
+
throw new TowerToolError('invalid-argument', 'roster name already used: ' + name)
|
|
1051
|
+
}
|
|
1052
|
+
} else {
|
|
1053
|
+
const base = role + '-' + args.missionId.toLowerCase()
|
|
1054
|
+
let candidate = base
|
|
1055
|
+
let suffix = 2
|
|
1056
|
+
while (view.roster.some((entry) => entry.name === candidate)) {
|
|
1057
|
+
candidate = base + '-' + suffix
|
|
1058
|
+
suffix += 1
|
|
1059
|
+
}
|
|
1060
|
+
name = candidate
|
|
1061
|
+
}
|
|
1062
|
+
let takeover = args.takeover === true
|
|
1063
|
+
if (role === 'worker') {
|
|
1064
|
+
// Pre-check the single-owner and live-worker rules BEFORE provisioning
|
|
1065
|
+
// or starting a child: a rejection must not leak an activation/worktree.
|
|
1066
|
+
const liveWorkers = view.roster.filter(
|
|
1067
|
+
(entry) => entry.role === 'worker' && !inactiveStates.includes(entry.state),
|
|
1068
|
+
)
|
|
1069
|
+
const sameMission = liveWorkers.filter((entry) => entry.missionId === args.missionId)
|
|
1070
|
+
if (sameMission.length > 1) {
|
|
1071
|
+
throw new TowerToolError(
|
|
1072
|
+
'invalid-transition',
|
|
1073
|
+
'mission ' + args.missionId + ' has ' + sameMission.length + ' live worker entries; inspect tower_roster before spawning',
|
|
1074
|
+
)
|
|
1075
|
+
}
|
|
1076
|
+
if (sameMission.length === 1 && !takeover) {
|
|
1077
|
+
throw new TowerToolError(
|
|
1078
|
+
'invalid-transition',
|
|
1079
|
+
'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',
|
|
1080
|
+
)
|
|
1081
|
+
}
|
|
1082
|
+
if (takeover) {
|
|
1083
|
+
if (sameMission.length === 1) {
|
|
1084
|
+
const worker = sameMission[0]
|
|
1085
|
+
const observed = await observeActivity(ctx, tower)
|
|
1086
|
+
if (observed.parentStatus[worker.parentSessionId] !== 'ok') {
|
|
1087
|
+
throw new TowerToolError(
|
|
1088
|
+
'takeover-refused',
|
|
1089
|
+
'cannot verify that worker ' + worker.name + ' stopped: its parent catalog is unavailable',
|
|
1090
|
+
)
|
|
1091
|
+
}
|
|
1092
|
+
const activity = observed.activity[worker.agentId]
|
|
1093
|
+
if (activity === 'running') {
|
|
1094
|
+
throw new TowerToolError(
|
|
1095
|
+
'takeover-refused',
|
|
1096
|
+
'worker ' + worker.name + ' is still running; call tower_interrupt (or wait) before takeover',
|
|
1097
|
+
)
|
|
1098
|
+
}
|
|
1099
|
+
if (activity === undefined) {
|
|
1100
|
+
throw new TowerToolError(
|
|
1101
|
+
'takeover-refused',
|
|
1102
|
+
'worker ' + worker.name + ' is not in the live catalog; run tower_adopt before takeover',
|
|
1103
|
+
)
|
|
1104
|
+
}
|
|
1105
|
+
} else {
|
|
1106
|
+
takeover = false
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
const replacing = takeover ? sameMission : []
|
|
1110
|
+
const remaining = liveWorkers.filter((entry) => !replacing.includes(entry))
|
|
1111
|
+
const max = Number(config.spawn.maxLiveWorkers) || 4
|
|
1112
|
+
if (remaining.length >= max) {
|
|
1113
|
+
throw new TowerToolError(
|
|
1114
|
+
'worker-limit',
|
|
1115
|
+
'live worker limit reached (' + max + '); stop a worker with tower_interrupt or raise spawn.maxLiveWorkers',
|
|
1116
|
+
)
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
310
1119
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
branch: worktree.branch,
|
|
343
|
-
})
|
|
1120
|
+
// Service/provider/preset checks happen before provisioning, so a failed
|
|
1121
|
+
// precheck cannot leave a worktree or roster revision behind.
|
|
1122
|
+
const subagents = ctx.get('subagents')
|
|
1123
|
+
if (!subagents) fail('subagents service is unavailable; cannot spawn')
|
|
1124
|
+
const providers = subagents.list()
|
|
1125
|
+
const provider = config.spawn.provider || (providers.includes('spawn') ? 'spawn' : providers[0])
|
|
1126
|
+
if (!provider) fail('no subagent provider is registered')
|
|
1127
|
+
const presets = ctx.get('agentPresets')
|
|
1128
|
+
if (presets && typeof presets.list === 'function') {
|
|
1129
|
+
const available = await presets.list()
|
|
1130
|
+
if (!available.some((entry) => String(entry.id) === preset)) {
|
|
1131
|
+
fail('agent preset "' + preset + '" is not registered; check the Tower bundle preset rows and restart DSH')
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
if (typeof config.spawn.requireLease === 'string' && config.spawn.requireLease.trim()) {
|
|
1135
|
+
const requiredLease = config.spawn.requireLease.trim()
|
|
1136
|
+
const info = await inspectLeases(tower.commonDir)
|
|
1137
|
+
const lease = info.leases.find((entry) => entry.name === requiredLease && entry.expired !== true)
|
|
1138
|
+
if (!lease) {
|
|
1139
|
+
throw new TowerToolError(
|
|
1140
|
+
'lease-busy',
|
|
1141
|
+
`spawn.requireLease="${requiredLease}" is not held; acquire it with tower_resource before spawning`,
|
|
1142
|
+
)
|
|
1143
|
+
}
|
|
1144
|
+
if (lease.holder !== actor.name && lease.holder !== String(exec.agent.id)) {
|
|
1145
|
+
throw new TowerToolError(
|
|
1146
|
+
'lease-busy',
|
|
1147
|
+
`spawn.requireLease="${requiredLease}" is held by "${lease.holder}", not by this tower`,
|
|
1148
|
+
)
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
344
1151
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
missionId: mission.id,
|
|
350
|
-
worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
|
|
351
|
-
created: provisioned.created,
|
|
352
|
-
briefingChars: briefing.length,
|
|
353
|
-
rosterEntry: entry,
|
|
354
|
-
provider,
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
if (args.wait === true) {
|
|
358
|
-
const waitMs = Math.max(1000, Math.min(600000, args.waitMs ?? 180000))
|
|
359
|
-
let timer
|
|
360
|
-
const timeout = new Promise((resolve) => {
|
|
361
|
-
timer = setTimeout(() => resolve({ __timeout: true }), waitMs)
|
|
1152
|
+
const provisioned = await tower.provision({
|
|
1153
|
+
missionId: args.missionId,
|
|
1154
|
+
worktreeRoot: args.worktreeRoot,
|
|
1155
|
+
includeDirtyBase: args.includeDirtyBase !== false,
|
|
362
1156
|
})
|
|
363
|
-
|
|
1157
|
+
const { mission, worktree } = provisioned
|
|
1158
|
+
const contextWithWorktree = {
|
|
1159
|
+
...context,
|
|
1160
|
+
mission: {
|
|
1161
|
+
...context.mission,
|
|
1162
|
+
worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
|
|
1163
|
+
},
|
|
1164
|
+
}
|
|
1165
|
+
const briefing =
|
|
1166
|
+
role === 'worker'
|
|
1167
|
+
? workerBriefing({
|
|
1168
|
+
repoRoot: tower.root,
|
|
1169
|
+
base: context.base,
|
|
1170
|
+
mission: contextWithWorktree.mission,
|
|
1171
|
+
deps: context.deps,
|
|
1172
|
+
worktree,
|
|
1173
|
+
instructions: args.instructions,
|
|
1174
|
+
reviews: context.reviews,
|
|
1175
|
+
findings: context.findings,
|
|
1176
|
+
completionReport: context.completionReport,
|
|
1177
|
+
})
|
|
1178
|
+
: reviewerBriefing({
|
|
1179
|
+
repoRoot: tower.root,
|
|
1180
|
+
base: context.base,
|
|
1181
|
+
mission: contextWithWorktree.mission,
|
|
1182
|
+
deps: context.deps,
|
|
1183
|
+
worktree,
|
|
1184
|
+
instructions: args.instructions,
|
|
1185
|
+
reviews: context.reviews,
|
|
1186
|
+
findings: context.findings,
|
|
1187
|
+
completionReport: context.completionReport,
|
|
1188
|
+
})
|
|
1189
|
+
|
|
1190
|
+
const agentOptions = {}
|
|
1191
|
+
for (const key of ['provider', 'model', 'reasoningEffort', 'maxTokens']) {
|
|
1192
|
+
if (model && model[key] !== undefined && model[key] !== null && model[key] !== '') agentOptions[key] = model[key]
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (role === 'worker' && tower.config.tests?.admission === 'spawn') {
|
|
1196
|
+
const discovery = await runMissionDiscovery(tower, mission)
|
|
1197
|
+
if (discovery.configured && discovery.receipt.ok !== true) {
|
|
1198
|
+
throw new TowerToolError(
|
|
1199
|
+
'tests-failed',
|
|
1200
|
+
`test admission failed for ${mission.id}: ${discovery.receipt.error}; no child was started`,
|
|
1201
|
+
)
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
let activation = null
|
|
1206
|
+
let agentId = null
|
|
1207
|
+
let entry = null
|
|
364
1208
|
try {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
1209
|
+
activation = await subagents.startActivation({
|
|
1210
|
+
provider,
|
|
1211
|
+
label: name,
|
|
1212
|
+
request: {
|
|
1213
|
+
cwd: worktree.path,
|
|
1214
|
+
agentPreset: preset,
|
|
1215
|
+
prompt: [{ type: 'text', text: briefing }],
|
|
1216
|
+
parent: exec.agent,
|
|
1217
|
+
maxDepth: config.spawn.maxDepth ?? 1,
|
|
1218
|
+
...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
|
|
1219
|
+
},
|
|
1220
|
+
signal: exec.signal,
|
|
1221
|
+
delivery: 'parent',
|
|
1222
|
+
})
|
|
1223
|
+
agentId = String(activation.childId)
|
|
1224
|
+
entry = await tower.recordSpawn({
|
|
1225
|
+
missionId: mission.id,
|
|
1226
|
+
name,
|
|
1227
|
+
role,
|
|
1228
|
+
agentId,
|
|
1229
|
+
parentSessionId: String(exec.agent.id),
|
|
1230
|
+
cwd: worktree.path,
|
|
1231
|
+
branch: worktree.branch,
|
|
1232
|
+
takeover,
|
|
1233
|
+
})
|
|
1234
|
+
} catch (error) {
|
|
1235
|
+
// The child may already be running even when recordSpawn failed (for
|
|
1236
|
+
// example a race on a roster name): stop it so it cannot keep writing
|
|
1237
|
+
// unobserved, then surface the real error.
|
|
1238
|
+
if (agentId) {
|
|
1239
|
+
try {
|
|
1240
|
+
subagents.interrupt(agentId, { kind: 'ancestor', agent: exec.agent })
|
|
1241
|
+
} catch {
|
|
1242
|
+
// best effort; the error below is the actionable one
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
throw error
|
|
368
1246
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
1247
|
+
|
|
1248
|
+
const recordOutcome = (state, detail) =>
|
|
1249
|
+
tower.recordSpawnOutcome({ missionId: mission.id, name, state, detail }).catch(() => {})
|
|
1250
|
+
|
|
1251
|
+
const report = {
|
|
1252
|
+
name,
|
|
1253
|
+
agentId,
|
|
1254
|
+
role,
|
|
1255
|
+
missionId: mission.id,
|
|
1256
|
+
preset,
|
|
1257
|
+
provider,
|
|
1258
|
+
maxDepth: config.spawn.maxDepth ?? 1,
|
|
1259
|
+
...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
|
|
1260
|
+
worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
|
|
1261
|
+
created: provisioned.created,
|
|
1262
|
+
briefingChars: briefing.length,
|
|
1263
|
+
takeover,
|
|
1264
|
+
rosterEntry: entry,
|
|
375
1265
|
}
|
|
1266
|
+
|
|
1267
|
+
return { report, activation, recordOutcome }
|
|
1268
|
+
})
|
|
1269
|
+
const { report, activation, recordOutcome } = spawned
|
|
1270
|
+
if (args.wait !== true) {
|
|
1271
|
+
activation.result
|
|
1272
|
+
.then((result) => {
|
|
1273
|
+
const state = result?.stopReason === 'completed' ? 'inactive' : 'failed'
|
|
1274
|
+
return recordOutcome(state, result?.diagnostic ?? result?.stopReason ?? null)
|
|
1275
|
+
})
|
|
1276
|
+
.catch((error) => recordOutcome('failed', String(error?.message ?? error)))
|
|
1277
|
+
return report
|
|
1278
|
+
}
|
|
1279
|
+
const waitMs = Math.max(1000, Math.min(config.spawn.maxWaitMs ?? 90000, args.waitMs ?? 180000))
|
|
1280
|
+
let timer
|
|
1281
|
+
const timeout = new Promise((resolve) => {
|
|
1282
|
+
timer = setTimeout(() => resolve({ __timeout: true }), waitMs)
|
|
1283
|
+
})
|
|
1284
|
+
let result
|
|
1285
|
+
try {
|
|
1286
|
+
result = await Promise.race([activation.result, timeout])
|
|
1287
|
+
} catch (error) {
|
|
1288
|
+
report.stopReason = 'failed'
|
|
1289
|
+
report.diagnostic = String(error?.message ?? error)
|
|
1290
|
+
await recordOutcome('failed', report.diagnostic)
|
|
1291
|
+
throw error
|
|
1292
|
+
} finally {
|
|
1293
|
+
clearTimeout(timer)
|
|
1294
|
+
}
|
|
1295
|
+
if (result && result.__timeout) {
|
|
1296
|
+
report.timedOut = true
|
|
1297
|
+
recordOutcome('unknown', 'spawn wait timed out; check tower_roster')
|
|
1298
|
+
} else {
|
|
1299
|
+
report.stopReason = result?.stopReason ?? null
|
|
1300
|
+
report.output = textOf(result?.output)
|
|
1301
|
+
report.diagnostic = result?.diagnostic ?? null
|
|
1302
|
+
const state = report.stopReason === 'completed' ? 'inactive' : 'failed'
|
|
1303
|
+
if (state === 'failed') report.diagnostic = report.diagnostic ?? report.stopReason
|
|
1304
|
+
await recordOutcome(state, report.diagnostic ?? null)
|
|
376
1305
|
}
|
|
377
1306
|
return report
|
|
1307
|
+
|
|
378
1308
|
},
|
|
379
1309
|
})
|
|
380
1310
|
|
|
@@ -390,7 +1320,7 @@ export function registerTowerTools(ctx) {
|
|
|
390
1320
|
required: ['repoRoot'],
|
|
391
1321
|
additionalProperties: false,
|
|
392
1322
|
},
|
|
393
|
-
output:
|
|
1323
|
+
output: output(SCHEMAS.roster),
|
|
394
1324
|
async execute(args) {
|
|
395
1325
|
const tower = await Tower.open(args.repoRoot)
|
|
396
1326
|
const view = await tower.rosterView()
|
|
@@ -410,13 +1340,22 @@ export function registerTowerTools(ctx) {
|
|
|
410
1340
|
}
|
|
411
1341
|
}
|
|
412
1342
|
|
|
1343
|
+
const staleMs = Number(tower.config.spawn?.staleMs) || 0
|
|
1344
|
+
const now = Date.now()
|
|
413
1345
|
const roster = view.roster.map((entry) => {
|
|
414
1346
|
const observed = activity.get(entry.agentId)
|
|
415
1347
|
const known = parentStatus[entry.parentSessionId] === 'ok'
|
|
1348
|
+
const stale =
|
|
1349
|
+
staleMs > 0 &&
|
|
1350
|
+
observed === undefined &&
|
|
1351
|
+
known &&
|
|
1352
|
+
!['retired', 'failed', 'interrupted', 'missing', 'stale'].includes(entry.state) &&
|
|
1353
|
+
now - Number(entry.spawnedAt ?? 0) > staleMs
|
|
416
1354
|
return {
|
|
417
1355
|
...entry,
|
|
418
1356
|
activity: observed ?? (known ? 'missing' : 'unknown'),
|
|
419
1357
|
observed: observed !== undefined,
|
|
1358
|
+
stale,
|
|
420
1359
|
}
|
|
421
1360
|
})
|
|
422
1361
|
return { revision: view.revision, roster, parentStatus, missions: view.missions }
|
|
@@ -424,20 +1363,80 @@ export function registerTowerTools(ctx) {
|
|
|
424
1363
|
})
|
|
425
1364
|
|
|
426
1365
|
define(ctx, {
|
|
427
|
-
name: '
|
|
1366
|
+
name: 'tower_interrupt',
|
|
428
1367
|
description:
|
|
429
|
-
'
|
|
1368
|
+
'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.',
|
|
430
1369
|
parameters: {
|
|
431
1370
|
type: 'object',
|
|
432
1371
|
properties: {
|
|
433
1372
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
1373
|
+
to: { type: 'string', description: 'Roster name to interrupt.' },
|
|
1374
|
+
agentId: { type: 'string', description: 'Agent/session id to interrupt (alternative to to).' },
|
|
1375
|
+
agent_id: { type: 'string', description: 'Alias for agentId.' },
|
|
1376
|
+
reason: { type: 'string', description: 'Why the agent is being interrupted (recorded in the roster).' },
|
|
1377
|
+
},
|
|
1378
|
+
required: ['repoRoot'],
|
|
1379
|
+
additionalProperties: false,
|
|
1380
|
+
},
|
|
1381
|
+
aliases: { agentId: ['agent_id'] },
|
|
1382
|
+
output: output(SCHEMAS.interrupt),
|
|
1383
|
+
async execute(args, exec) {
|
|
1384
|
+
const tower = await Tower.open(args.repoRoot)
|
|
1385
|
+
const actor = await actorFor(tower, exec)
|
|
1386
|
+
requireTowerActor(actor, 'interrupt tower agents')
|
|
1387
|
+
if (!args.to && !args.agentId) throw new TowerToolError('invalid-argument', 'tower_interrupt requires to (roster name) or agentId')
|
|
1388
|
+
const result = await interruptRosterAgent(ctx, tower, exec, {
|
|
1389
|
+
to: args.to,
|
|
1390
|
+
agentId: args.agentId,
|
|
1391
|
+
reason: args.reason,
|
|
1392
|
+
})
|
|
1393
|
+
return { ok: true, ...result }
|
|
1394
|
+
},
|
|
1395
|
+
})
|
|
1396
|
+
|
|
1397
|
+
define(ctx, {
|
|
1398
|
+
name: 'tower_diff',
|
|
1399
|
+
description:
|
|
1400
|
+
'Read-only view of a mission branch for review: merge base, changed files, commits, and a capped patch against the recorded base. Safe for reviewers; it never modifies the repository.',
|
|
1401
|
+
parameters: {
|
|
1402
|
+
type: 'object',
|
|
1403
|
+
properties: {
|
|
1404
|
+
repoRoot: { type: 'string', description: 'Absolute path to the main checkout root.' },
|
|
1405
|
+
missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
|
|
1406
|
+
id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
|
|
1407
|
+
maxBytes: { type: 'integer', minimum: 1024, description: 'Patch cap in bytes (default 65536).' },
|
|
434
1408
|
},
|
|
435
1409
|
required: ['repoRoot'],
|
|
436
1410
|
additionalProperties: false,
|
|
437
1411
|
},
|
|
438
|
-
|
|
1412
|
+
aliases: {"missionId":["id"]},
|
|
1413
|
+
requiresMissionId: true,
|
|
1414
|
+
output: output(SCHEMAS.diff),
|
|
439
1415
|
async execute(args) {
|
|
440
1416
|
const tower = await Tower.open(args.repoRoot)
|
|
1417
|
+
return tower.diff({ missionId: args.missionId, maxBytes: args.maxBytes })
|
|
1418
|
+
},
|
|
1419
|
+
})
|
|
1420
|
+
|
|
1421
|
+
define(ctx, {
|
|
1422
|
+
name: 'tower_adopt',
|
|
1423
|
+
description:
|
|
1424
|
+
'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.',
|
|
1425
|
+
parameters: {
|
|
1426
|
+
type: 'object',
|
|
1427
|
+
properties: {
|
|
1428
|
+
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
1429
|
+
retireMissing: { type: 'boolean', description: 'Retire provably gone agents (default true); false marks them missing instead.' },
|
|
1430
|
+
},
|
|
1431
|
+
required: ['repoRoot'],
|
|
1432
|
+
additionalProperties: false,
|
|
1433
|
+
},
|
|
1434
|
+
aliases: { retire_missing: ['retireMissing'] },
|
|
1435
|
+
output: output(SCHEMAS.adopt),
|
|
1436
|
+
async execute(args, exec) {
|
|
1437
|
+
const tower = await Tower.open(args.repoRoot)
|
|
1438
|
+
const actor = await actorFor(tower, exec)
|
|
1439
|
+
requireTowerActor(actor, 'adopt the roster')
|
|
441
1440
|
const view = await tower.rosterView()
|
|
442
1441
|
const subagents = ctx.get('subagents')
|
|
443
1442
|
if (!subagents) fail('subagents service is unavailable; cannot adopt')
|
|
@@ -461,10 +1460,13 @@ export function registerTowerTools(ctx) {
|
|
|
461
1460
|
if (parentStatus[entry.parentSessionId] !== 'ok') continue
|
|
462
1461
|
if (!activity.has(entry.agentId)) retiredCandidates.push(entry.agentId)
|
|
463
1462
|
}
|
|
464
|
-
const
|
|
1463
|
+
const retireMissing = args.retireMissing !== false
|
|
1464
|
+
const { retired } = retireMissing ? await tower.markRetired(retiredCandidates) : { retired: [] }
|
|
1465
|
+
const { missing } = retireMissing ? { missing: [] } : await tower.markMissing(retiredCandidates)
|
|
465
1466
|
const after = await tower.rosterView()
|
|
466
1467
|
return {
|
|
467
1468
|
retired,
|
|
1469
|
+
missing,
|
|
468
1470
|
skippedUnknownParents: Object.entries(parentStatus)
|
|
469
1471
|
.filter(([, status]) => status !== 'ok')
|
|
470
1472
|
.map(([parent]) => parent),
|
|
@@ -486,16 +1488,24 @@ export function registerTowerTools(ctx) {
|
|
|
486
1488
|
subject: { type: 'string', description: 'One-line subject.' },
|
|
487
1489
|
body: { type: 'string', description: 'Full message body (markdown).' },
|
|
488
1490
|
scope: { type: 'string', description: 'Optional scope tag, e.g. the mission id.' },
|
|
1491
|
+
interrupt: {
|
|
1492
|
+
type: 'boolean',
|
|
1493
|
+
description: 'Interrupt the recipient\'s current turn before delivering (Kimi-style "message interrupts the running turn"); default false = queue at the next step boundary.',
|
|
1494
|
+
},
|
|
1495
|
+
consentRef: { type: 'string', description: 'Optional audit reference recording who consented to this instruction.' },
|
|
1496
|
+
consent_ref: { type: 'string', description: 'Alias for consentRef.' },
|
|
489
1497
|
},
|
|
490
1498
|
required: ['repoRoot', 'to', 'subject', 'body'],
|
|
491
1499
|
additionalProperties: false,
|
|
492
1500
|
},
|
|
493
|
-
|
|
1501
|
+
aliases: { consentRef: ['consent_ref'] },
|
|
1502
|
+
output: output(SCHEMAS.send),
|
|
494
1503
|
async execute(args, exec) {
|
|
495
1504
|
const tower = await Tower.open(args.repoRoot)
|
|
1505
|
+
checkSendRate(tower.config, args.repoRoot)
|
|
1506
|
+
const actor = await actorFor(tower, exec)
|
|
496
1507
|
const view = await tower.rosterView()
|
|
497
|
-
const
|
|
498
|
-
const from = self ? self.name : 'tower'
|
|
1508
|
+
const from = actor.role === 'tower' ? 'tower' : actor.name
|
|
499
1509
|
if (typeof args.subject !== 'string' || args.subject.trim().length === 0) {
|
|
500
1510
|
fail('tower_send requires a subject')
|
|
501
1511
|
}
|
|
@@ -508,6 +1518,7 @@ export function registerTowerTools(ctx) {
|
|
|
508
1518
|
subject: args.subject.trim(),
|
|
509
1519
|
body: args.body,
|
|
510
1520
|
scope: args.scope,
|
|
1521
|
+
consentRef: args.consentRef,
|
|
511
1522
|
})
|
|
512
1523
|
|
|
513
1524
|
const subagents = ctx.get('subagents')
|
|
@@ -526,6 +1537,9 @@ export function registerTowerTools(ctx) {
|
|
|
526
1537
|
// an unreadable parent catalog only means "do not wake it"
|
|
527
1538
|
}
|
|
528
1539
|
}
|
|
1540
|
+
const sendConfig = tower.config.send ?? {}
|
|
1541
|
+
const interrupt =
|
|
1542
|
+
args.interrupt === undefined ? sendConfig.interruptDefault === true : args.interrupt === true
|
|
529
1543
|
const targets =
|
|
530
1544
|
args.to === 'all' ? roster.map((entry) => entry.name) : [args.to]
|
|
531
1545
|
for (const targetName of targets) {
|
|
@@ -542,6 +1556,16 @@ export function registerTowerTools(ctx) {
|
|
|
542
1556
|
results.push({ target: targetName, pushed: false, reason: 'not running; queued in inbox' })
|
|
543
1557
|
continue
|
|
544
1558
|
}
|
|
1559
|
+
let interrupted = false
|
|
1560
|
+
if (interrupt) {
|
|
1561
|
+
try {
|
|
1562
|
+
subagents.interrupt(entry.agentId, { kind: 'ancestor', agent: exec.agent })
|
|
1563
|
+
interrupted = true
|
|
1564
|
+
} catch (error) {
|
|
1565
|
+
results.push({ target: targetName, pushed: false, reason: `interrupt failed: ${String(error?.message ?? error)}` })
|
|
1566
|
+
continue
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
545
1569
|
try {
|
|
546
1570
|
await subagents.sendMessage(
|
|
547
1571
|
exec.agent,
|
|
@@ -554,7 +1578,7 @@ export function registerTowerTools(ctx) {
|
|
|
554
1578
|
],
|
|
555
1579
|
{ signal: exec.signal },
|
|
556
1580
|
)
|
|
557
|
-
results.push({ target: targetName, pushed: true })
|
|
1581
|
+
results.push({ target: targetName, pushed: true, interrupted })
|
|
558
1582
|
} catch (error) {
|
|
559
1583
|
results.push({ target: targetName, pushed: false, reason: String(error?.message ?? error) })
|
|
560
1584
|
}
|
|
@@ -578,11 +1602,13 @@ export function registerTowerTools(ctx) {
|
|
|
578
1602
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
579
1603
|
limit: { type: 'number', description: 'Max messages to return (default 20), newest first.' },
|
|
580
1604
|
include_read: { type: 'boolean', description: 'Include previously acknowledged messages for history lookup.' },
|
|
1605
|
+
includeRead: { type: 'boolean', description: 'Alias for include_read.' },
|
|
581
1606
|
},
|
|
582
1607
|
required: ['repoRoot'],
|
|
583
1608
|
additionalProperties: false,
|
|
584
1609
|
},
|
|
585
|
-
|
|
1610
|
+
aliases: {"include_read":["includeRead"]},
|
|
1611
|
+
output: output(SCHEMAS.inbox),
|
|
586
1612
|
async execute(args, exec) {
|
|
587
1613
|
const tower = await Tower.open(args.repoRoot)
|
|
588
1614
|
const view = await tower.rosterView()
|
|
@@ -595,24 +1621,64 @@ export function registerTowerTools(ctx) {
|
|
|
595
1621
|
define(ctx, {
|
|
596
1622
|
name: 'tower_complete',
|
|
597
1623
|
description:
|
|
598
|
-
'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.',
|
|
1624
|
+
'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.',
|
|
599
1625
|
parameters: {
|
|
600
1626
|
type: 'object',
|
|
601
1627
|
properties: {
|
|
602
1628
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
603
|
-
missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
|
|
1629
|
+
missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
|
|
1630
|
+
id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
|
|
604
1631
|
report: { type: 'string', description: 'Full completion report for the tower and the next review round.' },
|
|
1632
|
+
checks: {
|
|
1633
|
+
type: 'array',
|
|
1634
|
+
description: 'Optional evidence ledger entries: {name, command?, exitCode?, evidence?}.',
|
|
1635
|
+
items: {
|
|
1636
|
+
type: 'object',
|
|
1637
|
+
properties: {
|
|
1638
|
+
name: { type: 'string', description: 'What was checked.' },
|
|
1639
|
+
command: { type: 'string', description: 'Command or method used.' },
|
|
1640
|
+
exitCode: { type: 'integer', description: 'Observed exit code.' },
|
|
1641
|
+
evidence: { type: 'string', description: 'Short evidence line/log reference.' },
|
|
1642
|
+
},
|
|
1643
|
+
required: ['name'],
|
|
1644
|
+
additionalProperties: false,
|
|
1645
|
+
},
|
|
1646
|
+
},
|
|
605
1647
|
},
|
|
606
|
-
required: ['repoRoot', '
|
|
1648
|
+
required: ['repoRoot', 'report'],
|
|
607
1649
|
additionalProperties: false,
|
|
608
1650
|
},
|
|
609
|
-
|
|
1651
|
+
aliases: {"missionId":["id"]},
|
|
1652
|
+
requiresMissionId: true,
|
|
1653
|
+
board: true,
|
|
1654
|
+
output: output(SCHEMAS.complete),
|
|
610
1655
|
async execute(args, exec) {
|
|
611
1656
|
const tower = await Tower.open(args.repoRoot)
|
|
1657
|
+
// Validate authorization/state before executing repository-controlled
|
|
1658
|
+
// test code or writing a test receipt.
|
|
1659
|
+
await tower.checkCompletion({
|
|
1660
|
+
missionId: args.missionId,
|
|
1661
|
+
agentId: String(exec.agent.id),
|
|
1662
|
+
report: args.report,
|
|
1663
|
+
})
|
|
1664
|
+
const context = await tower.missionContext(args.missionId)
|
|
1665
|
+
const checks = [...(Array.isArray(args.checks) ? args.checks : [])]
|
|
1666
|
+
const testsConfig = tower.config.tests ?? {}
|
|
1667
|
+
if (testsConfig.enabled !== false && testsConfig.admission === 'complete') {
|
|
1668
|
+
const discovery = await runMissionDiscovery(tower, { id: args.missionId, worktree: context.mission.worktree })
|
|
1669
|
+
if (discovery.configured && discovery.receipt.ok !== true) {
|
|
1670
|
+
throw new TowerToolError('tests-failed', `test discovery failed for ${args.missionId}: ${discovery.receipt.error}`)
|
|
1671
|
+
}
|
|
1672
|
+
if (discovery.configured && testsConfig.autoChecks !== false && discovery.check) {
|
|
1673
|
+
if (checks.length >= 20) checks[19] = discovery.check
|
|
1674
|
+
else checks.push(discovery.check)
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
612
1677
|
return tower.complete({
|
|
613
1678
|
missionId: args.missionId,
|
|
614
1679
|
agentId: String(exec.agent.id),
|
|
615
1680
|
report: args.report,
|
|
1681
|
+
checks,
|
|
616
1682
|
})
|
|
617
1683
|
},
|
|
618
1684
|
})
|
|
@@ -620,7 +1686,7 @@ export function registerTowerTools(ctx) {
|
|
|
620
1686
|
define(ctx, {
|
|
621
1687
|
name: 'tower_finding',
|
|
622
1688
|
description:
|
|
623
|
-
'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.',
|
|
1689
|
+
'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.',
|
|
624
1690
|
parameters: {
|
|
625
1691
|
type: 'object',
|
|
626
1692
|
properties: {
|
|
@@ -632,12 +1698,15 @@ export function registerTowerTools(ctx) {
|
|
|
632
1698
|
location: { type: 'string', description: 'File/symbol the finding concerns.' },
|
|
633
1699
|
details: { type: 'string', description: 'Full details: evidence, reproduction, impact.' },
|
|
634
1700
|
suggested_fix: { type: 'string', description: 'What you would do about it.' },
|
|
635
|
-
missionId: { type: 'string', description: 'Optional mission this finding relates to.' },
|
|
1701
|
+
missionId: { type: 'string', description: 'Optional mission this finding relates to (alias: mission_id).' },
|
|
1702
|
+
mission_id: { type: 'string', description: 'Alias for missionId.' },
|
|
636
1703
|
},
|
|
637
1704
|
required: ['repoRoot', 'type', 'title', 'summary', 'details', 'suggested_fix'],
|
|
638
1705
|
additionalProperties: false,
|
|
639
1706
|
},
|
|
640
|
-
|
|
1707
|
+
aliases: {"missionId":["mission_id"]},
|
|
1708
|
+
board: true,
|
|
1709
|
+
output: output(SCHEMAS.finding),
|
|
641
1710
|
async execute(args, exec) {
|
|
642
1711
|
const tower = await Tower.open(args.repoRoot)
|
|
643
1712
|
const view = await tower.rosterView()
|
|
@@ -656,6 +1725,51 @@ export function registerTowerTools(ctx) {
|
|
|
656
1725
|
},
|
|
657
1726
|
})
|
|
658
1727
|
|
|
1728
|
+
define(ctx, {
|
|
1729
|
+
name: 'tower_findings',
|
|
1730
|
+
description:
|
|
1731
|
+
'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.',
|
|
1732
|
+
parameters: {
|
|
1733
|
+
type: 'object',
|
|
1734
|
+
properties: {
|
|
1735
|
+
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
1736
|
+
id: { type: 'string', description: 'Finding id, e.g. F1. When present the call reads or updates that finding.' },
|
|
1737
|
+
status: {
|
|
1738
|
+
type: 'string',
|
|
1739
|
+
enum: ['open', 'triaged', 'fixed', 'wontfix', 'all'],
|
|
1740
|
+
description: 'With id: target status. Without id: filter (default all).',
|
|
1741
|
+
},
|
|
1742
|
+
note: { type: 'string', description: 'Optional note recorded together with an update.' },
|
|
1743
|
+
type: { type: 'string', enum: ['bug', 'improve', 'vuln', 'idea'], description: 'Filter by finding type.' },
|
|
1744
|
+
severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'], description: 'Filter by severity.' },
|
|
1745
|
+
missionId: { type: 'string', description: 'Filter by mission id (alias: mission_id).' },
|
|
1746
|
+
mission_id: { type: 'string', description: 'Alias for missionId.' },
|
|
1747
|
+
open: { type: 'boolean', description: 'Only findings that are not fixed or wontfix.' },
|
|
1748
|
+
limit: { type: 'integer', minimum: 1, maximum: 200, description: 'Max findings to list (default 50).' },
|
|
1749
|
+
},
|
|
1750
|
+
required: ['repoRoot'],
|
|
1751
|
+
additionalProperties: false,
|
|
1752
|
+
},
|
|
1753
|
+
aliases: {"missionId":["mission_id"]},
|
|
1754
|
+
output: output(SCHEMAS.findings),
|
|
1755
|
+
async execute(args, exec) {
|
|
1756
|
+
const tower = await Tower.open(args.repoRoot)
|
|
1757
|
+
const view = await tower.rosterView()
|
|
1758
|
+
const self = view.roster.find((entry) => entry.agentId === String(exec.agent.id))
|
|
1759
|
+
return tower.findings({
|
|
1760
|
+
id: args.id,
|
|
1761
|
+
status: args.status,
|
|
1762
|
+
note: args.note,
|
|
1763
|
+
type: args.type,
|
|
1764
|
+
severity: args.severity,
|
|
1765
|
+
missionId: args.missionId,
|
|
1766
|
+
open: args.open,
|
|
1767
|
+
limit: args.limit,
|
|
1768
|
+
actor: { name: self ? self.name : 'tower', role: self ? self.role : 'tower' },
|
|
1769
|
+
})
|
|
1770
|
+
},
|
|
1771
|
+
})
|
|
1772
|
+
|
|
659
1773
|
define(ctx, {
|
|
660
1774
|
name: 'tower_review',
|
|
661
1775
|
description:
|
|
@@ -664,7 +1778,8 @@ export function registerTowerTools(ctx) {
|
|
|
664
1778
|
type: 'object',
|
|
665
1779
|
properties: {
|
|
666
1780
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
667
|
-
missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
|
|
1781
|
+
missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
|
|
1782
|
+
id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
|
|
668
1783
|
status: { type: 'string', description: 'Verdict: "clean", or "p1-Nitems" / "p2-Nitems" with the number of findings at that priority.' },
|
|
669
1784
|
merge: { type: 'string', enum: ['merge', 'fix-then-merge', 'hold'], description: 'Merge recommendation for the tower.' },
|
|
670
1785
|
findings: { type: 'string', description: 'Full findings text (markdown); write "none" when clean.' },
|
|
@@ -672,10 +1787,13 @@ export function registerTowerTools(ctx) {
|
|
|
672
1787
|
decision: { type: 'string', description: 'The reasoning behind your verdict.' },
|
|
673
1788
|
breakGlassReason: { type: 'string', description: 'Tower-only exceptional self-review: why no independent reviewer was used.' },
|
|
674
1789
|
},
|
|
675
|
-
required: ['repoRoot', '
|
|
1790
|
+
required: ['repoRoot', 'status', 'merge', 'findings', 'decision'],
|
|
676
1791
|
additionalProperties: false,
|
|
677
1792
|
},
|
|
678
|
-
|
|
1793
|
+
aliases: {"missionId":["id"]},
|
|
1794
|
+
requiresMissionId: true,
|
|
1795
|
+
board: true,
|
|
1796
|
+
output: output(SCHEMAS.review),
|
|
679
1797
|
async execute(args, exec) {
|
|
680
1798
|
const tower = await Tower.open(args.repoRoot)
|
|
681
1799
|
return tower.submitReview({
|
|
@@ -699,16 +1817,19 @@ export function registerTowerTools(ctx) {
|
|
|
699
1817
|
type: 'object',
|
|
700
1818
|
properties: {
|
|
701
1819
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
702
|
-
missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
|
|
1820
|
+
missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
|
|
1821
|
+
id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
|
|
703
1822
|
},
|
|
704
|
-
required: ['repoRoot'
|
|
1823
|
+
required: ['repoRoot'],
|
|
705
1824
|
additionalProperties: false,
|
|
706
1825
|
},
|
|
707
|
-
|
|
1826
|
+
aliases: {"missionId":["id"]},
|
|
1827
|
+
requiresMissionId: true,
|
|
1828
|
+
output: output(SCHEMAS.preflight),
|
|
708
1829
|
async execute(args) {
|
|
709
1830
|
const tower = await Tower.open(args.repoRoot)
|
|
710
|
-
const { activity } = await observeActivity(ctx, tower)
|
|
711
|
-
const gate = await tower.evaluateGate({ missionId: args.missionId, activity })
|
|
1831
|
+
const { activity, parentStatus } = await observeActivity(ctx, tower)
|
|
1832
|
+
const gate = await tower.evaluateGate({ missionId: args.missionId, activity, parentStatus })
|
|
712
1833
|
return {
|
|
713
1834
|
ok: gate.ok,
|
|
714
1835
|
blockers: gate.blockers,
|
|
@@ -737,21 +1858,32 @@ export function registerTowerTools(ctx) {
|
|
|
737
1858
|
type: 'object',
|
|
738
1859
|
properties: {
|
|
739
1860
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
740
|
-
missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
|
|
1861
|
+
missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
|
|
1862
|
+
id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
|
|
741
1863
|
timeoutMs: {
|
|
742
1864
|
type: 'integer',
|
|
743
1865
|
minimum: 1000,
|
|
744
1866
|
description: 'Optional timeout for the merge command in milliseconds, including pre-merge hooks (default 15 minutes).',
|
|
745
1867
|
},
|
|
1868
|
+
timeout_ms: {
|
|
1869
|
+
type: 'integer',
|
|
1870
|
+
minimum: 1000,
|
|
1871
|
+
description: 'Alias for timeoutMs.',
|
|
1872
|
+
},
|
|
746
1873
|
},
|
|
747
|
-
required: ['repoRoot'
|
|
1874
|
+
required: ['repoRoot'],
|
|
748
1875
|
additionalProperties: false,
|
|
749
1876
|
},
|
|
750
|
-
|
|
751
|
-
|
|
1877
|
+
aliases: {"missionId":["id"],"timeoutMs":["timeout_ms"]},
|
|
1878
|
+
requiresMissionId: true,
|
|
1879
|
+
board: true,
|
|
1880
|
+
output: output(SCHEMAS.merge),
|
|
1881
|
+
async execute(args, exec) {
|
|
752
1882
|
const tower = await Tower.open(args.repoRoot)
|
|
753
|
-
const
|
|
754
|
-
|
|
1883
|
+
const actor = await actorFor(tower, exec)
|
|
1884
|
+
requireTowerActor(actor, 'merge missions')
|
|
1885
|
+
const { activity, parentStatus } = await observeActivity(ctx, tower)
|
|
1886
|
+
return tower.merge({ missionId: args.missionId, activity, parentStatus, timeoutMs: args.timeoutMs })
|
|
755
1887
|
},
|
|
756
1888
|
})
|
|
757
1889
|
|
|
@@ -763,14 +1895,20 @@ export function registerTowerTools(ctx) {
|
|
|
763
1895
|
type: 'object',
|
|
764
1896
|
properties: {
|
|
765
1897
|
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
766
|
-
missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
|
|
1898
|
+
missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
|
|
1899
|
+
id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
|
|
767
1900
|
},
|
|
768
|
-
required: ['repoRoot'
|
|
1901
|
+
required: ['repoRoot'],
|
|
769
1902
|
additionalProperties: false,
|
|
770
1903
|
},
|
|
771
|
-
|
|
772
|
-
|
|
1904
|
+
aliases: {"missionId":["id"]},
|
|
1905
|
+
requiresMissionId: true,
|
|
1906
|
+
board: true,
|
|
1907
|
+
output: output(SCHEMAS.rebase),
|
|
1908
|
+
async execute(args, exec) {
|
|
773
1909
|
const tower = await Tower.open(args.repoRoot)
|
|
1910
|
+
const actor = await actorFor(tower, exec)
|
|
1911
|
+
requireTowerActor(actor, 'rebase missions')
|
|
774
1912
|
const { activity } = await observeActivity(ctx, tower)
|
|
775
1913
|
return tower.rebase({ missionId: args.missionId, activity })
|
|
776
1914
|
},
|
|
@@ -787,13 +1925,18 @@ export function registerTowerTools(ctx) {
|
|
|
787
1925
|
force: { type: 'boolean', description: 'Remove worktrees that contain uncommitted changes (never a live agent).' },
|
|
788
1926
|
exclude: { type: 'array', items: { type: 'string' }, description: 'Mission ids to keep.' },
|
|
789
1927
|
dry_run: { type: 'boolean', description: 'Preview remove/keep decisions without changing disk or state.' },
|
|
1928
|
+
dryRun: { type: 'boolean', description: 'Alias for dry_run.' },
|
|
790
1929
|
},
|
|
791
1930
|
required: ['repoRoot'],
|
|
792
1931
|
additionalProperties: false,
|
|
793
1932
|
},
|
|
794
|
-
|
|
795
|
-
|
|
1933
|
+
aliases: {"dry_run":["dryRun"]},
|
|
1934
|
+
board: true,
|
|
1935
|
+
output: output(SCHEMAS.teardown),
|
|
1936
|
+
async execute(args, exec) {
|
|
796
1937
|
const tower = await Tower.open(args.repoRoot)
|
|
1938
|
+
const actor = await actorFor(tower, exec)
|
|
1939
|
+
requireTowerActor(actor, 'tear down worktrees')
|
|
797
1940
|
const { activity } = await observeActivity(ctx, tower)
|
|
798
1941
|
return tower.teardown({
|
|
799
1942
|
force: args.force === true,
|
|
@@ -803,4 +1946,295 @@ export function registerTowerTools(ctx) {
|
|
|
803
1946
|
})
|
|
804
1947
|
},
|
|
805
1948
|
})
|
|
1949
|
+
|
|
1950
|
+
define(ctx, {
|
|
1951
|
+
name: 'tower_doctor',
|
|
1952
|
+
description:
|
|
1953
|
+
'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.',
|
|
1954
|
+
parameters: {
|
|
1955
|
+
type: 'object',
|
|
1956
|
+
properties: { repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' } },
|
|
1957
|
+
required: ['repoRoot'],
|
|
1958
|
+
additionalProperties: false,
|
|
1959
|
+
},
|
|
1960
|
+
output: output(SCHEMAS.doctor),
|
|
1961
|
+
async execute(args, exec) {
|
|
1962
|
+
const tower = await Tower.open(args.repoRoot)
|
|
1963
|
+
await actorFor(tower, exec)
|
|
1964
|
+
return runDoctor({ tower, ctx, agent: exec.agent })
|
|
1965
|
+
},
|
|
1966
|
+
})
|
|
1967
|
+
|
|
1968
|
+
define(ctx, {
|
|
1969
|
+
name: 'tower_test',
|
|
1970
|
+
description:
|
|
1971
|
+
'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.',
|
|
1972
|
+
parameters: {
|
|
1973
|
+
type: 'object',
|
|
1974
|
+
properties: {
|
|
1975
|
+
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
1976
|
+
missionId: { type: 'string', description: 'Mission id, e.g. M1 (alias: id).' },
|
|
1977
|
+
id: { type: 'string', description: 'Alias for missionId.' },
|
|
1978
|
+
},
|
|
1979
|
+
required: ['repoRoot', 'missionId'],
|
|
1980
|
+
additionalProperties: false,
|
|
1981
|
+
},
|
|
1982
|
+
aliases: { missionId: ['id'] },
|
|
1983
|
+
requiresMissionId: true,
|
|
1984
|
+
output: output(SCHEMAS.test),
|
|
1985
|
+
async execute(args, exec) {
|
|
1986
|
+
const tower = await Tower.open(args.repoRoot)
|
|
1987
|
+
const actor = await actorFor(tower, exec)
|
|
1988
|
+
const context = await tower.missionContext(args.missionId)
|
|
1989
|
+
if (!context.mission.worktree) fail(`mission ${args.missionId} has no worktree`)
|
|
1990
|
+
if (actor.role === 'reviewer') fail('reviewers may not run test discovery')
|
|
1991
|
+
if (actor.role === 'worker' && context.mission.owner && context.mission.owner !== actor.name) {
|
|
1992
|
+
fail(`worker ${actor.name} does not own mission ${args.missionId}`)
|
|
1993
|
+
}
|
|
1994
|
+
await tower.assertWorktree(context.mission.worktree)
|
|
1995
|
+
const discovery = await runMissionDiscovery(tower, context.mission)
|
|
1996
|
+
if (!discovery.configured) {
|
|
1997
|
+
return {
|
|
1998
|
+
ok: false,
|
|
1999
|
+
configured: false,
|
|
2000
|
+
error: discovery.error ?? 'tower.testDiscovery is not configured in the worktree package.json',
|
|
2001
|
+
tests: [],
|
|
2002
|
+
commands: [],
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
const receipt = discovery.receipt
|
|
2006
|
+
return {
|
|
2007
|
+
ok: receipt.ok === true,
|
|
2008
|
+
configured: true,
|
|
2009
|
+
treeTip: receipt.treeTip ?? null,
|
|
2010
|
+
tests: receipt.tests ?? [],
|
|
2011
|
+
commands: receipt.commands ?? [],
|
|
2012
|
+
error: receipt.error ?? undefined,
|
|
2013
|
+
receipt,
|
|
2014
|
+
checks: discovery.check ? [discovery.check] : [],
|
|
2015
|
+
}
|
|
2016
|
+
},
|
|
2017
|
+
})
|
|
2018
|
+
|
|
2019
|
+
define(ctx, {
|
|
2020
|
+
name: 'tower_batch',
|
|
2021
|
+
description:
|
|
2022
|
+
'Manage finite evidence batches: create freezes missionIds/requiredChecks/receiptPaths, status summarizes members, close verifies every required check and receipt hash. Membership and exclusions are immutable after create; close never removes missions.',
|
|
2023
|
+
parameters: {
|
|
2024
|
+
type: 'object',
|
|
2025
|
+
properties: {
|
|
2026
|
+
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
2027
|
+
action: { type: 'string', enum: ['create', 'status', 'close'], description: 'Batch operation.' },
|
|
2028
|
+
missionIds: { type: 'array', items: { type: 'string' }, description: 'Exact mission ids in this batch (create).' },
|
|
2029
|
+
exclusions: { type: 'array', items: { type: 'string' }, description: 'Paused legacy mission ids excluded from the batch.' },
|
|
2030
|
+
requiredChecks: { type: 'array', items: { type: 'string' }, description: 'Check names every build member must record (default ["check"]).' },
|
|
2031
|
+
receiptPaths: { type: 'array', items: { type: 'string' }, description: 'Exact relative receipt files checked at close.' },
|
|
2032
|
+
batchId: { type: 'string', description: 'Batch id (status/close).' },
|
|
2033
|
+
checks: { type: 'array', items: { type: 'object' }, description: 'Close-time extra check evidence.' },
|
|
2034
|
+
treeTip: { type: 'string', description: 'Expected reviewed/tested commit for close evidence.' },
|
|
2035
|
+
receiptHashes: { type: 'object', description: 'Relative receipt path -> sha256 observed by the caller.' },
|
|
2036
|
+
},
|
|
2037
|
+
required: ['repoRoot', 'action'],
|
|
2038
|
+
additionalProperties: false,
|
|
2039
|
+
},
|
|
2040
|
+
board: true,
|
|
2041
|
+
output: output(SCHEMAS.batch),
|
|
2042
|
+
async execute(args, exec) {
|
|
2043
|
+
const tower = await Tower.open(args.repoRoot)
|
|
2044
|
+
const actor = await actorFor(tower, exec)
|
|
2045
|
+
if (args.action === 'status') {
|
|
2046
|
+
const state = await tower.load()
|
|
2047
|
+
const batch = args.batchId
|
|
2048
|
+
? (state.batches ?? []).find((entry) => entry.id === args.batchId)
|
|
2049
|
+
: null
|
|
2050
|
+
if (args.batchId && !batch) fail(`unknown batch "${args.batchId}"`)
|
|
2051
|
+
return {
|
|
2052
|
+
ok: true,
|
|
2053
|
+
action: 'status',
|
|
2054
|
+
batches: (state.batches ?? []).map((entry) => summarizeBatch(state, entry)),
|
|
2055
|
+
...(batch ? { batch: summarizeBatch(state, batch) } : {}),
|
|
2056
|
+
...(args.batchId ? { summary: summarizeBatch(state, batch) } : {}),
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
requireTowerActor(actor, args.action === 'create' ? 'create a batch' : 'close a batch')
|
|
2060
|
+
if (args.action === 'create') {
|
|
2061
|
+
const batch = await tower.createBatch(args, actor.name)
|
|
2062
|
+
return { ok: true, action: 'create', batch, batches: (await tower.batchStatus()).batches }
|
|
2063
|
+
}
|
|
2064
|
+
if (typeof args.batchId !== 'string' || args.batchId.trim().length === 0) {
|
|
2065
|
+
fail('tower_batch close requires batchId')
|
|
2066
|
+
}
|
|
2067
|
+
const result = await tower.closeBatch(args.batchId.trim(), {
|
|
2068
|
+
checks: args.checks,
|
|
2069
|
+
treeTip: args.treeTip,
|
|
2070
|
+
receiptHashes: args.receiptHashes,
|
|
2071
|
+
}, actor.name)
|
|
2072
|
+
return {
|
|
2073
|
+
ok: result.ok,
|
|
2074
|
+
action: 'close',
|
|
2075
|
+
batch: result.batch,
|
|
2076
|
+
blockers: (result.blockers ?? []).map((blocker) => ({
|
|
2077
|
+
code: blocker.code ?? 'batch-invalid',
|
|
2078
|
+
detail: blocker.detail ?? blocker.message ?? String(blocker),
|
|
2079
|
+
...(blocker.cleanup ? { cleanup: blocker.cleanup } : {}),
|
|
2080
|
+
...(blocker.timeout ? { timeout: true } : {}),
|
|
2081
|
+
})),
|
|
2082
|
+
checks: result.checks ?? [],
|
|
2083
|
+
}
|
|
2084
|
+
},
|
|
2085
|
+
})
|
|
2086
|
+
|
|
2087
|
+
define(ctx, {
|
|
2088
|
+
name: 'tower_resource',
|
|
2089
|
+
description:
|
|
2090
|
+
'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.',
|
|
2091
|
+
parameters: {
|
|
2092
|
+
type: 'object',
|
|
2093
|
+
properties: {
|
|
2094
|
+
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
2095
|
+
action: { type: 'string', enum: ['acquire', 'release', 'status'], description: 'Lease operation.' },
|
|
2096
|
+
name: { type: 'string', description: 'Lease name, e.g. plugin-load or heavy-ci.' },
|
|
2097
|
+
holder: { type: 'string', description: 'Holder label (defaults to the caller).' },
|
|
2098
|
+
token: { type: 'string', description: 'Release token; generated on acquire when omitted.' },
|
|
2099
|
+
ttlMs: { type: 'integer', minimum: 1000, description: 'Lease lifetime (default leases.ttlMs).' },
|
|
2100
|
+
force: { type: 'boolean', description: 'Explicitly release an expired lease (token still required).' },
|
|
2101
|
+
},
|
|
2102
|
+
required: ['repoRoot', 'action'],
|
|
2103
|
+
additionalProperties: false,
|
|
2104
|
+
},
|
|
2105
|
+
output: output(SCHEMAS.resource),
|
|
2106
|
+
async execute(args, exec) {
|
|
2107
|
+
const tower = await Tower.open(args.repoRoot)
|
|
2108
|
+
const actor = await actorFor(tower, exec)
|
|
2109
|
+
if (args.action === 'status') {
|
|
2110
|
+
const info = await inspectLeases(tower.commonDir)
|
|
2111
|
+
return { ok: true, action: 'status', leases: info.leases, ...info }
|
|
2112
|
+
}
|
|
2113
|
+
requireTowerActor(actor, args.action === 'acquire' ? 'acquire a lease' : 'release a lease')
|
|
2114
|
+
if (typeof args.name !== 'string' || args.name.trim().length === 0) fail('tower_resource requires name')
|
|
2115
|
+
const name = args.name.trim()
|
|
2116
|
+
const ttlMs = Number(args.ttlMs ?? tower.config.leases?.ttlMs ?? 900000)
|
|
2117
|
+
if (args.action === 'acquire') {
|
|
2118
|
+
const lease = await acquireLease(tower.commonDir, {
|
|
2119
|
+
name,
|
|
2120
|
+
holder: args.holder ?? actor.name,
|
|
2121
|
+
token: args.token ?? crypto.randomUUID(),
|
|
2122
|
+
pid: process.pid,
|
|
2123
|
+
ttlMs,
|
|
2124
|
+
})
|
|
2125
|
+
return { ok: true, action: 'acquire', lease }
|
|
2126
|
+
}
|
|
2127
|
+
const token = args.token
|
|
2128
|
+
if (typeof token !== 'string' || token.length === 0) fail('tower_resource release requires token')
|
|
2129
|
+
const released = await releaseLease(tower.commonDir, { name, token, force: args.force === true })
|
|
2130
|
+
return { ok: true, action: 'release', released, leases: (await inspectLeases(tower.commonDir)).leases }
|
|
2131
|
+
},
|
|
2132
|
+
})
|
|
2133
|
+
|
|
2134
|
+
define(ctx, {
|
|
2135
|
+
name: 'tower_import_legacy',
|
|
2136
|
+
description:
|
|
2137
|
+
'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.',
|
|
2138
|
+
parameters: {
|
|
2139
|
+
type: 'object',
|
|
2140
|
+
properties: {
|
|
2141
|
+
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
2142
|
+
sourcePath: { type: 'string', description: 'Absolute path to the legacy JSON state file.' },
|
|
2143
|
+
},
|
|
2144
|
+
required: ['repoRoot', 'sourcePath'],
|
|
2145
|
+
additionalProperties: false,
|
|
2146
|
+
},
|
|
2147
|
+
board: true,
|
|
2148
|
+
output: output(SCHEMAS.importLegacy),
|
|
2149
|
+
async execute(args, exec) {
|
|
2150
|
+
const tower = await Tower.open(args.repoRoot)
|
|
2151
|
+
const actor = await actorFor(tower, exec)
|
|
2152
|
+
requireTowerActor(actor, 'import a legacy snapshot')
|
|
2153
|
+
const imported = await importLegacySnapshot({
|
|
2154
|
+
repoRoot: tower.root,
|
|
2155
|
+
commonDir: tower.commonDir,
|
|
2156
|
+
sourcePath: args.sourcePath,
|
|
2157
|
+
})
|
|
2158
|
+
const recorded = await tower.recordLegacySnapshot(imported.snapshot)
|
|
2159
|
+
return { ok: true, summary: imported.summary, snapshot: recorded.snapshot }
|
|
2160
|
+
},
|
|
2161
|
+
})
|
|
2162
|
+
|
|
2163
|
+
define(ctx, {
|
|
2164
|
+
name: 'tower_legacy_map',
|
|
2165
|
+
description:
|
|
2166
|
+
'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.',
|
|
2167
|
+
parameters: {
|
|
2168
|
+
type: 'object',
|
|
2169
|
+
properties: {
|
|
2170
|
+
repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
|
|
2171
|
+
action: { type: 'string', enum: ['map', 'status'], description: 'Mapping operation.' },
|
|
2172
|
+
sourceHash: { type: 'string', description: 'Legacy snapshot sha256 (map).' },
|
|
2173
|
+
oldId: { type: 'string', description: 'Legacy mission id (map).' },
|
|
2174
|
+
newMissionId: { type: 'string', description: 'Current mission id; optional audit pointer.' },
|
|
2175
|
+
},
|
|
2176
|
+
required: ['repoRoot', 'action'],
|
|
2177
|
+
additionalProperties: false,
|
|
2178
|
+
},
|
|
2179
|
+
output: output(SCHEMAS.legacyMap),
|
|
2180
|
+
async execute(args, exec) {
|
|
2181
|
+
const tower = await Tower.open(args.repoRoot)
|
|
2182
|
+
const actor = await actorFor(tower, exec)
|
|
2183
|
+
const state = await tower.load()
|
|
2184
|
+
if (args.action === 'status') {
|
|
2185
|
+
return { ok: true, maps: state.legacyMaps ?? [] }
|
|
2186
|
+
}
|
|
2187
|
+
requireTowerActor(actor, 'map a legacy mission')
|
|
2188
|
+
const validation = validateLegacyMap(state, args)
|
|
2189
|
+
if (validation.ok !== true) fail(validation.error ?? 'invalid legacy mapping')
|
|
2190
|
+
const map = { ...validation.map, by: actor.name, at: Date.now() }
|
|
2191
|
+
const recorded = await tower.recordLegacyMap(map)
|
|
2192
|
+
return { ok: true, map: recorded.map, maps: [...(state.legacyMaps ?? []), recorded.map] }
|
|
2193
|
+
},
|
|
2194
|
+
})
|
|
2195
|
+
|
|
2196
|
+
// Dev-only hot reload. The tool is registered whenever dev.hotReload is on;
|
|
2197
|
+
// it reports reload-unavailable when the profile has no HMR service. When the
|
|
2198
|
+
// Host passed a shared bridge, its status() reflects the command's reloads too.
|
|
2199
|
+
const dev = runtimeConfig().dev ?? {}
|
|
2200
|
+
if (dev.hotReload !== false) {
|
|
2201
|
+
const bridge =
|
|
2202
|
+
sharedHmrBridge ??
|
|
2203
|
+
createHmrBridge(ctx, {
|
|
2204
|
+
requiredIds: dev.reloadRequiredIds,
|
|
2205
|
+
delayMs: dev.reloadDelayMs,
|
|
2206
|
+
logger: ctx.logger ?? console,
|
|
2207
|
+
})
|
|
2208
|
+
define(ctx, {
|
|
2209
|
+
name: 'tower_reload',
|
|
2210
|
+
description:
|
|
2211
|
+
'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.',
|
|
2212
|
+
parameters: {
|
|
2213
|
+
type: 'object',
|
|
2214
|
+
properties: {
|
|
2215
|
+
repoRoot: { type: 'string', description: 'Optional repository root; when present the caller must be the tower.' },
|
|
2216
|
+
},
|
|
2217
|
+
required: [],
|
|
2218
|
+
additionalProperties: false,
|
|
2219
|
+
},
|
|
2220
|
+
output: output(SCHEMAS.reload),
|
|
2221
|
+
async execute(args, exec) {
|
|
2222
|
+
if (typeof args.repoRoot === 'string') {
|
|
2223
|
+
const tower = await Tower.open(args.repoRoot)
|
|
2224
|
+
const actor = await actorFor(tower, exec)
|
|
2225
|
+
requireTowerActor(actor, 'reload the Tower plugin')
|
|
2226
|
+
}
|
|
2227
|
+
if (!bridge.available) {
|
|
2228
|
+
throw new TowerToolError('reload-unavailable', 'this DSH profile has no HMR service; restart DSH instead')
|
|
2229
|
+
}
|
|
2230
|
+
const outcome = bridge.schedule()
|
|
2231
|
+
return {
|
|
2232
|
+
ok: true,
|
|
2233
|
+
...outcome,
|
|
2234
|
+
...(outcome.lastError ? { error: outcome.lastError } : {}),
|
|
2235
|
+
restartHint: 'If the new code does not appear, restart DSH or check the profile hmr entry.',
|
|
2236
|
+
}
|
|
2237
|
+
},
|
|
2238
|
+
})
|
|
2239
|
+
}
|
|
806
2240
|
}
|