dsh-tower 1.1.2 → 1.2.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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0 — 2026-10-18
4
+
5
+ - **`tower_merge` 对“提交已落地但调用报错”的恢复**:合并命令失败/超时后检查 `HEAD`,若已是
6
+ `[baseTip, branchTip]` 双父合并提交则视为成功并记录;失败时清理 `MERGE_HEAD` 与陈旧锁文件,
7
+ 只回滚与本 mission 相关的路径,避免 index/工作树残留。
8
+ - **区分失败类型**:真实内容冲突返回 `merge-conflict`(保留 blocked 语义);hook/进程失败返回
9
+ `merge-hook-failed`,附带 `timeout: true`(超时)与 `cleanup` 字段,mission 保持 `completed`
10
+ 以便重试。
11
+ - **合并超时可配置**:`tower_merge` 新增 `timeoutMs` 参数(默认 15 分钟),并把合并输出缓冲提升到
12
+ 64 MB,慢速 `pre-merge-commit` 钩子不再被 15 秒默认超时误杀。
13
+ - **自动对账已落地合并**:`tower_merge` 检测 branch/reviewed commit 已包含在 base 中时,找到对应
14
+ merge commit(或落地 tip)并直接记录为 merged(`reconciled: true`),无需手工改 Git 历史。
15
+ - **`tower_rebase`**:branch 已包含于 base 时返回 `alreadyMerged`/`skipped` 且不再把分支
16
+ fast-forward 到 base tip,保留已审查的 tip 供 `tower_merge` 记录。
17
+ - **状态机**:允许 `blocked → completed`;`missionSummary` 暴露 `mergedCommit`/`mergedAt`。
18
+ - 测试:22 项(新增 reconcile、rebase skip、hook 失败、hook 超时四个协议用例)。
19
+ - 修复 GitHub issues #1 / #2。
20
+
3
21
  ## 1.1.2 — 2026-10-18
4
22
 
5
23
  - README 文件更名:默认 `README.md` 改为中文版,英文版移到 `README.en.md`;两边的语言切换链接、
package/README.en.md CHANGED
@@ -149,6 +149,8 @@ The offline suite needs no DSH process and creates throwaway Git repositories in
149
149
  - **`tower_control` is missing.** The config `allowAgentControl` is `false`; set it to `true` and restart.
150
150
  - **Merge is blocked.** Run `tower_preflight` and read the blocker codes above; `stale-base` means run `tower_rebase` first.
151
151
  - **A mission branch is stale after base moved.** `tower_rebase` rebases inside the worktree; a clean conflict-free rebase waives re-review for the previous verdict.
152
+ - **A merge commit exists but `tower_merge` reported a failure.** Re-run `tower_merge`: it detects the landed merge commit (or a branch already contained in the base) and records it as reconciled instead of merging twice.
153
+ - **The repository runs a slow `pre-merge-commit` hook.** The merge timeout defaults to 15 minutes and can be raised per call with `tower_merge({ ..., timeoutMs: 900000 })`; hook output is buffered up to 64 MB. A hook failure returns `merge-hook-failed` (with `timeout: true` when it timed out) and leaves the repository clean for a retry.
152
154
 
153
155
  ## Contributing
154
156
 
package/README.md CHANGED
@@ -149,6 +149,8 @@ npm test # node --test tests/*.test.mjs
149
149
  - **没有 `tower_control`。** 配置 `allowAgentControl` 为 `false`;改为 `true` 并重启。
150
150
  - **合并被阻止。** 运行 `tower_preflight` 查看 blocker code;`stale-base` 表示先执行 `tower_rebase`。
151
151
  - **基线前进后分支过期。** `tower_rebase` 在 worktree 内完成 rebase;干净且无冲突的 rebase 可免去上一轮审查的重审。
152
+ - **合并提交已经存在,但 `tower_merge` 报了失败。** 重新执行 `tower_merge` 即可:它会检测已落地的合并提交(或分支已包含在 base 中)并记录为 reconciled,不会重复合并。
153
+ - **仓库的 `pre-merge-commit` 钩子很慢。** 合并超时默认 15 分钟,可按次通过 `tower_merge({ ..., timeoutMs: 900000 })` 调大;钩子输出缓冲上限 64 MB。钩子失败返回 `merge-hook-failed`(超时带 `timeout: true`),并保持仓库干净以便重试。
152
154
 
153
155
  ## 贡献
154
156
 
package/lib/git.js CHANGED
@@ -6,10 +6,13 @@ const GIT_TIMEOUT_MS = 15000
6
6
  const MAX_BUFFER = 16 * 1024 * 1024
7
7
 
8
8
  export class GitError extends Error {
9
- constructor(args, detail, code) {
9
+ constructor(args, detail, code, meta = {}) {
10
10
  super(`git ${args.join(' ')} failed: ${detail}`)
11
11
  this.name = 'GitError'
12
12
  this.code = code
13
+ this.timedOut = meta.timedOut === true
14
+ this.bufferOverflow = meta.bufferOverflow === true
15
+ this.stdout = meta.stdout ?? ''
13
16
  }
14
17
  }
15
18
 
@@ -27,11 +30,14 @@ export function git(cwd, args, options = {}) {
27
30
  },
28
31
  (error, stdout, stderr) => {
29
32
  if (error) {
33
+ const timedOut = error.killed === true || error.signal === 'SIGTERM' || error.code === 'ETIMEDOUT'
34
+ const bufferOverflow = error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
30
35
  reject(
31
36
  new GitError(
32
37
  args,
33
38
  String(stderr || error.message || '').trim(),
34
39
  typeof error.code === 'number' ? error.code : undefined,
40
+ { timedOut, bufferOverflow, stdout: String(stdout ?? '').slice(0, 64 * 1024) },
35
41
  ),
36
42
  )
37
43
  return
package/lib/tools.js CHANGED
@@ -732,12 +732,17 @@ export function registerTowerTools(ctx) {
732
732
  define(ctx, {
733
733
  name: 'tower_merge',
734
734
  description:
735
- 'Merge a mission branch into the recorded base branch with --no-ff, after the hard gate passes. Verifies the two merge parents and records the mission as merged. A failed gate returns stable blocker codes instead of merging; a Git conflict aborts the merge and marks the mission blocked.',
735
+ 'Merge a mission branch into the recorded base branch with --no-ff, after the hard gate passes. Verifies the two merge parents and records the mission as merged. If the merge commit already landed (manual merge, crashed or slow pre-merge hook), it detects the merge commit and records it as reconciled instead of merging again. A failed gate returns stable blocker codes without merging; a hook failure aborts cleanly and returns "merge-hook-failed" while a real content conflict returns "merge-conflict".',
736
736
  parameters: {
737
737
  type: 'object',
738
738
  properties: {
739
739
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
740
740
  missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
741
+ timeoutMs: {
742
+ type: 'integer',
743
+ minimum: 1000,
744
+ description: 'Optional timeout for the merge command in milliseconds, including pre-merge hooks (default 15 minutes).',
745
+ },
741
746
  },
742
747
  required: ['repoRoot', 'missionId'],
743
748
  additionalProperties: false,
@@ -746,14 +751,14 @@ export function registerTowerTools(ctx) {
746
751
  async execute(args) {
747
752
  const tower = await Tower.open(args.repoRoot)
748
753
  const { activity } = await observeActivity(ctx, tower)
749
- return tower.merge({ missionId: args.missionId, activity })
754
+ return tower.merge({ missionId: args.missionId, activity, timeoutMs: args.timeoutMs })
750
755
  },
751
756
  })
752
757
 
753
758
  define(ctx, {
754
759
  name: 'tower_rebase',
755
760
  description:
756
- 'Rebase a mission branch onto the current base tip inside its own worktree. Refuses while its worker is running or the worktree is dirty. A clean conflict-free rebase waives re-review for the old review; a conflict is aborted and the mission is marked blocked.',
761
+ 'Rebase a mission branch onto the current base tip inside its own worktree. Refuses while its worker is running or the worktree is dirty. A clean conflict-free rebase waives re-review for the old review; a conflict is aborted and the mission is marked blocked. If the branch is already contained in the base, it returns alreadyMerged and leaves the branch untouched so tower_merge can record the landed merge.',
757
762
  parameters: {
758
763
  type: 'object',
759
764
  properties: {
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,
@@ -41,7 +43,7 @@ const TERMINAL = new Set(['merged', 'abandoned'])
41
43
  const TRANSITIONS = {
42
44
  planned: ['active', 'abandoned'],
43
45
  active: ['completed', 'blocked', 'paused', 'abandoned'],
44
- blocked: ['active', 'paused', 'abandoned'],
46
+ blocked: ['active', 'paused', 'completed', 'abandoned'],
45
47
  paused: ['active', 'abandoned'],
46
48
  completed: ['active', 'abandoned'],
47
49
  merged: [],
@@ -63,6 +65,46 @@ export const FINDING_SEVERITIES = ['low', 'medium', 'high', 'critical']
63
65
  export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
64
66
  const MAX_REVIEW_ROUNDS = 5
65
67
  const REVIEW_STATUS_RE = /^(clean|p1-\d+items|p2-\d+items)$/
68
+ const DEFAULT_MERGE_TIMEOUT_MS = 15 * 60 * 1000
69
+ const MERGE_MAX_BUFFER = 64 * 1024 * 1024
70
+
71
+ function mergeTimeoutMs(value) {
72
+ if (value === undefined || value === null) return DEFAULT_MERGE_TIMEOUT_MS
73
+ const ms = Number(value)
74
+ if (!Number.isInteger(ms) || ms < 1000) fail('timeoutMs must be an integer of at least 1000 milliseconds')
75
+ return ms
76
+ }
77
+
78
+ async function commitParents(cwd, sha) {
79
+ const line = await tryGit(cwd, ['rev-list', '--parents', '-n', '1', sha])
80
+ return line ? line.trim().split(/\s+/).slice(1) : []
81
+ }
82
+
83
+ /**
84
+ * Find a reachable merge commit whose second parent is one of the known tips.
85
+ * `--no-ff` merges put the integrated branch on the second parent, so this is
86
+ * the concrete evidence that a mission's work already landed in the base.
87
+ */
88
+ async function findLandedMerge(cwd, baseTip, tips) {
89
+ const revs = (await tryGit(cwd, ['rev-list', '--merges', '-n', '100', baseTip])) ?? ''
90
+ for (const sha of revs.split('\n').filter(Boolean)) {
91
+ const parents = await commitParents(cwd, sha)
92
+ if (parents.length >= 2 && tips.has(parents[1])) return { commit: sha, parents }
93
+ }
94
+ return null
95
+ }
96
+
97
+ function describeGitFailure(error) {
98
+ const prefix = error?.timedOut ? 'timed out: ' : error?.bufferOverflow ? 'output buffer overflow: ' : ''
99
+ return `${prefix}${String(error?.message ?? error)}`
100
+ }
101
+
102
+ /** A killed git process can leave lock files behind; only used after a failed merge. */
103
+ async function removeStaleGitLocks(commonDir) {
104
+ for (const name of ['index.lock', 'HEAD.lock', 'MERGE_HEAD.lock', 'ORIG_HEAD.lock']) {
105
+ await fsp.unlink(path.join(commonDir, name)).catch(() => {})
106
+ }
107
+ }
66
108
 
67
109
  function globToRegExp(pattern) {
68
110
  let out = '^'
@@ -361,6 +403,8 @@ export function missionSummary(mission) {
361
403
  blockers: mission.blockers ?? [],
362
404
  tasks: taskCounts(mission.tasks ?? []),
363
405
  scopeGeneration: mission.scopeGeneration ?? 1,
406
+ mergedCommit: mission.mergedCommit ?? null,
407
+ mergedAt: mission.mergedAt ?? null,
364
408
  updatedAt: mission.updatedAt,
365
409
  }
366
410
  }
@@ -1286,35 +1330,197 @@ export class Tower {
1286
1330
  }
1287
1331
  }
1288
1332
 
1333
+ /**
1334
+ * Record a merge that already landed in Git without being recorded here:
1335
+ * a crashed/timed-out merge, a manual merge, or a rebase that fast-forwarded
1336
+ * the branch to the base tip. The evidence is concrete: a clean merge review
1337
+ * exists and either the branch tip or the reviewed commit is already an
1338
+ * ancestor of the base.
1339
+ */
1340
+ async reconcileLandedMerge(state, mission) {
1341
+ if (!['completed', 'blocked'].includes(mission.status)) return null
1342
+ if (!mission.worktree) return null
1343
+ const branch = mission.worktree.branch
1344
+ const reviews = (state.reviews ?? []).filter((review) => review.branch === branch)
1345
+ if (reviews.length === 0) return null
1346
+ const latest = reviews.reduce((a, b) => (a.round >= b.round ? a : b))
1347
+ if (latest.status !== 'clean' || latest.mergeVerdict !== 'merge') return null
1348
+ let baseTip
1349
+ let branchTip
1350
+ try {
1351
+ baseTip = await revParse(this.root, state.base)
1352
+ branchTip = await revParse(mission.worktree.path, 'HEAD')
1353
+ } catch {
1354
+ return null
1355
+ }
1356
+ const reviewedTip = latest.reviewedCommit
1357
+ const branchLanded = await isAncestor(this.root, branchTip, baseTip)
1358
+ const reviewedLanded = reviewedTip ? await isAncestor(this.root, reviewedTip, baseTip) : false
1359
+ if (!branchLanded && !reviewedLanded) return null
1360
+ const tips = new Set([branchTip, reviewedTip].filter(Boolean))
1361
+ const found = await findLandedMerge(this.root, baseTip, tips)
1362
+ const mergedCommit = found ? found.commit : reviewedLanded ? reviewedTip : branchTip
1363
+ const now = Date.now()
1364
+ mission.status = 'merged'
1365
+ mission.mergedAt = now
1366
+ mission.mergedCommit = mergedCommit
1367
+ mission.blockers = []
1368
+ mission.notes.push(
1369
+ found
1370
+ ? `reconciled already-landed merge ${mergedCommit.slice(0, 7)} with parents [${found.parents.map((parent) => parent.slice(0, 7)).join(', ')}]`
1371
+ : `reconciled already-landed merge: ${mergedCommit.slice(0, 7)} is contained in ${state.base} (no merge commit found)`,
1372
+ )
1373
+ mission.updatedAt = now
1374
+ state.revision += 1
1375
+ state.updatedAt = now
1376
+ validateState(state, this.paths.state)
1377
+ await writeJsonAtomic(this.paths.state, state)
1378
+ await appendJsonl(this.paths.activity, {
1379
+ at: now,
1380
+ type: 'merge',
1381
+ missionId: mission.id,
1382
+ text: `reconciled already-landed ${mergedCommit.slice(0, 7)} on ${state.base}`,
1383
+ })
1384
+ return {
1385
+ ok: true,
1386
+ reconciled: true,
1387
+ alreadyLanded: true,
1388
+ mergedCommit,
1389
+ baseTip,
1390
+ branchTip,
1391
+ parents: found ? found.parents : null,
1392
+ review: {
1393
+ round: latest.round,
1394
+ status: latest.status,
1395
+ mergeVerdict: latest.mergeVerdict,
1396
+ reviewer: latest.reviewer,
1397
+ reviewedCommit: reviewedTip,
1398
+ },
1399
+ }
1400
+ }
1401
+
1289
1402
  async merge(input) {
1290
1403
  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
1404
  const state = await this.load()
1294
1405
  const mission = state.missions.find((entry) => entry.id === input.missionId)
1406
+ if (!mission) {
1407
+ return { ok: false, blockers: [{ code: 'mission-missing', detail: `unknown mission "${input.missionId}"` }] }
1408
+ }
1409
+ const baseTip = await revParse(this.root, state.base)
1410
+ if (mission.status === 'merged') {
1411
+ return { ok: true, alreadyMerged: true, mergedCommit: mission.mergedCommit ?? null, baseTip }
1412
+ }
1413
+ const reconciled = await this.reconcileLandedMerge(state, mission)
1414
+ if (reconciled) return reconciled
1415
+ const gate = await this.evaluateGate(input)
1416
+ if (!gate.ok) return { ok: false, blockers: gate.blockers, missionId: input.missionId }
1417
+
1295
1418
  const worktree = mission.worktree
1296
- const baseTipBefore = await revParse(this.root, state.base)
1419
+ const baseTipBefore = await revParse(this.root, 'HEAD')
1297
1420
  const branchTip = await revParse(worktree.path, 'HEAD')
1298
-
1421
+ const timeoutMs = mergeTimeoutMs(input.timeoutMs)
1422
+ let failure = null
1299
1423
  try {
1300
- await git(this.root, ['merge', '--no-ff', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`])
1424
+ await git(
1425
+ this.root,
1426
+ ['merge', '--no-ff', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`],
1427
+ { timeoutMs, maxBuffer: MERGE_MAX_BUFFER },
1428
+ )
1301
1429
  } catch (error) {
1302
- await git(this.root, ['merge', '--abort']).catch(() => {})
1430
+ failure = error
1431
+ }
1432
+
1433
+ const newTip = await revParse(this.root, 'HEAD')
1434
+ const parents = await commitParents(this.root, newTip)
1435
+ const expectedShape = parents.length === 2 && parents[0] === baseTipBefore && parents[1] === branchTip
1436
+ const warnings = []
1437
+
1438
+ if (failure && expectedShape) {
1439
+ warnings.push(
1440
+ `merge command reported a failure (${describeGitFailure(failure)}) but merge commit ${newTip.slice(0, 7)} exists; treated as merged`,
1441
+ )
1442
+ const dirty = (await git(this.root, ['status', '--porcelain']))
1443
+ .split('\n')
1444
+ .filter(Boolean)
1445
+ .map((line) => line.slice(3).trim())
1446
+ if (dirty.length > 0) {
1447
+ const changed = new Set(gate.changedFiles ?? [])
1448
+ if (dirty.every((file) => changed.has(file))) {
1449
+ await git(this.root, ['reset', '--hard', 'HEAD'])
1450
+ warnings.push('restored index/worktree to the merge commit (only merge-related paths were dirty)')
1451
+ } else {
1452
+ warnings.push('working tree still has unrelated changes; left untouched — inspect it before the next Git operation')
1453
+ }
1454
+ }
1455
+ } else if (failure) {
1456
+ const unmerged = (await tryGit(this.root, ['ls-files', '-u'])) ?? ''
1457
+ const conflict = unmerged.trim().length > 0
1458
+ let cleanup = 'none'
1459
+ const mergeHeadPath = path.join(this.commonDir, 'MERGE_HEAD')
1460
+ if (await exists(mergeHeadPath)) {
1461
+ try {
1462
+ await git(this.root, ['merge', '--abort'])
1463
+ cleanup = 'aborted'
1464
+ } catch {
1465
+ await removeStaleGitLocks(this.commonDir)
1466
+ try {
1467
+ await git(this.root, ['merge', '--abort'])
1468
+ cleanup = 'aborted-after-lock-cleanup'
1469
+ } catch {
1470
+ await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
1471
+ cleanup = 'reset'
1472
+ }
1473
+ }
1474
+ } else if (newTip !== baseTipBefore) {
1475
+ await removeStaleGitLocks(this.commonDir)
1476
+ await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
1477
+ cleanup = 'reset'
1478
+ }
1479
+ // A killed git process can leave the index staged even without
1480
+ // MERGE_HEAD. Restore only when every dirty path belongs to this merge.
1481
+ const dirtyAfter = (await git(this.root, ['status', '--porcelain']))
1482
+ .split('\n')
1483
+ .filter(Boolean)
1484
+ .map((line) => line.slice(3).trim())
1485
+ if (dirtyAfter.length > 0) {
1486
+ const changed = new Set(gate.changedFiles ?? [])
1487
+ if (dirtyAfter.every((file) => changed.has(file))) {
1488
+ await removeStaleGitLocks(this.commonDir)
1489
+ await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
1490
+ cleanup = cleanup === 'none' ? 'reset' : `${cleanup}+reset`
1491
+ } else {
1492
+ warnings.push(`cleanup left unrelated changes untouched: ${dirtyAfter.slice(0, 5).join(', ')}`)
1493
+ }
1494
+ }
1495
+ const detail = describeGitFailure(failure).slice(0, 2000)
1303
1496
  const now = Date.now()
1304
- mission.status = 'blocked'
1305
- mission.blockers.push(`merge conflict: ${String(error?.message ?? error).slice(0, 240)}`)
1497
+ if (conflict) {
1498
+ mission.status = 'blocked'
1499
+ mission.blockers.push(`merge conflict: ${detail.slice(0, 240)}`)
1500
+ } else {
1501
+ mission.notes.push(`merge command failed before creating a commit (${detail.slice(0, 240)}); mission left ${mission.status} for retry`)
1502
+ }
1306
1503
  mission.updatedAt = now
1307
1504
  state.revision += 1
1308
1505
  state.updatedAt = now
1309
1506
  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) }] }
1507
+ await appendJsonl(this.paths.activity, { at: now, type: 'merge', missionId: mission.id, text: `${conflict ? 'conflict' : 'failed-before-commit'} (${cleanup})` })
1508
+ return {
1509
+ ok: false,
1510
+ missionId: mission.id,
1511
+ blockers: [
1512
+ {
1513
+ code: conflict ? 'merge-conflict' : 'merge-hook-failed',
1514
+ detail,
1515
+ ...(failure.timedOut === true ? { timeout: true } : {}),
1516
+ cleanup,
1517
+ },
1518
+ ],
1519
+ ...(warnings.length > 0 ? { warnings } : {}),
1520
+ }
1312
1521
  }
1313
1522
 
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) {
1523
+ if (!expectedShape) {
1318
1524
  return {
1319
1525
  ok: false,
1320
1526
  blockers: [
@@ -1342,7 +1548,14 @@ export class Tower {
1342
1548
  missionId: mission.id,
1343
1549
  text: `${branchTip.slice(0, 7)} -> ${newTip.slice(0, 7)} on ${state.base}`,
1344
1550
  })
1345
- return { ok: true, mergedCommit: newTip, baseTip: baseTipBefore, branchTip, parents }
1551
+ return {
1552
+ ok: true,
1553
+ mergedCommit: newTip,
1554
+ baseTip: baseTipBefore,
1555
+ branchTip,
1556
+ parents,
1557
+ ...(warnings.length > 0 ? { warnings, recoveredFromFailure: true } : {}),
1558
+ }
1346
1559
  })
1347
1560
  }
1348
1561
 
@@ -1364,6 +1577,20 @@ export class Tower {
1364
1577
  }
1365
1578
  const oldTip = await revParse(mission.worktree.path, 'HEAD')
1366
1579
  const baseTip = await revParse(this.root, state.base)
1580
+ if (await isAncestor(this.root, oldTip, baseTip)) {
1581
+ // The branch is already contained in the base (manual merge, crashed
1582
+ // merge, earlier fast-forward). Rewriting the branch tip to the base
1583
+ // would destroy the reviewed tip and the ability to record the merge.
1584
+ return {
1585
+ ok: true,
1586
+ alreadyMerged: true,
1587
+ skipped: true,
1588
+ oldTip,
1589
+ newTip: oldTip,
1590
+ baseTip,
1591
+ 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`,
1592
+ }
1593
+ }
1367
1594
  if (await isAncestor(this.root, baseTip, oldTip)) {
1368
1595
  return { ok: true, alreadyUpToDate: true, oldTip, newTip: oldTip, baseTip }
1369
1596
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tower",
3
- "version": "1.1.2",
3
+ "version": "1.2.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",