dsh-tower 1.1.2 → 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 +37 -0
- package/CONTRIBUTING.md +36 -0
- package/README.en.md +48 -23
- package/README.md +44 -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/git.js +7 -1
- package/lib/modeStore.js +19 -15
- package/lib/tools.js +80 -13
- package/lib/tower.js +315 -19
- 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/tower.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Tower domain: repo-scoped mission state machine, validation, and gates.
|
|
2
2
|
import path from 'node:path'
|
|
3
|
+
import fsp from 'node:fs/promises'
|
|
3
4
|
import {
|
|
4
5
|
git,
|
|
5
6
|
resolveRepo,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
countCommits,
|
|
15
16
|
isClean,
|
|
16
17
|
isAncestor,
|
|
18
|
+
tryGit,
|
|
17
19
|
} from './git.js'
|
|
18
20
|
import {
|
|
19
21
|
pathsFor,
|
|
@@ -26,6 +28,7 @@ import {
|
|
|
26
28
|
withLock,
|
|
27
29
|
shortHash,
|
|
28
30
|
} from './store.js'
|
|
31
|
+
import { runtimeConfig } from './config.js'
|
|
29
32
|
|
|
30
33
|
export const SCHEMA_VERSION = 1
|
|
31
34
|
export const MISSION_STATUSES = [
|
|
@@ -41,7 +44,7 @@ const TERMINAL = new Set(['merged', 'abandoned'])
|
|
|
41
44
|
const TRANSITIONS = {
|
|
42
45
|
planned: ['active', 'abandoned'],
|
|
43
46
|
active: ['completed', 'blocked', 'paused', 'abandoned'],
|
|
44
|
-
blocked: ['active', 'paused', 'abandoned'],
|
|
47
|
+
blocked: ['active', 'paused', 'completed', 'abandoned'],
|
|
45
48
|
paused: ['active', 'abandoned'],
|
|
46
49
|
completed: ['active', 'abandoned'],
|
|
47
50
|
merged: [],
|
|
@@ -63,6 +66,49 @@ export const FINDING_SEVERITIES = ['low', 'medium', 'high', 'critical']
|
|
|
63
66
|
export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
|
|
64
67
|
const MAX_REVIEW_ROUNDS = 5
|
|
65
68
|
const REVIEW_STATUS_RE = /^(clean|p1-\d+items|p2-\d+items)$/
|
|
69
|
+
const DEFAULT_MERGE_TIMEOUT_MS = 15 * 60 * 1000
|
|
70
|
+
const MERGE_MAX_BUFFER = 64 * 1024 * 1024
|
|
71
|
+
|
|
72
|
+
function mergeTimeoutMs(value) {
|
|
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
|
+
}
|
|
77
|
+
const ms = Number(value)
|
|
78
|
+
if (!Number.isInteger(ms) || ms < 1000) fail('timeoutMs must be an integer of at least 1000 milliseconds')
|
|
79
|
+
return ms
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function commitParents(cwd, sha) {
|
|
83
|
+
const line = await tryGit(cwd, ['rev-list', '--parents', '-n', '1', sha])
|
|
84
|
+
return line ? line.trim().split(/\s+/).slice(1) : []
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Find a reachable merge commit whose second parent is one of the known tips.
|
|
89
|
+
* `--no-ff` merges put the integrated branch on the second parent, so this is
|
|
90
|
+
* the concrete evidence that a mission's work already landed in the base.
|
|
91
|
+
*/
|
|
92
|
+
async function findLandedMerge(cwd, baseTip, tips) {
|
|
93
|
+
const revs = (await tryGit(cwd, ['rev-list', '--merges', '-n', '100', baseTip])) ?? ''
|
|
94
|
+
for (const sha of revs.split('\n').filter(Boolean)) {
|
|
95
|
+
const parents = await commitParents(cwd, sha)
|
|
96
|
+
if (parents.length >= 2 && tips.has(parents[1])) return { commit: sha, parents }
|
|
97
|
+
}
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function describeGitFailure(error) {
|
|
102
|
+
const prefix = error?.timedOut ? 'timed out: ' : error?.bufferOverflow ? 'output buffer overflow: ' : ''
|
|
103
|
+
return `${prefix}${String(error?.message ?? error)}`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A killed git process can leave lock files behind; only used after a failed merge. */
|
|
107
|
+
async function removeStaleGitLocks(commonDir) {
|
|
108
|
+
for (const name of ['index.lock', 'HEAD.lock', 'MERGE_HEAD.lock', 'ORIG_HEAD.lock']) {
|
|
109
|
+
await fsp.unlink(path.join(commonDir, name)).catch(() => {})
|
|
110
|
+
}
|
|
111
|
+
}
|
|
66
112
|
|
|
67
113
|
function globToRegExp(pattern) {
|
|
68
114
|
let out = '^'
|
|
@@ -361,6 +407,8 @@ export function missionSummary(mission) {
|
|
|
361
407
|
blockers: mission.blockers ?? [],
|
|
362
408
|
tasks: taskCounts(mission.tasks ?? []),
|
|
363
409
|
scopeGeneration: mission.scopeGeneration ?? 1,
|
|
410
|
+
mergedCommit: mission.mergedCommit ?? null,
|
|
411
|
+
mergedAt: mission.mergedAt ?? null,
|
|
364
412
|
updatedAt: mission.updatedAt,
|
|
365
413
|
}
|
|
366
414
|
}
|
|
@@ -631,6 +679,30 @@ export class Tower {
|
|
|
631
679
|
return worktree
|
|
632
680
|
}
|
|
633
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
|
+
|
|
634
706
|
async provision(input = {}) {
|
|
635
707
|
const missionId = input.missionId
|
|
636
708
|
if (typeof missionId !== 'string' || missionId.trim().length === 0) {
|
|
@@ -649,7 +721,9 @@ export class Tower {
|
|
|
649
721
|
const branch = `tower/${mission.id.toLowerCase()}-${slugify(mission.title)}`
|
|
650
722
|
const root = input.worktreeRoot
|
|
651
723
|
? path.resolve(input.worktreeRoot)
|
|
652
|
-
:
|
|
724
|
+
: runtimeConfig().worktrees.root
|
|
725
|
+
? path.resolve(runtimeConfig().worktrees.root)
|
|
726
|
+
: path.join(path.dirname(this.root), '.dsh-tower-worktrees', shortHash(this.commonDir))
|
|
653
727
|
if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
|
|
654
728
|
this.assertOutside(root)
|
|
655
729
|
await ensureDir(root)
|
|
@@ -707,6 +781,7 @@ export class Tower {
|
|
|
707
781
|
fail(`agent ${input.agentId} is already in the roster`)
|
|
708
782
|
}
|
|
709
783
|
const now = Date.now()
|
|
784
|
+
const spawnTip = input.cwd ? ((await tryGit(input.cwd, ['rev-parse', 'HEAD'])) ?? '').trim() || null : null
|
|
710
785
|
const entry = {
|
|
711
786
|
name: input.name,
|
|
712
787
|
role: input.role,
|
|
@@ -716,6 +791,7 @@ export class Tower {
|
|
|
716
791
|
missionId: input.missionId,
|
|
717
792
|
cwd: input.cwd ?? null,
|
|
718
793
|
branch: input.branch ?? null,
|
|
794
|
+
spawnTip,
|
|
719
795
|
state: 'active',
|
|
720
796
|
spawnedAt: now,
|
|
721
797
|
}
|
|
@@ -1067,11 +1143,17 @@ export class Tower {
|
|
|
1067
1143
|
await this.assertWorktree(mission.worktree)
|
|
1068
1144
|
const tip = await revParse(mission.worktree.path, 'HEAD')
|
|
1069
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
|
+
}
|
|
1070
1151
|
const prior = (state.reviews ?? []).filter((review) => review.branch === branch)
|
|
1071
1152
|
const reviewerRounds = prior.filter((review) => review.reviewer === reviewerName).length
|
|
1072
|
-
|
|
1153
|
+
const maxReviewRounds = Number(runtimeConfig().review.maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
|
|
1154
|
+
if (reviewerRounds >= maxReviewRounds) {
|
|
1073
1155
|
fail(
|
|
1074
|
-
`reviewer ${reviewerName} reached the ${
|
|
1156
|
+
`reviewer ${reviewerName} reached the ${maxReviewRounds}-round cap for ${branch}; continue with a different reviewer`,
|
|
1075
1157
|
)
|
|
1076
1158
|
}
|
|
1077
1159
|
const round = prior.length === 0 ? 1 : Math.max(...prior.map((review) => review.round)) + 1
|
|
@@ -1286,35 +1368,228 @@ export class Tower {
|
|
|
1286
1368
|
}
|
|
1287
1369
|
}
|
|
1288
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
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* Record a merge that already landed in Git without being recorded here:
|
|
1404
|
+
* a crashed/timed-out merge, a manual merge, or a rebase that fast-forwarded
|
|
1405
|
+
* the branch to the base tip. The evidence is concrete: a clean merge review
|
|
1406
|
+
* exists and either the branch tip or the reviewed commit is already an
|
|
1407
|
+
* ancestor of the base.
|
|
1408
|
+
*/
|
|
1409
|
+
async reconcileLandedMerge(state, mission) {
|
|
1410
|
+
if (!['completed', 'blocked'].includes(mission.status)) return null
|
|
1411
|
+
if (!mission.worktree) return null
|
|
1412
|
+
const branch = mission.worktree.branch
|
|
1413
|
+
const reviews = (state.reviews ?? []).filter((review) => review.branch === branch)
|
|
1414
|
+
if (reviews.length === 0) return null
|
|
1415
|
+
const latest = reviews.reduce((a, b) => (a.round >= b.round ? a : b))
|
|
1416
|
+
if (latest.status !== 'clean' || latest.mergeVerdict !== 'merge') return null
|
|
1417
|
+
let baseTip
|
|
1418
|
+
let branchTip
|
|
1419
|
+
try {
|
|
1420
|
+
baseTip = await revParse(this.root, state.base)
|
|
1421
|
+
branchTip = await revParse(mission.worktree.path, 'HEAD')
|
|
1422
|
+
} catch {
|
|
1423
|
+
return null
|
|
1424
|
+
}
|
|
1425
|
+
const reviewedTip = latest.reviewedCommit
|
|
1426
|
+
const branchLanded = await isAncestor(this.root, branchTip, baseTip)
|
|
1427
|
+
const reviewedLanded = reviewedTip ? await isAncestor(this.root, reviewedTip, baseTip) : false
|
|
1428
|
+
if (!branchLanded && !reviewedLanded) return null
|
|
1429
|
+
const tips = new Set([branchTip, reviewedTip].filter(Boolean))
|
|
1430
|
+
const found = await findLandedMerge(this.root, baseTip, tips)
|
|
1431
|
+
const mergedCommit = found ? found.commit : reviewedLanded ? reviewedTip : branchTip
|
|
1432
|
+
const now = Date.now()
|
|
1433
|
+
mission.status = 'merged'
|
|
1434
|
+
mission.mergedAt = now
|
|
1435
|
+
mission.mergedCommit = mergedCommit
|
|
1436
|
+
mission.blockers = []
|
|
1437
|
+
mission.notes.push(
|
|
1438
|
+
found
|
|
1439
|
+
? `reconciled already-landed merge ${mergedCommit.slice(0, 7)} with parents [${found.parents.map((parent) => parent.slice(0, 7)).join(', ')}]`
|
|
1440
|
+
: `reconciled already-landed merge: ${mergedCommit.slice(0, 7)} is contained in ${state.base} (no merge commit found)`,
|
|
1441
|
+
)
|
|
1442
|
+
mission.updatedAt = now
|
|
1443
|
+
state.revision += 1
|
|
1444
|
+
state.updatedAt = now
|
|
1445
|
+
validateState(state, this.paths.state)
|
|
1446
|
+
await writeJsonAtomic(this.paths.state, state)
|
|
1447
|
+
await appendJsonl(this.paths.activity, {
|
|
1448
|
+
at: now,
|
|
1449
|
+
type: 'merge',
|
|
1450
|
+
missionId: mission.id,
|
|
1451
|
+
text: `reconciled already-landed ${mergedCommit.slice(0, 7)} on ${state.base}`,
|
|
1452
|
+
})
|
|
1453
|
+
return {
|
|
1454
|
+
ok: true,
|
|
1455
|
+
reconciled: true,
|
|
1456
|
+
alreadyLanded: true,
|
|
1457
|
+
mergedCommit,
|
|
1458
|
+
baseTip,
|
|
1459
|
+
branchTip,
|
|
1460
|
+
parents: found ? found.parents : null,
|
|
1461
|
+
review: {
|
|
1462
|
+
round: latest.round,
|
|
1463
|
+
status: latest.status,
|
|
1464
|
+
mergeVerdict: latest.mergeVerdict,
|
|
1465
|
+
reviewer: latest.reviewer,
|
|
1466
|
+
reviewedCommit: reviewedTip,
|
|
1467
|
+
},
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1289
1471
|
async merge(input) {
|
|
1290
1472
|
return withLock(this.paths, async () => {
|
|
1291
|
-
const gate = await this.evaluateGate(input)
|
|
1292
|
-
if (!gate.ok) return { ok: false, blockers: gate.blockers, missionId: input.missionId }
|
|
1293
1473
|
const state = await this.load()
|
|
1294
1474
|
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
1475
|
+
if (!mission) {
|
|
1476
|
+
return { ok: false, blockers: [{ code: 'mission-missing', detail: `unknown mission "${input.missionId}"` }] }
|
|
1477
|
+
}
|
|
1478
|
+
const baseTip = await revParse(this.root, state.base)
|
|
1479
|
+
if (mission.status === 'merged') {
|
|
1480
|
+
return { ok: true, alreadyMerged: true, mergedCommit: mission.mergedCommit ?? null, baseTip }
|
|
1481
|
+
}
|
|
1482
|
+
const reconciled = await this.reconcileLandedMerge(state, mission)
|
|
1483
|
+
if (reconciled) return reconciled
|
|
1484
|
+
const gate = await this.evaluateGate(input)
|
|
1485
|
+
if (!gate.ok) return { ok: false, blockers: gate.blockers, missionId: input.missionId }
|
|
1486
|
+
|
|
1295
1487
|
const worktree = mission.worktree
|
|
1296
|
-
const baseTipBefore = await revParse(this.root,
|
|
1488
|
+
const baseTipBefore = await revParse(this.root, 'HEAD')
|
|
1297
1489
|
const branchTip = await revParse(worktree.path, 'HEAD')
|
|
1298
|
-
|
|
1490
|
+
const timeoutMs = mergeTimeoutMs(input.timeoutMs)
|
|
1491
|
+
let failure = null
|
|
1299
1492
|
try {
|
|
1300
|
-
await git(
|
|
1493
|
+
await git(
|
|
1494
|
+
this.root,
|
|
1495
|
+
['merge', '--no-ff', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`],
|
|
1496
|
+
{ timeoutMs, maxBuffer: runtimeConfig().merge.maxBufferBytes || MERGE_MAX_BUFFER },
|
|
1497
|
+
)
|
|
1301
1498
|
} catch (error) {
|
|
1302
|
-
|
|
1499
|
+
failure = error
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
const newTip = await revParse(this.root, 'HEAD')
|
|
1503
|
+
const parents = await commitParents(this.root, newTip)
|
|
1504
|
+
const expectedShape = parents.length === 2 && parents[0] === baseTipBefore && parents[1] === branchTip
|
|
1505
|
+
const warnings = []
|
|
1506
|
+
|
|
1507
|
+
if (failure && expectedShape) {
|
|
1508
|
+
warnings.push(
|
|
1509
|
+
`merge command reported a failure (${describeGitFailure(failure)}) but merge commit ${newTip.slice(0, 7)} exists; treated as merged`,
|
|
1510
|
+
)
|
|
1511
|
+
const dirty = (await git(this.root, ['status', '--porcelain']))
|
|
1512
|
+
.split('\n')
|
|
1513
|
+
.filter(Boolean)
|
|
1514
|
+
.map((line) => line.slice(3).trim())
|
|
1515
|
+
if (dirty.length > 0) {
|
|
1516
|
+
const changed = new Set(gate.changedFiles ?? [])
|
|
1517
|
+
if (dirty.every((file) => changed.has(file))) {
|
|
1518
|
+
await git(this.root, ['reset', '--hard', 'HEAD'])
|
|
1519
|
+
warnings.push('restored index/worktree to the merge commit (only merge-related paths were dirty)')
|
|
1520
|
+
} else {
|
|
1521
|
+
warnings.push('working tree still has unrelated changes; left untouched — inspect it before the next Git operation')
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
} else if (failure) {
|
|
1525
|
+
const unmerged = (await tryGit(this.root, ['ls-files', '-u'])) ?? ''
|
|
1526
|
+
const conflict = unmerged.trim().length > 0
|
|
1527
|
+
let cleanup = 'none'
|
|
1528
|
+
const mergeHeadPath = path.join(this.commonDir, 'MERGE_HEAD')
|
|
1529
|
+
if (await exists(mergeHeadPath)) {
|
|
1530
|
+
try {
|
|
1531
|
+
await git(this.root, ['merge', '--abort'])
|
|
1532
|
+
cleanup = 'aborted'
|
|
1533
|
+
} catch {
|
|
1534
|
+
await removeStaleGitLocks(this.commonDir)
|
|
1535
|
+
try {
|
|
1536
|
+
await git(this.root, ['merge', '--abort'])
|
|
1537
|
+
cleanup = 'aborted-after-lock-cleanup'
|
|
1538
|
+
} catch {
|
|
1539
|
+
await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
|
|
1540
|
+
cleanup = 'reset'
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
} else if (newTip !== baseTipBefore) {
|
|
1544
|
+
await removeStaleGitLocks(this.commonDir)
|
|
1545
|
+
await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
|
|
1546
|
+
cleanup = 'reset'
|
|
1547
|
+
}
|
|
1548
|
+
// A killed git process can leave the index staged even without
|
|
1549
|
+
// MERGE_HEAD. Restore only when every dirty path belongs to this merge.
|
|
1550
|
+
const dirtyAfter = (await git(this.root, ['status', '--porcelain']))
|
|
1551
|
+
.split('\n')
|
|
1552
|
+
.filter(Boolean)
|
|
1553
|
+
.map((line) => line.slice(3).trim())
|
|
1554
|
+
if (dirtyAfter.length > 0) {
|
|
1555
|
+
const changed = new Set(gate.changedFiles ?? [])
|
|
1556
|
+
if (dirtyAfter.every((file) => changed.has(file))) {
|
|
1557
|
+
await removeStaleGitLocks(this.commonDir)
|
|
1558
|
+
await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
|
|
1559
|
+
cleanup = cleanup === 'none' ? 'reset' : `${cleanup}+reset`
|
|
1560
|
+
} else {
|
|
1561
|
+
warnings.push(`cleanup left unrelated changes untouched: ${dirtyAfter.slice(0, 5).join(', ')}`)
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
const detail = describeGitFailure(failure).slice(0, 2000)
|
|
1303
1565
|
const now = Date.now()
|
|
1304
|
-
|
|
1305
|
-
|
|
1566
|
+
if (conflict) {
|
|
1567
|
+
mission.status = 'blocked'
|
|
1568
|
+
mission.blockers.push(`merge conflict: ${detail.slice(0, 240)}`)
|
|
1569
|
+
} else {
|
|
1570
|
+
mission.notes.push(`merge command failed before creating a commit (${detail.slice(0, 240)}); mission left ${mission.status} for retry`)
|
|
1571
|
+
}
|
|
1306
1572
|
mission.updatedAt = now
|
|
1307
1573
|
state.revision += 1
|
|
1308
1574
|
state.updatedAt = now
|
|
1309
1575
|
await writeJsonAtomic(this.paths.state, state)
|
|
1310
|
-
await appendJsonl(this.paths.activity, { at: now, type: 'merge', missionId: mission.id, text: 'conflict' })
|
|
1311
|
-
return {
|
|
1576
|
+
await appendJsonl(this.paths.activity, { at: now, type: 'merge', missionId: mission.id, text: `${conflict ? 'conflict' : 'failed-before-commit'} (${cleanup})` })
|
|
1577
|
+
return {
|
|
1578
|
+
ok: false,
|
|
1579
|
+
missionId: mission.id,
|
|
1580
|
+
blockers: [
|
|
1581
|
+
{
|
|
1582
|
+
code: conflict ? 'merge-conflict' : 'merge-hook-failed',
|
|
1583
|
+
detail,
|
|
1584
|
+
...(failure.timedOut === true ? { timeout: true } : {}),
|
|
1585
|
+
cleanup,
|
|
1586
|
+
},
|
|
1587
|
+
],
|
|
1588
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
1589
|
+
}
|
|
1312
1590
|
}
|
|
1313
1591
|
|
|
1314
|
-
|
|
1315
|
-
const parentLine = await git(this.root, ['rev-list', '--parents', '-n', '1', 'HEAD'])
|
|
1316
|
-
const parents = parentLine.trim().split(/\s+/).slice(1)
|
|
1317
|
-
if (parents.length !== 2 || parents[0] !== baseTipBefore || parents[1] !== branchTip) {
|
|
1592
|
+
if (!expectedShape) {
|
|
1318
1593
|
return {
|
|
1319
1594
|
ok: false,
|
|
1320
1595
|
blockers: [
|
|
@@ -1342,7 +1617,14 @@ export class Tower {
|
|
|
1342
1617
|
missionId: mission.id,
|
|
1343
1618
|
text: `${branchTip.slice(0, 7)} -> ${newTip.slice(0, 7)} on ${state.base}`,
|
|
1344
1619
|
})
|
|
1345
|
-
return {
|
|
1620
|
+
return {
|
|
1621
|
+
ok: true,
|
|
1622
|
+
mergedCommit: newTip,
|
|
1623
|
+
baseTip: baseTipBefore,
|
|
1624
|
+
branchTip,
|
|
1625
|
+
parents,
|
|
1626
|
+
...(warnings.length > 0 ? { warnings, recoveredFromFailure: true } : {}),
|
|
1627
|
+
}
|
|
1346
1628
|
})
|
|
1347
1629
|
}
|
|
1348
1630
|
|
|
@@ -1364,6 +1646,20 @@ export class Tower {
|
|
|
1364
1646
|
}
|
|
1365
1647
|
const oldTip = await revParse(mission.worktree.path, 'HEAD')
|
|
1366
1648
|
const baseTip = await revParse(this.root, state.base)
|
|
1649
|
+
if (await isAncestor(this.root, oldTip, baseTip)) {
|
|
1650
|
+
// The branch is already contained in the base (manual merge, crashed
|
|
1651
|
+
// merge, earlier fast-forward). Rewriting the branch tip to the base
|
|
1652
|
+
// would destroy the reviewed tip and the ability to record the merge.
|
|
1653
|
+
return {
|
|
1654
|
+
ok: true,
|
|
1655
|
+
alreadyMerged: true,
|
|
1656
|
+
skipped: true,
|
|
1657
|
+
oldTip,
|
|
1658
|
+
newTip: oldTip,
|
|
1659
|
+
baseTip,
|
|
1660
|
+
note: `${oldTip.slice(0, 7)} is already contained in ${state.base} ${baseTip.slice(0, 7)}; branch left untouched — call tower_merge to record the landed merge`,
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1367
1663
|
if (await isAncestor(this.root, baseTip, oldTip)) {
|
|
1368
1664
|
return { ok: true, alreadyUpToDate: true, oldTip, newTip: oldTip, baseTip }
|
|
1369
1665
|
}
|
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
|
+
}
|