dsh-tower 2.0.3 → 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 (193) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +59 -16
  5. package/README.md +57 -16
  6. package/SECURITY.md +31 -1
  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 +33 -1
  33. package/docs/architecture/overview.md +114 -14
  34. package/docs/board.md +72 -0
  35. package/docs/examples/first-mission.md +2 -1
  36. package/docs/faq.md +11 -7
  37. package/docs/guide/hot-reload.md +1 -1
  38. package/docs/guide/install-upgrade-migration.md +22 -6
  39. package/docs/guide/quickstart.md +12 -7
  40. package/docs/guide/release.md +125 -0
  41. package/docs/guide/scenarios-and-philosophy.md +86 -69
  42. package/docs/operations/security.md +65 -14
  43. package/docs/reference/blockers.md +11 -10
  44. package/docs/reference/config.generated.md +66 -0
  45. package/docs/reference/config.md +11 -7
  46. package/docs/reference/errors.generated.md +39 -0
  47. package/docs/reference/errors.md +10 -2
  48. package/docs/reference/events.md +7 -4
  49. package/docs/reference/testing.md +2 -1
  50. package/docs/reference/tools.generated.md +33 -0
  51. package/docs/reference/tools.md +8 -7
  52. package/docs/zh/README.md +4 -3
  53. package/docs/zh/faq.md +9 -6
  54. package/docs/zh/guide/hot-reload.md +1 -1
  55. package/docs/zh/guide/install-upgrade-migration.md +17 -5
  56. package/docs/zh/guide/quickstart.md +13 -8
  57. package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
  58. package/docs/zh/operations/security.md +43 -7
  59. package/docs/zh/reference/blockers.md +10 -10
  60. package/docs/zh/reference/config.md +11 -7
  61. package/docs/zh/reference/errors.md +4 -1
  62. package/docs/zh/reference/events.md +6 -3
  63. package/docs/zh/reference/testing.md +3 -2
  64. package/docs/zh/reference/tools.md +8 -7
  65. package/domain/collab/batch.js +112 -0
  66. package/domain/collab/messages.js +127 -0
  67. package/domain/collab/roster.js +158 -0
  68. package/domain/collab/spawn.js +188 -0
  69. package/domain/config/schema.js +404 -0
  70. package/domain/contracts/outputs.js +279 -0
  71. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  72. package/domain/contracts/tool-inputs/findings.js +29 -0
  73. package/domain/contracts/tool-inputs/index.js +94 -0
  74. package/domain/contracts/tool-inputs/integration.js +37 -0
  75. package/domain/contracts/tool-inputs/missions.js +94 -0
  76. package/domain/contracts/tool-inputs/ops.js +75 -0
  77. package/domain/contracts/tool-inputs/shared.js +25 -0
  78. package/domain/errors.js +96 -0
  79. package/domain/gate.js +125 -0
  80. package/domain/integration/gate.js +176 -0
  81. package/domain/integration/init.js +225 -0
  82. package/domain/integration/liveness.js +31 -0
  83. package/domain/integration/merge.js +377 -0
  84. package/domain/integration/provision.js +124 -0
  85. package/domain/integration/rebase.js +236 -0
  86. package/domain/integration/review.js +52 -0
  87. package/domain/integration/teardown.js +118 -0
  88. package/domain/mission.js +68 -0
  89. package/domain/missions/completion.js +114 -0
  90. package/domain/missions/findings.js +201 -0
  91. package/domain/missions/lifecycle.js +185 -0
  92. package/domain/missions/plan.js +149 -0
  93. package/domain/missions/review.js +179 -0
  94. package/domain/planning.js +118 -0
  95. package/domain/read/enrichedMissions.js +58 -0
  96. package/domain/read/missionContext.js +53 -0
  97. package/domain/read/views.js +128 -0
  98. package/domain/report.js +59 -0
  99. package/domain/roster.js +40 -0
  100. package/domain/scope.js +105 -0
  101. package/domain/state/schema.js +233 -0
  102. package/domain/state/semantics.js +117 -0
  103. package/domain/state.js +33 -0
  104. package/domain/summary.js +114 -0
  105. package/domain/vocabulary.js +179 -0
  106. package/index.js +86 -34
  107. package/lib/briefing.js +176 -0
  108. package/lib/compatibility.js +108 -0
  109. package/lib/config.js +54 -302
  110. package/lib/doctor.js +108 -9
  111. package/lib/git.js +93 -15
  112. package/lib/hmr.js +4 -3
  113. package/lib/leases.js +3 -2
  114. package/lib/legacy.js +20 -9
  115. package/lib/modeState.js +39 -0
  116. package/lib/modeStore.js +35 -33
  117. package/lib/persistence.js +349 -0
  118. package/lib/presets.js +35 -0
  119. package/lib/spawn-prechecks.js +168 -0
  120. package/lib/spawn-support.js +79 -0
  121. package/lib/store.js +57 -85
  122. package/lib/tests.js +46 -11
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +46 -22
  125. package/lib/worktreeOwnership.js +5 -4
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +33 -4
  129. package/ports/clock.js +6 -0
  130. package/ports/ids.js +7 -0
  131. package/ports/index.js +2 -0
  132. package/ports/store.js +11 -0
  133. package/ports/vcs.js +11 -0
  134. package/scripts/architecture.mjs +94 -0
  135. package/scripts/check-docs.mjs +44 -3
  136. package/scripts/check-pack.mjs +49 -0
  137. package/scripts/domain-metrics.mjs +48 -0
  138. package/scripts/generate-config-reference.mjs +46 -0
  139. package/scripts/generate-error-reference.mjs +29 -0
  140. package/scripts/generate-tools-reference.mjs +56 -0
  141. package/scripts/issue-audit.mjs +259 -0
  142. package/scripts/lib/cli.mjs +76 -0
  143. package/scripts/lib/tmp.mjs +20 -0
  144. package/scripts/mount-smoke/fixtures.mjs +162 -0
  145. package/scripts/mount-smoke/host.json +4 -0
  146. package/scripts/mount-smoke/probe.mjs +125 -0
  147. package/scripts/mount-smoke/surface.mjs +141 -0
  148. package/scripts/mount-smoke.mjs +270 -0
  149. package/scripts/pack-manifest.txt +203 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/skills/tower-recovery.md +7 -5
  152. package/tools/adopt.js +72 -0
  153. package/tools/batch.js +78 -0
  154. package/tools/complete.js +53 -0
  155. package/tools/diff.js +39 -0
  156. package/tools/doctor.js +50 -0
  157. package/tools/finding.js +40 -0
  158. package/tools/findings.js +39 -0
  159. package/tools/import-legacy.js +42 -0
  160. package/tools/inbox.js +39 -0
  161. package/tools/index.js +39 -0
  162. package/tools/init.js +63 -0
  163. package/tools/interrupt.js +42 -0
  164. package/tools/legacy-map.js +44 -0
  165. package/tools/merge.js +39 -0
  166. package/tools/mission.js +41 -0
  167. package/tools/plan.js +39 -0
  168. package/tools/preflight.js +44 -0
  169. package/tools/rebase.js +39 -0
  170. package/tools/recover.js +45 -0
  171. package/tools/reload.js +52 -0
  172. package/tools/resource.js +54 -0
  173. package/tools/review.js +45 -0
  174. package/tools/roster.js +60 -0
  175. package/tools/send.js +147 -0
  176. package/tools/spawn.js +196 -0
  177. package/tools/status.js +94 -0
  178. package/tools/teardown.js +42 -0
  179. package/tools/test.js +61 -0
  180. package/types/cordis.d.ts +6 -0
  181. package/types/index.d.ts +339 -0
  182. package/ui-bundle/README.md +111 -0
  183. package/ui-bundle/client.js +1980 -0
  184. package/ui-bundle/cordis.patch.yml +3 -0
  185. package/ui-bundle/icon.svg +6 -0
  186. package/ui-bundle/index.js +2 -0
  187. package/ui-bundle/locale/en.json +6 -0
  188. package/ui-bundle/locale/zh.json +6 -0
  189. package/ui-bundle/package.json +35 -0
  190. package/lib/errors.js +0 -111
  191. package/lib/tools.js +0 -2250
  192. package/lib/tower.js +0 -2689
  193. /package/{lib → domain}/batch.js +0 -0
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
+ }