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/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
- : path.join(path.dirname(this.root), '.dsh-tower-worktrees', shortHash(this.commonDir))
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
- if (reviewerRounds >= MAX_REVIEW_ROUNDS) {
1153
+ const maxReviewRounds = Number(runtimeConfig().review.maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
1154
+ if (reviewerRounds >= maxReviewRounds) {
1073
1155
  fail(
1074
- `reviewer ${reviewerName} reached the ${MAX_REVIEW_ROUNDS}-round cap for ${branch}; continue with a different reviewer`,
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, state.base)
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(this.root, ['merge', '--no-ff', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`])
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
- await git(this.root, ['merge', '--abort']).catch(() => {})
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
- mission.status = 'blocked'
1305
- mission.blockers.push(`merge conflict: ${String(error?.message ?? error).slice(0, 240)}`)
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 { ok: false, blockers: [{ code: 'merge-conflict', detail: String(error?.message ?? error) }] }
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
- const newTip = await revParse(this.root, 'HEAD')
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 { ok: true, mergedCommit: newTip, baseTip: baseTipBefore, branchTip, parents }
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.1.2",
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
+ }