dsh-tower 2.0.4 → 2.1.0-beta.1

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.
Files changed (171) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +40 -3
  5. package/README.md +38 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +10 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/batch.js +79 -0
  11. package/app/board.js +25 -0
  12. package/app/checkCompletion.js +76 -0
  13. package/app/enrichedMissions.js +26 -0
  14. package/app/findings.js +78 -0
  15. package/app/gate.js +108 -0
  16. package/app/init.js +249 -0
  17. package/app/merge.js +567 -0
  18. package/app/messages.js +66 -0
  19. package/app/mission.js +35 -0
  20. package/app/missionContext.js +14 -0
  21. package/app/plan.js +42 -0
  22. package/app/provision.js +193 -0
  23. package/app/read.js +57 -0
  24. package/app/rebase.js +302 -0
  25. package/app/recording.js +72 -0
  26. package/app/review.js +145 -0
  27. package/app/roster.js +123 -0
  28. package/app/spawn.js +125 -0
  29. package/app/teardown.js +184 -0
  30. package/app/tower.js +524 -0
  31. package/cordis.patch.yml +61 -53
  32. package/docs/README.md +3 -1
  33. package/docs/architecture/overview.md +111 -12
  34. package/docs/board.md +72 -0
  35. package/docs/guide/hot-reload.md +1 -1
  36. package/docs/guide/install-upgrade-migration.md +8 -1
  37. package/docs/guide/release.md +125 -0
  38. package/docs/operations/security.md +47 -2
  39. package/docs/reference/config.generated.md +66 -0
  40. package/docs/reference/config.md +4 -1
  41. package/docs/reference/errors.generated.md +39 -0
  42. package/docs/reference/errors.md +4 -2
  43. package/docs/reference/tools.generated.md +33 -0
  44. package/docs/reference/tools.md +1 -1
  45. package/docs/zh/README.md +3 -2
  46. package/docs/zh/guide/hot-reload.md +1 -1
  47. package/docs/zh/guide/install-upgrade-migration.md +6 -1
  48. package/docs/zh/operations/security.md +30 -1
  49. package/docs/zh/reference/config.md +4 -1
  50. package/docs/zh/reference/errors.md +3 -1
  51. package/docs/zh/reference/tools.md +1 -1
  52. package/domain/collab/batch.js +112 -0
  53. package/domain/collab/messages.js +127 -0
  54. package/domain/collab/roster.js +158 -0
  55. package/domain/collab/spawn.js +188 -0
  56. package/domain/config/schema.js +404 -0
  57. package/domain/contracts/outputs.js +279 -0
  58. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  59. package/domain/contracts/tool-inputs/findings.js +29 -0
  60. package/domain/contracts/tool-inputs/index.js +94 -0
  61. package/domain/contracts/tool-inputs/integration.js +37 -0
  62. package/domain/contracts/tool-inputs/missions.js +94 -0
  63. package/domain/contracts/tool-inputs/ops.js +75 -0
  64. package/domain/contracts/tool-inputs/shared.js +25 -0
  65. package/domain/errors.js +96 -0
  66. package/domain/gate.js +125 -0
  67. package/domain/integration/gate.js +176 -0
  68. package/domain/integration/init.js +225 -0
  69. package/domain/integration/liveness.js +31 -0
  70. package/domain/integration/merge.js +377 -0
  71. package/domain/integration/provision.js +124 -0
  72. package/domain/integration/rebase.js +236 -0
  73. package/domain/integration/review.js +52 -0
  74. package/domain/integration/teardown.js +118 -0
  75. package/domain/mission.js +68 -0
  76. package/domain/missions/completion.js +114 -0
  77. package/domain/missions/findings.js +201 -0
  78. package/domain/missions/lifecycle.js +185 -0
  79. package/domain/missions/plan.js +149 -0
  80. package/domain/missions/review.js +179 -0
  81. package/domain/planning.js +118 -0
  82. package/domain/read/enrichedMissions.js +58 -0
  83. package/domain/read/missionContext.js +53 -0
  84. package/domain/read/views.js +128 -0
  85. package/domain/report.js +59 -0
  86. package/domain/roster.js +40 -0
  87. package/domain/scope.js +105 -0
  88. package/domain/state/schema.js +233 -0
  89. package/domain/state/semantics.js +117 -0
  90. package/domain/state.js +33 -0
  91. package/domain/summary.js +114 -0
  92. package/domain/vocabulary.js +179 -0
  93. package/index.js +63 -20
  94. package/lib/briefing.js +176 -0
  95. package/lib/compatibility.js +108 -0
  96. package/lib/config.js +33 -375
  97. package/lib/doctor.js +97 -4
  98. package/lib/git.js +60 -16
  99. package/lib/hmr.js +4 -3
  100. package/lib/leases.js +3 -2
  101. package/lib/legacy.js +20 -9
  102. package/lib/modeState.js +39 -0
  103. package/lib/modeStore.js +11 -43
  104. package/lib/persistence.js +349 -0
  105. package/lib/presets.js +35 -0
  106. package/lib/spawn-prechecks.js +168 -0
  107. package/lib/spawn-support.js +79 -0
  108. package/lib/store.js +54 -137
  109. package/lib/tests.js +11 -10
  110. package/lib/tool-registry.js +544 -0
  111. package/lib/workspaceLock.js +2 -2
  112. package/lib/worktreeOwnership.js +5 -4
  113. package/locale/en.json +23 -1
  114. package/locale/zh.json +22 -0
  115. package/package.json +32 -4
  116. package/ports/clock.js +6 -0
  117. package/ports/ids.js +7 -0
  118. package/ports/index.js +2 -0
  119. package/ports/store.js +11 -0
  120. package/ports/vcs.js +11 -0
  121. package/scripts/architecture.mjs +94 -0
  122. package/scripts/check-docs.mjs +9 -3
  123. package/scripts/check-pack.mjs +49 -0
  124. package/scripts/domain-metrics.mjs +48 -0
  125. package/scripts/generate-config-reference.mjs +46 -0
  126. package/scripts/generate-error-reference.mjs +29 -0
  127. package/scripts/generate-tools-reference.mjs +56 -0
  128. package/scripts/issue-audit.mjs +259 -0
  129. package/scripts/lib/cli.mjs +76 -0
  130. package/scripts/lib/tmp.mjs +20 -0
  131. package/scripts/mount-smoke/fixtures.mjs +162 -0
  132. package/scripts/mount-smoke/host.json +4 -0
  133. package/scripts/mount-smoke/probe.mjs +125 -0
  134. package/scripts/mount-smoke/surface.mjs +141 -0
  135. package/scripts/mount-smoke.mjs +270 -0
  136. package/scripts/pack-manifest.txt +203 -0
  137. package/scripts/release-check.mjs +303 -0
  138. package/tools/adopt.js +72 -0
  139. package/tools/batch.js +78 -0
  140. package/tools/complete.js +53 -0
  141. package/tools/diff.js +39 -0
  142. package/tools/doctor.js +50 -0
  143. package/tools/finding.js +40 -0
  144. package/tools/findings.js +39 -0
  145. package/tools/import-legacy.js +42 -0
  146. package/tools/inbox.js +39 -0
  147. package/tools/index.js +39 -0
  148. package/tools/init.js +63 -0
  149. package/tools/interrupt.js +42 -0
  150. package/tools/legacy-map.js +44 -0
  151. package/tools/merge.js +39 -0
  152. package/tools/mission.js +41 -0
  153. package/tools/plan.js +39 -0
  154. package/tools/preflight.js +44 -0
  155. package/tools/rebase.js +39 -0
  156. package/tools/recover.js +45 -0
  157. package/tools/reload.js +52 -0
  158. package/tools/resource.js +54 -0
  159. package/tools/review.js +45 -0
  160. package/tools/roster.js +60 -0
  161. package/tools/send.js +147 -0
  162. package/tools/spawn.js +196 -0
  163. package/tools/status.js +94 -0
  164. package/tools/teardown.js +42 -0
  165. package/tools/test.js +61 -0
  166. package/types/cordis.d.ts +6 -0
  167. package/types/index.d.ts +339 -0
  168. package/lib/errors.js +0 -123
  169. package/lib/tools.js +0 -2460
  170. package/lib/tower.js +0 -2912
  171. /package/{lib → domain}/batch.js +0 -0
package/app/board.js ADDED
@@ -0,0 +1,25 @@
1
+ // Mission board (MISSIONS.md) writing use cases.
2
+ import path from 'node:path'
3
+ import { fsp } from '../adapters/store-fs.js'
4
+ import { renameWithRetry } from '../lib/store.js'
5
+
6
+ export function boardFile(tower) {
7
+ return tower.config.docs?.boardLocation === 'repo'
8
+ ? path.join(tower.root, 'MISSIONS.md')
9
+ : path.join(tower.paths.dir, 'MISSIONS.md')
10
+ }
11
+
12
+ export async function writeBoard(tower) {
13
+ if (tower.config.docs?.board === false) return null
14
+ try {
15
+ const state = await tower.load()
16
+ const file = boardFile(tower)
17
+ await tower.ensureExcluded(file)
18
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
19
+ await fsp.writeFile(tmp, tower.boardMarkdown(state), 'utf8')
20
+ await renameWithRetry(tmp, file)
21
+ return file
22
+ } catch {
23
+ return null
24
+ }
25
+ }
@@ -0,0 +1,76 @@
1
+ // Worker completion preparation and commit use cases.
2
+ import { TowerToolError } from '../domain/errors.js'
3
+ import {
4
+ normalizeChecks,
5
+ assertCompletionRequest,
6
+ assertCompletionInbox,
7
+ completionReport,
8
+ } from '../domain/missions/completion.js'
9
+ import { fullMission } from '../domain/summary.js'
10
+ import { validateState } from '../domain/state.js'
11
+ import { countCommits, isClean } from '../lib/git.js'
12
+ import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
13
+
14
+ export { normalizeChecks }
15
+
16
+ export async function checkCompletion(tower, input) {
17
+ const state = await tower.load()
18
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
19
+ if (!mission) {
20
+ throw new TowerToolError('invalid-argument', `unknown mission "${input.missionId}"`)
21
+ }
22
+ const worker = assertCompletionRequest({ mission, roster: state.roster, input })
23
+ await tower.assertWorktree(mission.worktree)
24
+ const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
25
+ if (commits < 1) {
26
+ throw new TowerToolError(
27
+ 'invalid-argument',
28
+ `mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`,
29
+ )
30
+ }
31
+ if (!(await isClean(mission.worktree.path))) {
32
+ const dirty = (await tower.git(mission.worktree.path, ['status', '--porcelain']))
33
+ .split('\n')
34
+ .filter(Boolean)
35
+ .map((line) => line.slice(3).trim())
36
+ throw new TowerToolError(
37
+ 'worktree-dirty',
38
+ `mission ${mission.id} worktree has uncommitted changes (${dirty.slice(0, 8).join(', ')}${dirty.length > 8 ? ', …' : ''}); commit, remove, or record them before tower_complete`,
39
+ )
40
+ }
41
+ const unread = tower.unreadFor(state, worker.name)
42
+ assertCompletionInbox({ mission, worker, unread })
43
+ return { state, mission, worker, commits }
44
+ }
45
+
46
+ export async function complete(tower, input) {
47
+ // Two-phase pilot (#51): expensive git/state checks run before the lock;
48
+ // the lock only revalidates the state revision and commits the transition.
49
+ const prepared = await tower.checkCompletion(input)
50
+ return withLock(tower.paths, async () => {
51
+ const state = await tower.load()
52
+ if (state.revision !== prepared.state.revision) {
53
+ throw new TowerToolError('stale-base', 'tower state changed while completion was being prepared; retry')
54
+ }
55
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
56
+ if (!mission) throw new TowerToolError('mission-not-found', `unknown mission "${input.missionId}"`)
57
+ const { worker, commits } = prepared
58
+ const checks = normalizeChecks(input.checks)
59
+ const now = Date.now()
60
+ mission.status = 'completed'
61
+ mission.completionReport = completionReport({ report: input.report, worker, checks, now })
62
+ mission.updatedAt = now
63
+ state.revision += 1
64
+ state.updatedAt = now
65
+
66
+ validateState(state, tower.paths.state)
67
+ await writeJsonAtomic(tower.paths.state, state)
68
+ await appendJsonl(tower.paths.activity, {
69
+ at: now,
70
+ type: 'complete',
71
+ missionId: mission.id,
72
+ text: `${worker.name} reported completion (${commits} commit(s))`,
73
+ })
74
+ return { mission: fullMission(mission), commits, unread: 0 }
75
+ })
76
+ }
@@ -0,0 +1,26 @@
1
+ // Dashboard mission enrichment use case.
2
+ import { exists } from '../lib/store.js'
3
+ import { revParse } from '../lib/git.js'
4
+ import {
5
+ enrichedMissionView,
6
+ latestReviewForMission,
7
+ missionsToEnrich,
8
+ } from '../domain/read/enrichedMissions.js'
9
+
10
+ export async function enrichedMissions(tower, state, onlyIds) {
11
+ const out = []
12
+ for (const mission of missionsToEnrich(state.missions, onlyIds)) {
13
+ const latest = latestReviewForMission(state, mission)
14
+ let tipMoved = false
15
+ if (latest && mission.worktree && (await exists(mission.worktree.path))) {
16
+ try {
17
+ const tip = await revParse(mission.worktree.path, 'HEAD')
18
+ tipMoved = tip !== latest.reviewedCommit
19
+ } catch {
20
+ tipMoved = false
21
+ }
22
+ }
23
+ out.push(enrichedMissionView({ mission, latest, tipMoved }))
24
+ }
25
+ return out
26
+ }
@@ -0,0 +1,78 @@
1
+ // Findings use cases.
2
+ import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
3
+ import { validateState } from '../domain/state.js'
4
+ import { TowerToolError } from '../domain/errors.js'
5
+ import {
6
+ normalizeFindingInput,
7
+ newFinding,
8
+ normalizeFindingActor,
9
+ wantsFindingUpdate,
10
+ readFindingsView,
11
+ applyFindingUpdate,
12
+ findingUpdateView,
13
+ } from '../domain/missions/findings.js'
14
+
15
+ function fail(message, code = 'invalid-argument') {
16
+ throw new TowerToolError(code, message)
17
+ }
18
+
19
+ export async function addFinding(tower, input) {
20
+ return withLock(tower.paths, async () => {
21
+ const state = await tower.load()
22
+ if (!Array.isArray(state.findings)) state.findings = []
23
+ const fields = normalizeFindingInput(input, state.missions)
24
+ const now = Date.now()
25
+ const seq = (state.findingSeq ?? 0) + 1
26
+ state.findingSeq = seq
27
+ const finding = newFinding(fields, { seq, now, reportedBy: input.reportedBy })
28
+ state.findings.push(finding)
29
+ state.revision += 1
30
+ state.updatedAt = now
31
+
32
+ validateState(state, tower.paths.state)
33
+ await writeJsonAtomic(tower.paths.state, state)
34
+ await appendJsonl(tower.paths.activity, {
35
+ at: now,
36
+ type: 'finding',
37
+ missionId: finding.missionId,
38
+ text: `${finding.id} ${finding.type}: ${finding.title}`,
39
+ })
40
+ return finding
41
+ })
42
+ }
43
+
44
+ /** List or read findings. Read-only paths never take the writer lock. */
45
+ export async function readFindings(tower, input = {}) {
46
+ const state = await tower.load()
47
+ return readFindingsView(state.findings ?? [], input)
48
+ }
49
+
50
+ /**
51
+ * List, read, or transition findings. Listing and reading are lock-free;
52
+ * only a transition takes the writer lock. Reviewers may only read.
53
+ */
54
+ export async function findings(tower, input = {}) {
55
+ const actor = normalizeFindingActor(input.actor)
56
+ if (!wantsFindingUpdate(input)) return tower.readFindings(input)
57
+
58
+ return withLock(tower.paths, async () => {
59
+ const state = await tower.load()
60
+ const all = state.findings ?? []
61
+ const finding = all.find((entry) => entry.id === input.id)
62
+ if (!finding) fail(`unknown finding "${input.id}"`, 'finding-not-found')
63
+ const now = Date.now()
64
+ const { noteAdded } = applyFindingUpdate({ finding, input, actor, now })
65
+ state.revision += 1
66
+ state.updatedAt = now
67
+ validateState(state, tower.paths.state)
68
+ await writeJsonAtomic(tower.paths.state, state)
69
+ await appendJsonl(tower.paths.activity, {
70
+ at: now,
71
+ type: 'finding',
72
+ missionId: finding.missionId,
73
+ text: `${finding.id} ${finding.status}${noteAdded ? ' +note' : ''} (${actor.name})`,
74
+ })
75
+ // Counts must reflect the transition just recorded.
76
+ return findingUpdateView(finding, all)
77
+ })
78
+ }
package/app/gate.js ADDED
@@ -0,0 +1,108 @@
1
+ // Merge/preflight gate evaluation.
2
+ import { currentBranch } from '../lib/git.js'
3
+ import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
4
+ import { completionBlockers, mergeGateBlockers } from '../domain/gate.js'
5
+ import { fullMission } from '../domain/summary.js'
6
+ import { latestReview, reviewsForBranch } from '../domain/integration/review.js'
7
+ import {
8
+ baseDirtyBlocker,
9
+ baseWrongBranchBlocker,
10
+ dependencyBlockers,
11
+ gateResult,
12
+ mergeIntentPendingBlocker,
13
+ missingMissionGateResult,
14
+ noWorktreeBlocker,
15
+ outOfScopeBlocker,
16
+ overwrittenPaths,
17
+ staleBaseBlocker,
18
+ surveyChangedFilesBlocker,
19
+ } from '../domain/integration/gate.js'
20
+
21
+ export async function evaluateGate(tower, input) {
22
+ const state = await tower.load()
23
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
24
+ if (!mission) return missingMissionGateResult(input.missionId)
25
+ const activity = input.activity ?? {}
26
+ const blockers = []
27
+ const push = (code, detail) => blockers.push({ code, detail })
28
+
29
+ for (const blocker of completionBlockers(mission, tower.config)) push(blocker.code, blocker.detail)
30
+ if (state.mergeIntent && state.mergeIntent.missionId === mission.id) {
31
+ const blocker = mergeIntentPendingBlocker(state.mergeIntent)
32
+ push(blocker.code, blocker.detail)
33
+ }
34
+
35
+ const worktree = mission.worktree ?? null
36
+ let branchTip = null
37
+ let baseTip = null
38
+ let changedFiles = []
39
+ if (!worktree) {
40
+ const blocker = noWorktreeBlocker(mission.id)
41
+ push(blocker.code, blocker.detail)
42
+ } else {
43
+ try {
44
+ await tower.assertWorktree(worktree)
45
+ branchTip = await tower.revParse(worktree.path, 'HEAD')
46
+ baseTip = await tower.revParse(tower.root, state.base)
47
+ const mergeBase = (await tower.git(worktree.path, ['merge-base', baseTip, branchTip])).trim()
48
+ changedFiles = (await tower.git(worktree.path, ['diff', '--name-only', `${mergeBase}..${branchTip}`]))
49
+ .split('\n')
50
+ .filter(Boolean)
51
+ if (!(await tower.isAncestor(tower.root, baseTip, branchTip))) {
52
+ const blocker = staleBaseBlocker({ branch: worktree.branch, base: state.base, baseTip })
53
+ push(blocker.code, blocker.detail)
54
+ }
55
+ if (mission.kind === 'survey') {
56
+ const blocker = surveyChangedFilesBlocker({ missionId: mission.id, changedFiles })
57
+ if (blocker) push(blocker.code, blocker.detail)
58
+ }
59
+ if (mission.kind !== 'survey') {
60
+ const blocker = outOfScopeBlocker({ mission, changedFiles })
61
+ if (blocker) push(blocker.code, blocker.detail)
62
+ }
63
+ const dirty = (await tower.git(tower.root, ['status', '--porcelain']))
64
+ .split('\n')
65
+ .filter(Boolean)
66
+ .map((line) => line.slice(3).trim())
67
+ const blocker = baseDirtyBlocker(overwrittenPaths(changedFiles, dirty))
68
+ if (blocker) push(blocker.code, blocker.detail)
69
+ } catch (error) {
70
+ push('branch-missing', String(error?.message ?? error))
71
+ }
72
+ }
73
+
74
+ for (const blocker of dependencyBlockers({ deps: mission.deps ?? [], state })) {
75
+ push(blocker.code, blocker.detail)
76
+ }
77
+
78
+ const branch = worktree ? worktree.branch : null
79
+ const latest = latestReview(reviewsForBranch(state.reviews, branch))
80
+ for (const blocker of mergeGateBlockers({
81
+ mission,
82
+ roster: state.roster ?? [],
83
+ branch,
84
+ branchTip,
85
+ review: latest,
86
+ config: tower.config,
87
+ activity,
88
+ parentStatus: input.parentStatus,
89
+ inactiveStates: ROSTER_INACTIVE_STATES,
90
+ rebaseWaived: latest ? tower.rebaseWaived(mission, latest) : false,
91
+ })) push(blocker.code, blocker.detail)
92
+
93
+ const checkoutBranch = await currentBranch(tower.root)
94
+ const wrongBranch = baseWrongBranchBlocker({ checkoutBranch, base: state.base })
95
+ if (wrongBranch) push(wrongBranch.code, wrongBranch.detail)
96
+
97
+ return gateResult({
98
+ mission: fullMission(mission),
99
+ worktree,
100
+ branchTip,
101
+ baseTip,
102
+ changedFiles,
103
+ review: latest,
104
+ blockers,
105
+ })
106
+ }
107
+
108
+ /** Read-only mission diff for reviewers: branch changes against the merge base. */
package/app/init.js ADDED
@@ -0,0 +1,249 @@
1
+ // `tower_init` use case, split into three phases (#51):
2
+ //
3
+ // prepare — dirty/committed/base/snapshot decisions, all lock-free (no state
4
+ // lock and no git lock; git reads only).
5
+ // act — the only Git mutations (stash/update-ref/for-each-ref) run under
6
+ // the repo-scoped git mutex, never under the state lock.
7
+ // commit — one short state lock writes/claims/reanchors, revalidating the
8
+ // state revision captured by prepare.
9
+ import { withLock, withGitLock, exists, ensureDir, writeJsonAtomic, appendJsonl } from '../lib/store.js'
10
+ import { validateState } from '../domain/state.js'
11
+ import { TowerToolError } from '../domain/errors.js'
12
+ import {
13
+ SNAPSHOT_REF_PREFIX,
14
+ claimCaller,
15
+ createdInitResult,
16
+ existingInitResult,
17
+ existingInitWriteResult,
18
+ explicitBase,
19
+ fallbackBase,
20
+ initActivityText,
21
+ initialState,
22
+ invalidBaseMessage,
23
+ ownedByOther,
24
+ ownerClaimActivityText,
25
+ reanchorActivityText,
26
+ reanchorBlockedMessage,
27
+ shouldReanchor,
28
+ shouldSnapshot,
29
+ snapshotRefFor,
30
+ } from '../domain/integration/init.js'
31
+
32
+ function fail(message, code = 'invalid-argument') {
33
+ throw new TowerToolError(code, message)
34
+ }
35
+
36
+ /** Phase 1 of `Tower.init` (#51). A read-only init returns its result here. */
37
+ async function prepareInit(tower, input) {
38
+ const dirty = !(await tower.isClean(tower.root))
39
+ const committed = ((await tower.tryGit(tower.root, ['rev-list', '-n', '1', '--all'])) ?? '').trim().length > 0
40
+
41
+ const resolveBase = async () => {
42
+ const explicit = explicitBase(input.base)
43
+ if (explicit) {
44
+ const message = invalidBaseMessage(explicit)
45
+ if (message) fail(message)
46
+ if (committed && (await tower.tryGit(tower.root, ['show-ref', '--verify', '--quiet', `refs/heads/${explicit}`])) === null) {
47
+ fail(`base branch "${explicit}" does not exist locally`)
48
+ }
49
+ return explicit
50
+ }
51
+ const fallback = fallbackBase({ branch: tower.branch, committed })
52
+ if ('error' in fallback) fail(fallback.error)
53
+ return /** @type {{ base: string }} */ (fallback).base
54
+ }
55
+
56
+ if (await exists(tower.paths.state)) {
57
+ const state = await tower.load()
58
+ const owner = state.towerAgentId ? String(state.towerAgentId) : null
59
+ const caller = input.towerAgentId ? String(input.towerAgentId) : null
60
+ if (ownedByOther(owner, caller)) {
61
+ fail(`tower state is owned by another agent; ${caller} is not the tower`, 'not-authorized')
62
+ }
63
+ let reanchorBase = null
64
+ if (input.reanchor === true) {
65
+ const refusal = reanchorBlockedMessage(state.missions)
66
+ if (refusal) fail(refusal, 'invalid-transition')
67
+ reanchorBase = await resolveBase()
68
+ }
69
+ const claim = claimCaller(owner, caller) !== null
70
+ const reanchor = shouldReanchor({ reanchor: input.reanchor, reanchorBase, currentBase: state.base })
71
+ if (!claim && !reanchor) {
72
+ return { early: existingInitResult({ state, statePath: tower.paths.state, dirty }) }
73
+ }
74
+ return { stateRevision: state.revision, claim, reanchor, base: reanchorBase, dirty }
75
+ }
76
+
77
+ return {
78
+ create: true,
79
+ base: await resolveBase(),
80
+ snapshot: shouldSnapshot({ snapshotDirty: input.snapshotDirty, dirty }),
81
+ dirty,
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Phase 2a of `Tower.init` (#51): create the dirty snapshot and its keeping ref
87
+ * under the git mutex. The prepared "no state file yet" decision is
88
+ * revalidated first so a concurrent init cannot leave an unreferenced snapshot.
89
+ */
90
+ async function actInitCreate(tower, prepared) {
91
+ return withGitLock(tower.paths, async () => {
92
+ if (await exists(tower.paths.state)) return { stale: true }
93
+ if (!prepared.snapshot) return { snapshotCommit: null, snapshotRef: null }
94
+ const snapshotCommit = ((await tower.tryGit(tower.root, ['stash', 'create'])) ?? '').trim() || null
95
+ if (!snapshotCommit) {
96
+ fail('snapshotDirty was requested but no snapshot commit could be created; commit or stash the working tree first')
97
+ }
98
+ // A dangling commit can be pruned by git gc; keep a ref so the mission
99
+ // baseline stays reachable for the life of the state.
100
+ const snapshotRef = snapshotRefFor(snapshotCommit)
101
+ await tower.git(tower.root, ['update-ref', snapshotRef, snapshotCommit])
102
+ return { snapshotCommit, snapshotRef }
103
+ })
104
+ }
105
+
106
+ /** Phase 2b of `Tower.init` (#51): snapshot-ref cleanup for a base reanchor. */
107
+ async function actInitReanchor(tower, prepared) {
108
+ return withGitLock(tower.paths, async () => {
109
+ const state = await tower.load()
110
+ if (state.revision !== prepared.stateRevision) return { staleRevision: state.revision }
111
+ if (state.base === prepared.base) return { deleted: [] }
112
+ const refs =
113
+ (await tower.tryGit(tower.root, ['for-each-ref', '--format=%(refname) %(objectname)', SNAPSHOT_REF_PREFIX])) ?? ''
114
+ const deleted = []
115
+ for (const line of refs.split('\n').map((entry) => entry.trim()).filter(Boolean)) {
116
+ const [ref, sha] = line.split(' ')
117
+ if (!ref) continue
118
+ try {
119
+ await tower.git(tower.root, ['update-ref', '-d', ref])
120
+ deleted.push({ ref, sha })
121
+ } catch {
122
+ // best effort: the single-phase code never failed init on a stale snapshot ref
123
+ }
124
+ }
125
+ return { deleted }
126
+ })
127
+ }
128
+
129
+ /** Restore snapshot refs when the reanchor commit is rejected after phase 2. */
130
+ async function restoreInitRefs(tower, deleted) {
131
+ const entries = deleted.filter((entry) => typeof entry.sha === 'string' && entry.sha.length > 0)
132
+ if (entries.length === 0) return
133
+ await withGitLock(tower.paths, async () => {
134
+ for (const entry of entries) {
135
+ await tower.git(tower.root, ['update-ref', entry.ref, entry.sha]).catch(() => {})
136
+ }
137
+ })
138
+ }
139
+
140
+ /** Phase 3a of `Tower.init` (#51): write the initial state under a short lock. */
141
+ async function commitInitCreate(tower, prepared, acted, input) {
142
+ return withLock(tower.paths, async () => {
143
+ // Revision revalidation for the create path: prepare saw "absent", so a
144
+ // state file that appeared meanwhile must never be overwritten.
145
+ if (await exists(tower.paths.state)) {
146
+ throw new TowerToolError('stale-base', 'tower state appeared while init was being prepared; retry')
147
+ }
148
+ const now = Date.now()
149
+ const state = initialState({
150
+ repoRoot: tower.root,
151
+ commonDir: tower.commonDir,
152
+ base: prepared.base,
153
+ dirty: prepared.dirty,
154
+ towerAgentId: input.towerAgentId ? String(input.towerAgentId) : null,
155
+ snapshotCommit: acted.snapshotCommit,
156
+ snapshotRef: acted.snapshotRef,
157
+ now,
158
+ })
159
+ validateState(state, tower.paths.state)
160
+ await writeJsonAtomic(tower.paths.state, state)
161
+ await appendJsonl(tower.paths.activity, {
162
+ at: now,
163
+ type: 'init',
164
+ text: initActivityText({ base: prepared.base, snapshotCommit: acted.snapshotCommit, dirty: prepared.dirty }),
165
+ })
166
+ return createdInitResult({
167
+ repoRoot: tower.root,
168
+ commonDir: tower.commonDir,
169
+ statePath: tower.paths.state,
170
+ base: prepared.base,
171
+ dirty: prepared.dirty,
172
+ snapshotCommit: acted.snapshotCommit,
173
+ snapshotRef: acted.snapshotRef,
174
+ })
175
+ })
176
+ }
177
+
178
+ /** Phase 3b of `Tower.init` (#51): owner claim and reanchor writes under a short lock. */
179
+ async function commitInitExisting(tower, prepared, input) {
180
+ return withLock(tower.paths, async () => {
181
+ const state = await tower.load()
182
+ if (state.revision !== prepared.stateRevision) {
183
+ throw new TowerToolError(
184
+ 'stale-base',
185
+ `tower state changed while init was being prepared (${prepared.stateRevision} -> ${state.revision}); retry`,
186
+ )
187
+ }
188
+ const now = Date.now()
189
+ const owner = state.towerAgentId ? String(state.towerAgentId) : null
190
+ const caller = input.towerAgentId ? String(input.towerAgentId) : null
191
+ if (ownedByOther(owner, caller)) {
192
+ fail(`tower state is owned by another agent; ${caller} is not the tower`, 'not-authorized')
193
+ }
194
+ const claimant = claimCaller(owner, caller)
195
+ if (claimant) {
196
+ state.towerAgentId = claimant
197
+ state.revision += 1
198
+ state.updatedAt = now
199
+ validateState(state, tower.paths.state)
200
+ await writeJsonAtomic(tower.paths.state, state)
201
+ await appendJsonl(tower.paths.activity, {
202
+ at: now,
203
+ type: 'init',
204
+ text: ownerClaimActivityText(claimant),
205
+ })
206
+ }
207
+ let reanchored = false
208
+ if (prepared.reanchor && state.base !== prepared.base) {
209
+ state.base = prepared.base
210
+ state.baseChangedAt = now
211
+ delete state.snapshotCommit
212
+ delete state.snapshotRef
213
+ reanchored = true
214
+ state.revision += 1
215
+ state.updatedAt = now
216
+ validateState(state, tower.paths.state)
217
+ await writeJsonAtomic(tower.paths.state, state)
218
+ await appendJsonl(tower.paths.activity, { at: now, type: 'init', text: reanchorActivityText(prepared.base) })
219
+ }
220
+ return existingInitWriteResult({ state, statePath: tower.paths.state, dirty: prepared.dirty, reanchored })
221
+ })
222
+ }
223
+
224
+ /** #51 two-phase init: prepare/act/commit with stale-state retries. */
225
+ export async function init(tower, input = {}) {
226
+ await ensureDir(tower.paths.dir)
227
+ const prepared = await prepareInit(tower, input)
228
+ if (prepared.early) return prepared.early
229
+ if (prepared.create) {
230
+ const acted = await actInitCreate(tower, prepared)
231
+ if (acted.stale) {
232
+ throw new TowerToolError('stale-base', 'tower state appeared while init was being prepared; retry')
233
+ }
234
+ return commitInitCreate(tower, prepared, acted, input)
235
+ }
236
+ const acted = prepared.reanchor ? await actInitReanchor(tower, prepared) : { deleted: [] }
237
+ if (acted.staleRevision !== undefined) {
238
+ throw new TowerToolError(
239
+ 'stale-base',
240
+ `tower state changed while init was being prepared (${prepared.stateRevision} -> ${acted.staleRevision}); retry`,
241
+ )
242
+ }
243
+ try {
244
+ return await commitInitExisting(tower, prepared, input)
245
+ } catch (error) {
246
+ if (acted.deleted.length > 0) await restoreInitRefs(tower, acted.deleted)
247
+ throw error
248
+ }
249
+ }