dsh-tower 1.2.0 → 1.3.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 +19 -0
- package/CONTRIBUTING.md +36 -0
- package/README.en.md +46 -23
- package/README.md +42 -21
- package/SECURITY.md +20 -0
- package/cordis.patch.yml +22 -0
- package/docs/README.md +13 -0
- package/docs/architecture/overview.md +66 -0
- package/docs/examples/first-mission.md +58 -0
- package/docs/faq.md +43 -0
- package/docs/guide/install-upgrade-migration.md +48 -0
- package/docs/guide/quickstart.md +65 -0
- package/docs/operations/runbook.md +57 -0
- package/docs/operations/security.md +39 -0
- package/docs/reference/blockers.md +50 -0
- package/docs/reference/config.md +51 -0
- package/docs/reference/tools.md +33 -0
- package/index.js +33 -37
- package/lib/config.js +119 -0
- package/lib/modeStore.js +19 -15
- package/lib/tools.js +72 -10
- package/lib/tower.js +74 -5
- package/package.json +18 -4
- package/reviewer.js +39 -0
- package/skills/tower-recovery.md +40 -0
- package/skills/tower-review.md +14 -0
- package/skills/tower-worker-protocol.md +13 -0
- package/skills.js +63 -0
- package/worker.js +3 -2
package/lib/tools.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Tower tools: init, plan, mission, status, spawn, roster, adopt.
|
|
2
2
|
import { Tower } from './tower.js'
|
|
3
|
+
import { runtimeConfig } from './config.js'
|
|
3
4
|
|
|
4
5
|
const OUTPUT = {
|
|
5
6
|
schema: { type: 'object' },
|
|
@@ -251,7 +252,7 @@ export function registerTowerTools(ctx) {
|
|
|
251
252
|
define(ctx, {
|
|
252
253
|
name: 'tower_spawn',
|
|
253
254
|
description:
|
|
254
|
-
'Spawn a worker or reviewer for a mission. Provisions (or reuses) the mission git worktree and branch, generates a self-contained briefing
|
|
255
|
+
'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
256
|
parameters: {
|
|
256
257
|
type: 'object',
|
|
257
258
|
properties: {
|
|
@@ -272,9 +273,17 @@ export function registerTowerTools(ctx) {
|
|
|
272
273
|
const tower = await Tower.open(args.repoRoot)
|
|
273
274
|
const role = args.role ?? 'worker'
|
|
274
275
|
if (!ROLE_ENUM.includes(role)) fail(`role must be one of ${ROLE_ENUM.join('|')}`)
|
|
276
|
+
const config = runtimeConfig()
|
|
277
|
+
const preset = role === 'reviewer' ? config.spawn.reviewerPreset : config.spawn.workerPreset
|
|
278
|
+
const model = role === 'reviewer' ? config.spawn.reviewerModel : config.spawn.workerModel
|
|
275
279
|
const context = await tower.missionContext(args.missionId)
|
|
276
|
-
if (role === 'reviewer'
|
|
277
|
-
|
|
280
|
+
if (role === 'reviewer') {
|
|
281
|
+
if (!context.mission.worktree) {
|
|
282
|
+
fail('reviewer needs an existing mission worktree; spawn a worker first')
|
|
283
|
+
}
|
|
284
|
+
if (context.mission.status !== 'completed') {
|
|
285
|
+
fail(`reviewer requires a completed mission awaiting review; ${context.mission.id} is ${context.mission.status}`)
|
|
286
|
+
}
|
|
278
287
|
}
|
|
279
288
|
const provisioned = await tower.provision({
|
|
280
289
|
missionId: args.missionId,
|
|
@@ -311,25 +320,35 @@ export function registerTowerTools(ctx) {
|
|
|
311
320
|
const subagents = ctx.get('subagents')
|
|
312
321
|
if (!subagents) fail('subagents service is unavailable; cannot spawn')
|
|
313
322
|
const providers = subagents.list()
|
|
314
|
-
const provider = providers.includes('spawn') ? 'spawn' : providers[0]
|
|
323
|
+
const provider = config.spawn.provider || (providers.includes('spawn') ? 'spawn' : providers[0])
|
|
315
324
|
if (!provider) fail('no subagent provider is registered')
|
|
325
|
+
const presets = ctx.get('agentPresets')
|
|
326
|
+
if (presets && typeof presets.list === 'function') {
|
|
327
|
+
const available = await presets.list()
|
|
328
|
+
if (!available.some((entry) => String(entry.id) === preset)) {
|
|
329
|
+
fail(`agent preset "${preset}" is not registered; check the Tower bundle's preset rows and restart DSH`)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const agentOptions = {}
|
|
334
|
+
for (const key of ['provider', 'model', 'reasoningEffort', 'maxTokens']) {
|
|
335
|
+
if (model && model[key] !== undefined && model[key] !== null && model[key] !== '') agentOptions[key] = model[key]
|
|
336
|
+
}
|
|
316
337
|
|
|
317
338
|
const activation = await subagents.startActivation({
|
|
318
339
|
provider,
|
|
319
340
|
label: name,
|
|
320
341
|
request: {
|
|
321
342
|
cwd: worktree.path,
|
|
322
|
-
agentPreset:
|
|
343
|
+
agentPreset: preset,
|
|
323
344
|
prompt: [{ type: 'text', text: briefing }],
|
|
324
345
|
parent: exec.agent,
|
|
325
|
-
maxDepth: 1,
|
|
346
|
+
maxDepth: config.spawn.maxDepth ?? 1,
|
|
347
|
+
...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
|
|
326
348
|
},
|
|
327
349
|
signal: exec.signal,
|
|
328
350
|
delivery: 'parent',
|
|
329
351
|
})
|
|
330
|
-
if (activation.result && typeof activation.result.catch === 'function') {
|
|
331
|
-
activation.result.catch(() => {})
|
|
332
|
-
}
|
|
333
352
|
|
|
334
353
|
const agentId = String(activation.childId)
|
|
335
354
|
const entry = await tower.recordSpawn({
|
|
@@ -342,16 +361,22 @@ export function registerTowerTools(ctx) {
|
|
|
342
361
|
branch: worktree.branch,
|
|
343
362
|
})
|
|
344
363
|
|
|
364
|
+
const recordOutcome = (state, detail) =>
|
|
365
|
+
tower.recordSpawnOutcome({ missionId: mission.id, name, state, detail }).catch(() => {})
|
|
366
|
+
|
|
345
367
|
const report = {
|
|
346
368
|
name,
|
|
347
369
|
agentId,
|
|
348
370
|
role,
|
|
349
371
|
missionId: mission.id,
|
|
372
|
+
preset,
|
|
373
|
+
provider,
|
|
374
|
+
maxDepth: config.spawn.maxDepth ?? 1,
|
|
375
|
+
...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
|
|
350
376
|
worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
|
|
351
377
|
created: provisioned.created,
|
|
352
378
|
briefingChars: briefing.length,
|
|
353
379
|
rosterEntry: entry,
|
|
354
|
-
provider,
|
|
355
380
|
}
|
|
356
381
|
|
|
357
382
|
if (args.wait === true) {
|
|
@@ -363,16 +388,32 @@ export function registerTowerTools(ctx) {
|
|
|
363
388
|
let result
|
|
364
389
|
try {
|
|
365
390
|
result = await Promise.race([activation.result, timeout])
|
|
391
|
+
} catch (error) {
|
|
392
|
+
report.stopReason = 'failed'
|
|
393
|
+
report.diagnostic = String(error?.message ?? error)
|
|
394
|
+
await recordOutcome('failed', report.diagnostic)
|
|
395
|
+
throw error
|
|
366
396
|
} finally {
|
|
367
397
|
clearTimeout(timer)
|
|
368
398
|
}
|
|
369
399
|
if (result && result.__timeout) {
|
|
370
400
|
report.timedOut = true
|
|
401
|
+
recordOutcome('unknown', 'spawn wait timed out; check tower_roster')
|
|
371
402
|
} else {
|
|
372
403
|
report.stopReason = result?.stopReason ?? null
|
|
373
404
|
report.output = textOf(result?.output)
|
|
374
405
|
report.diagnostic = result?.diagnostic ?? null
|
|
406
|
+
const state = report.stopReason === 'completed' ? 'inactive' : 'failed'
|
|
407
|
+
if (state === 'failed') report.diagnostic = report.diagnostic ?? report.stopReason
|
|
408
|
+
await recordOutcome(state, report.diagnostic ?? null)
|
|
375
409
|
}
|
|
410
|
+
} else {
|
|
411
|
+
activation.result
|
|
412
|
+
.then((result) => {
|
|
413
|
+
const state = result?.stopReason === 'completed' ? 'inactive' : 'failed'
|
|
414
|
+
return recordOutcome(state, result?.diagnostic ?? result?.stopReason ?? null)
|
|
415
|
+
})
|
|
416
|
+
.catch((error) => recordOutcome('failed', String(error?.message ?? error)))
|
|
376
417
|
}
|
|
377
418
|
return report
|
|
378
419
|
},
|
|
@@ -423,6 +464,27 @@ export function registerTowerTools(ctx) {
|
|
|
423
464
|
},
|
|
424
465
|
})
|
|
425
466
|
|
|
467
|
+
define(ctx, {
|
|
468
|
+
name: 'tower_diff',
|
|
469
|
+
description:
|
|
470
|
+
'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.',
|
|
471
|
+
parameters: {
|
|
472
|
+
type: 'object',
|
|
473
|
+
properties: {
|
|
474
|
+
repoRoot: { type: 'string', description: 'Absolute path to the main checkout root.' },
|
|
475
|
+
missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
|
|
476
|
+
maxBytes: { type: 'integer', minimum: 1024, description: 'Patch cap in bytes (default 65536).' },
|
|
477
|
+
},
|
|
478
|
+
required: ['repoRoot', 'missionId'],
|
|
479
|
+
additionalProperties: false,
|
|
480
|
+
},
|
|
481
|
+
output: OUTPUT,
|
|
482
|
+
async execute(args) {
|
|
483
|
+
const tower = await Tower.open(args.repoRoot)
|
|
484
|
+
return tower.diff({ missionId: args.missionId, maxBytes: args.maxBytes })
|
|
485
|
+
},
|
|
486
|
+
})
|
|
487
|
+
|
|
426
488
|
define(ctx, {
|
|
427
489
|
name: 'tower_adopt',
|
|
428
490
|
description:
|
package/lib/tower.js
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
withLock,
|
|
29
29
|
shortHash,
|
|
30
30
|
} from './store.js'
|
|
31
|
+
import { runtimeConfig } from './config.js'
|
|
31
32
|
|
|
32
33
|
export const SCHEMA_VERSION = 1
|
|
33
34
|
export const MISSION_STATUSES = [
|
|
@@ -69,7 +70,10 @@ const DEFAULT_MERGE_TIMEOUT_MS = 15 * 60 * 1000
|
|
|
69
70
|
const MERGE_MAX_BUFFER = 64 * 1024 * 1024
|
|
70
71
|
|
|
71
72
|
function mergeTimeoutMs(value) {
|
|
72
|
-
if (value === undefined || value === null)
|
|
73
|
+
if (value === undefined || value === null) {
|
|
74
|
+
const configured = runtimeConfig().merge.timeoutMs
|
|
75
|
+
return Number.isInteger(configured) && configured >= 1000 ? configured : DEFAULT_MERGE_TIMEOUT_MS
|
|
76
|
+
}
|
|
73
77
|
const ms = Number(value)
|
|
74
78
|
if (!Number.isInteger(ms) || ms < 1000) fail('timeoutMs must be an integer of at least 1000 milliseconds')
|
|
75
79
|
return ms
|
|
@@ -675,6 +679,30 @@ export class Tower {
|
|
|
675
679
|
return worktree
|
|
676
680
|
}
|
|
677
681
|
|
|
682
|
+
/** Record that a spawned agent failed or went idle, so roster state is truthful. */
|
|
683
|
+
async recordSpawnOutcome(input) {
|
|
684
|
+
return withLock(this.paths, async () => {
|
|
685
|
+
const state = await this.load()
|
|
686
|
+
const entry = (state.roster ?? []).find((row) => row.name === input.name && row.missionId === input.missionId)
|
|
687
|
+
if (!entry) return null
|
|
688
|
+
const now = Date.now()
|
|
689
|
+
entry.state = input.state ?? 'failed'
|
|
690
|
+
entry.outcome = input.detail ? String(input.detail).slice(0, 1000) : null
|
|
691
|
+
entry.outcomeAt = now
|
|
692
|
+
state.revision += 1
|
|
693
|
+
state.updatedAt = now
|
|
694
|
+
validateState(state, this.paths.state)
|
|
695
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
696
|
+
await appendJsonl(this.paths.activity, {
|
|
697
|
+
at: now,
|
|
698
|
+
type: 'spawn',
|
|
699
|
+
missionId: input.missionId,
|
|
700
|
+
text: `${entry.name} ${entry.state}${entry.outcome ? `: ${entry.outcome.slice(0, 160)}` : ''}`,
|
|
701
|
+
})
|
|
702
|
+
return entry
|
|
703
|
+
})
|
|
704
|
+
}
|
|
705
|
+
|
|
678
706
|
async provision(input = {}) {
|
|
679
707
|
const missionId = input.missionId
|
|
680
708
|
if (typeof missionId !== 'string' || missionId.trim().length === 0) {
|
|
@@ -693,7 +721,9 @@ export class Tower {
|
|
|
693
721
|
const branch = `tower/${mission.id.toLowerCase()}-${slugify(mission.title)}`
|
|
694
722
|
const root = input.worktreeRoot
|
|
695
723
|
? path.resolve(input.worktreeRoot)
|
|
696
|
-
:
|
|
724
|
+
: runtimeConfig().worktrees.root
|
|
725
|
+
? path.resolve(runtimeConfig().worktrees.root)
|
|
726
|
+
: path.join(path.dirname(this.root), '.dsh-tower-worktrees', shortHash(this.commonDir))
|
|
697
727
|
if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
|
|
698
728
|
this.assertOutside(root)
|
|
699
729
|
await ensureDir(root)
|
|
@@ -751,6 +781,7 @@ export class Tower {
|
|
|
751
781
|
fail(`agent ${input.agentId} is already in the roster`)
|
|
752
782
|
}
|
|
753
783
|
const now = Date.now()
|
|
784
|
+
const spawnTip = input.cwd ? ((await tryGit(input.cwd, ['rev-parse', 'HEAD'])) ?? '').trim() || null : null
|
|
754
785
|
const entry = {
|
|
755
786
|
name: input.name,
|
|
756
787
|
role: input.role,
|
|
@@ -760,6 +791,7 @@ export class Tower {
|
|
|
760
791
|
missionId: input.missionId,
|
|
761
792
|
cwd: input.cwd ?? null,
|
|
762
793
|
branch: input.branch ?? null,
|
|
794
|
+
spawnTip,
|
|
763
795
|
state: 'active',
|
|
764
796
|
spawnedAt: now,
|
|
765
797
|
}
|
|
@@ -1111,11 +1143,17 @@ export class Tower {
|
|
|
1111
1143
|
await this.assertWorktree(mission.worktree)
|
|
1112
1144
|
const tip = await revParse(mission.worktree.path, 'HEAD')
|
|
1113
1145
|
const branch = mission.worktree.branch
|
|
1146
|
+
if (caller && caller.role === 'reviewer' && caller.spawnTip && caller.spawnTip !== tip) {
|
|
1147
|
+
fail(
|
|
1148
|
+
`reviewer ${caller.name} modified the mission branch after it was spawned (${caller.spawnTip.slice(0, 7)} -> ${tip.slice(0, 7)}); reviewers must be read-only — spawn a fresh reviewer`,
|
|
1149
|
+
)
|
|
1150
|
+
}
|
|
1114
1151
|
const prior = (state.reviews ?? []).filter((review) => review.branch === branch)
|
|
1115
1152
|
const reviewerRounds = prior.filter((review) => review.reviewer === reviewerName).length
|
|
1116
|
-
|
|
1153
|
+
const maxReviewRounds = Number(runtimeConfig().review.maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
|
|
1154
|
+
if (reviewerRounds >= maxReviewRounds) {
|
|
1117
1155
|
fail(
|
|
1118
|
-
`reviewer ${reviewerName} reached the ${
|
|
1156
|
+
`reviewer ${reviewerName} reached the ${maxReviewRounds}-round cap for ${branch}; continue with a different reviewer`,
|
|
1119
1157
|
)
|
|
1120
1158
|
}
|
|
1121
1159
|
const round = prior.length === 0 ? 1 : Math.max(...prior.map((review) => review.round)) + 1
|
|
@@ -1330,6 +1368,37 @@ export class Tower {
|
|
|
1330
1368
|
}
|
|
1331
1369
|
}
|
|
1332
1370
|
|
|
1371
|
+
/** Read-only mission diff for reviewers: branch changes against the merge base. */
|
|
1372
|
+
async diff(input = {}) {
|
|
1373
|
+
const state = await this.load()
|
|
1374
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
1375
|
+
if (!mission) fail(`unknown mission "${input.missionId}"`)
|
|
1376
|
+
if (!mission.worktree) fail(`mission ${mission.id} has no worktree to diff`)
|
|
1377
|
+
await this.assertWorktree(mission.worktree)
|
|
1378
|
+
const baseTip = await revParse(this.root, state.base)
|
|
1379
|
+
const branchTip = await revParse(mission.worktree.path, 'HEAD')
|
|
1380
|
+
const mergeBase = (await git(mission.worktree.path, ['merge-base', baseTip, branchTip])).trim()
|
|
1381
|
+
const files = (await git(mission.worktree.path, ['diff', '--name-status', `${mergeBase}..${branchTip}`]))
|
|
1382
|
+
.split('\n')
|
|
1383
|
+
.filter(Boolean)
|
|
1384
|
+
const commits = (await git(mission.worktree.path, ['log', '--oneline', `${mergeBase}..${branchTip}`]))
|
|
1385
|
+
.split('\n')
|
|
1386
|
+
.filter(Boolean)
|
|
1387
|
+
const fullPatch = await git(mission.worktree.path, ['diff', `${mergeBase}..${branchTip}`])
|
|
1388
|
+
const limit = Number.isInteger(input.maxBytes) && input.maxBytes > 0 ? input.maxBytes : 64 * 1024
|
|
1389
|
+
return {
|
|
1390
|
+
missionId: mission.id,
|
|
1391
|
+
branch: mission.worktree.branch,
|
|
1392
|
+
baseTip,
|
|
1393
|
+
branchTip,
|
|
1394
|
+
mergeBase,
|
|
1395
|
+
files,
|
|
1396
|
+
commits,
|
|
1397
|
+
truncated: fullPatch.length > limit,
|
|
1398
|
+
patch: fullPatch.slice(0, limit),
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1333
1402
|
/**
|
|
1334
1403
|
* Record a merge that already landed in Git without being recorded here:
|
|
1335
1404
|
* a crashed/timed-out merge, a manual merge, or a rebase that fast-forwarded
|
|
@@ -1424,7 +1493,7 @@ export class Tower {
|
|
|
1424
1493
|
await git(
|
|
1425
1494
|
this.root,
|
|
1426
1495
|
['merge', '--no-ff', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`],
|
|
1427
|
-
{ timeoutMs, maxBuffer: MERGE_MAX_BUFFER },
|
|
1496
|
+
{ timeoutMs, maxBuffer: runtimeConfig().merge.maxBufferBytes || MERGE_MAX_BUFFER },
|
|
1428
1497
|
)
|
|
1429
1498
|
} catch (error) {
|
|
1430
1499
|
failure = error
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tower",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DSH-native Tower: mission planning, worktree isolation, review-gated merges, and a human-owned Tower mode.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,12 +28,18 @@
|
|
|
28
28
|
"exports": {
|
|
29
29
|
".": "./index.js",
|
|
30
30
|
"./worker": "./worker.js",
|
|
31
|
+
"./reviewer": "./reviewer.js",
|
|
32
|
+
"./skills": "./skills.js",
|
|
31
33
|
"./locale/*.json": "./locale/*.json",
|
|
32
|
-
"./icon": "./icon.svg"
|
|
34
|
+
"./icon": "./icon.svg",
|
|
35
|
+
"./package.json": "./package.json"
|
|
33
36
|
},
|
|
34
37
|
"files": [
|
|
35
38
|
"index.js",
|
|
36
39
|
"worker.js",
|
|
40
|
+
"reviewer.js",
|
|
41
|
+
"skills.js",
|
|
42
|
+
"skills/",
|
|
37
43
|
"lib/*.js",
|
|
38
44
|
"cordis.patch.yml",
|
|
39
45
|
"locale/*.json",
|
|
@@ -41,7 +47,10 @@
|
|
|
41
47
|
"README.md",
|
|
42
48
|
"README.en.md",
|
|
43
49
|
"CHANGELOG.md",
|
|
44
|
-
"LICENSE"
|
|
50
|
+
"LICENSE",
|
|
51
|
+
"CONTRIBUTING.md",
|
|
52
|
+
"SECURITY.md",
|
|
53
|
+
"docs/"
|
|
45
54
|
],
|
|
46
55
|
"dsh": {
|
|
47
56
|
"bundle": {
|
|
@@ -50,6 +59,11 @@
|
|
|
50
59
|
},
|
|
51
60
|
"scripts": {
|
|
52
61
|
"test": "node --test tests/*.test.mjs",
|
|
53
|
-
"test:watch": "node --test --watch tests/*.test.mjs"
|
|
62
|
+
"test:watch": "node --test --watch tests/*.test.mjs",
|
|
63
|
+
"docs:check": "node scripts/check-docs.mjs",
|
|
64
|
+
"check": "npm test && npm run docs:check"
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"@deepseek-ai/schemastery": "~3.18.5"
|
|
54
68
|
}
|
|
55
69
|
}
|
package/reviewer.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// tower-reviewer preset policy: read-only review surface. The reviewer must
|
|
2
|
+
// never modify the mission branch: tower_review binds the verdict to the tip
|
|
3
|
+
// recorded when the reviewer was spawned, so a modified tip is rejected.
|
|
4
|
+
export const name = 'dsh-tower/reviewer'
|
|
5
|
+
export const inject = ['tools', 'systemPrompt']
|
|
6
|
+
|
|
7
|
+
const ALLOW = [
|
|
8
|
+
'read',
|
|
9
|
+
'glob',
|
|
10
|
+
'grep',
|
|
11
|
+
'tower_diff',
|
|
12
|
+
'tower_status',
|
|
13
|
+
'tower_send',
|
|
14
|
+
'tower_inbox',
|
|
15
|
+
'tower_finding',
|
|
16
|
+
'tower_review',
|
|
17
|
+
'skill',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const PROTOCOL = [
|
|
21
|
+
'You are an independent Tower reviewer in a DeepSeek Harness Tower workspace.',
|
|
22
|
+
'You are read-only: do not write, edit, commit, rebase, or merge, and never modify the mission branch.',
|
|
23
|
+
'Inspect the mission diff with tower_diff, check scope, tasks, and the reviewed tip, then submit exactly one',
|
|
24
|
+
'tower_review verdict (clean + merge, or p1-/p2-items + hold/fix-then-merge) with findings and decision.',
|
|
25
|
+
'You must not be the mission worker. Use tower_finding for out-of-scope issues instead of editing files.',
|
|
26
|
+
].join(' ')
|
|
27
|
+
|
|
28
|
+
export function apply(ctx) {
|
|
29
|
+
try {
|
|
30
|
+
ctx.tools.restrict({ allow: ALLOW })
|
|
31
|
+
} catch {
|
|
32
|
+
// a composition without the tool registry still gets the protocol text
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
ctx.systemPrompt.section({ name: 'tower:reviewer-protocol', order: 12000, text: PROTOCOL })
|
|
36
|
+
} catch {
|
|
37
|
+
// prompt assembly remains valid without the section
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Tower recovery playbook
|
|
2
|
+
|
|
3
|
+
Read `tower_preflight` and the last failure before changing anything.
|
|
4
|
+
|
|
5
|
+
## Merge hook failed or timed out (`merge-hook-failed`)
|
|
6
|
+
|
|
7
|
+
The merge commit was not created; the repository was restored. Re-run `tower_merge`. For slow
|
|
8
|
+
`pre-merge-commit` hooks pass a larger `timeoutMs` (default 15 minutes).
|
|
9
|
+
|
|
10
|
+
## Merge commit landed but the call failed (`recoveredFromFailure`)
|
|
11
|
+
|
|
12
|
+
The mission is already recorded as merged. Do not merge again; run `tower_teardown`.
|
|
13
|
+
|
|
14
|
+
## Merge already landed earlier (`reconciled` / `alreadyMerged`)
|
|
15
|
+
|
|
16
|
+
`tower_merge` detects the existing merge commit or a branch contained in the base and records it.
|
|
17
|
+
Only a clean/merge review qualifies; otherwise ask for a fresh review.
|
|
18
|
+
|
|
19
|
+
## Merge blocked with `stale-base`
|
|
20
|
+
|
|
21
|
+
Run `tower_rebase` inside the mission worktree, then `tower_preflight` and `tower_merge`.
|
|
22
|
+
If the branch was already contained in the base, rebase returns `alreadyMerged` and leaves it
|
|
23
|
+
untouched — call `tower_merge` to record it.
|
|
24
|
+
|
|
25
|
+
## `state.lock` reports another writer
|
|
26
|
+
|
|
27
|
+
Wait, then retry. Never delete the lock while a writer may be active; if the process is gone,
|
|
28
|
+
remove `<git-common-dir>/dsh-tower/state.lock` deliberately and retry.
|
|
29
|
+
|
|
30
|
+
## Worktree missing or dirty
|
|
31
|
+
|
|
32
|
+
`tower_status` shows the expected path and branch. Re-add a missing worktree only from Git if the
|
|
33
|
+
branch still exists; do not delete a branch you have not reviewed. Dirty worktrees require an
|
|
34
|
+
explicit teardown `force` decision.
|
|
35
|
+
|
|
36
|
+
## Reset a repository's Tower state
|
|
37
|
+
|
|
38
|
+
Only when no mission is in flight and you accept losing mission history: stop all agents, remove
|
|
39
|
+
`<git-common-dir>/dsh-tower/`, then `tower_init` again. Worktrees and branches must be cleaned
|
|
40
|
+
manually.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Tower review
|
|
2
|
+
|
|
3
|
+
You are the independent reviewer of a completed Tower mission. You are read-only.
|
|
4
|
+
|
|
5
|
+
1. Read the mission briefing and the review binding: the reviewed tip must equal the current branch tip.
|
|
6
|
+
2. Inspect the diff with `tower_diff` (files, commits, patch). Do not run write, edit, pwsh, commit, rebase, or merge.
|
|
7
|
+
3. Verify: every task is met; all changes are inside the mission scope; no unrelated or generated files; no secrets; tests or evidence are adequate.
|
|
8
|
+
4. Check the reviewed commit is the tip you inspected. If it moved, stop and report a blocker instead of approving.
|
|
9
|
+
5. Submit exactly one `tower_review`:
|
|
10
|
+
- `clean` + `merge` + `findings: "none"` when everything passes.
|
|
11
|
+
- `p1-Nitems` or `p2-Nitems` + `hold` or `fix-then-merge` with concrete findings when it does not.
|
|
12
|
+
6. Put out-of-scope observations in `tower_finding`, never in the branch.
|
|
13
|
+
|
|
14
|
+
The verdict is bound to the inspected tip; do not modify the branch.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Tower worker protocol
|
|
2
|
+
|
|
3
|
+
You are a Tower worker: one mission, one worktree, one commit series.
|
|
4
|
+
|
|
5
|
+
1. Read the briefing: mission id, branch, task list, scope globs, dependencies, and context.
|
|
6
|
+
2. Work only inside the assigned worktree. Never modify the main checkout or other missions.
|
|
7
|
+
3. Keep changes inside the scope globs; report anything else as a `tower_finding` instead of editing it.
|
|
8
|
+
4. As you finish each task call `tower_mission` with `task_done` (and `note` when useful).
|
|
9
|
+
5. Run the verification available to you (tests, type check, build). Record it in your final message.
|
|
10
|
+
6. Commit on the mission branch with a clear message. Do not push, spawn agents, review, or merge.
|
|
11
|
+
7. If blocked, call `tower_mission` with `blocker`, then stop and report exactly what is blocking.
|
|
12
|
+
|
|
13
|
+
Final message must contain: mission id, branch, every changed file, how you verified it, and anything left undone.
|
package/skills.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Bundled Tower skills: protocol, review checklist, and recovery playbook.
|
|
2
|
+
// The provider is mounted by the bundle patch so every agent sees the catalog;
|
|
3
|
+
// the short system-prompt sections stay authoritative for safety rules.
|
|
4
|
+
import { readFile } from 'node:fs/promises'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
export const name = 'dsh-tower/skills'
|
|
8
|
+
export const inject = ['skills']
|
|
9
|
+
|
|
10
|
+
const ASSETS = fileURLToPath(new URL('./skills/', import.meta.url))
|
|
11
|
+
|
|
12
|
+
const SKILLS = [
|
|
13
|
+
{
|
|
14
|
+
name: 'tower-worker-protocol',
|
|
15
|
+
description:
|
|
16
|
+
'Follow the Tower worker workflow: work only in the assigned mission worktree, update the mission, commit the branch, and report a complete handoff. Use when spawned as a Tower worker.',
|
|
17
|
+
file: 'tower-worker-protocol.md',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'tower-review',
|
|
21
|
+
description:
|
|
22
|
+
'Run an independent Tower review: inspect the mission diff read-only, verify scope/tasks/reviewed tip, and submit exactly one clean or p1/p2 review verdict. Use when spawned as a Tower reviewer.',
|
|
23
|
+
file: 'tower-review.md',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'tower-recovery',
|
|
27
|
+
description:
|
|
28
|
+
'Recover a Tower mission from a merge hook failure, an already-landed merge, a stale branch, a stuck state lock, or a missing worktree. Use when tower_merge, tower_rebase, or tower_preflight reports a blocker.',
|
|
29
|
+
file: 'tower-recovery.md',
|
|
30
|
+
},
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const provider = {
|
|
34
|
+
name: 'dsh-tower',
|
|
35
|
+
list: async () =>
|
|
36
|
+
SKILLS.map((skill) => ({
|
|
37
|
+
name: skill.name,
|
|
38
|
+
description: skill.description,
|
|
39
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
40
|
+
provider: 'dsh-tower',
|
|
41
|
+
source: 'bundled',
|
|
42
|
+
resourceBase: { kind: 'directory', path: ASSETS },
|
|
43
|
+
rank: 600,
|
|
44
|
+
locator: new URL(skill.file, new URL('./skills/', import.meta.url)),
|
|
45
|
+
})),
|
|
46
|
+
async get(candidate) {
|
|
47
|
+
const skill = SKILLS.find((entry) => entry.name === candidate.name)
|
|
48
|
+
if (!skill) throw new Error(`unknown dsh-tower skill: ${candidate.name}`)
|
|
49
|
+
return {
|
|
50
|
+
name: skill.name,
|
|
51
|
+
description: skill.description,
|
|
52
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
53
|
+
provider: 'dsh-tower',
|
|
54
|
+
source: 'bundled',
|
|
55
|
+
resourceBase: { kind: 'directory', path: ASSETS },
|
|
56
|
+
content: await readFile(new URL(skill.file, new URL('./skills/', import.meta.url)), 'utf8'),
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function apply(ctx) {
|
|
62
|
+
ctx.skills.registerProvider(() => provider)
|
|
63
|
+
}
|
package/worker.js
CHANGED
|
@@ -11,11 +11,12 @@ const ALLOW = [
|
|
|
11
11
|
'pwsh',
|
|
12
12
|
'tower_mission',
|
|
13
13
|
'tower_status',
|
|
14
|
+
'tower_diff',
|
|
14
15
|
'tower_send',
|
|
15
16
|
'tower_inbox',
|
|
16
17
|
'tower_complete',
|
|
17
18
|
'tower_finding',
|
|
18
|
-
'
|
|
19
|
+
'skill',
|
|
19
20
|
]
|
|
20
21
|
|
|
21
22
|
const PROTOCOL = [
|
|
@@ -24,7 +25,7 @@ const PROTOCOL = [
|
|
|
24
25
|
'Commit your work on the mission branch. Update the mission as you go with tower_mission',
|
|
25
26
|
'(task_done, blocker, status), and put the full handoff in your final message: what changed,',
|
|
26
27
|
'every file path, how you verified it, and anything left undone.',
|
|
27
|
-
'Do not spawn other agents and do not merge branches; the tower owns integration.',
|
|
28
|
+
'Do not spawn other agents, do not review, and do not merge branches; the tower owns integration.',
|
|
28
29
|
].join(' ')
|
|
29
30
|
|
|
30
31
|
export function apply(ctx) {
|