dsh-tower 2.1.0-beta.1 → 2.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.
Files changed (188) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +71 -5
  4. package/PRINCIPLES.md +145 -0
  5. package/README.en.md +14 -5
  6. package/README.md +14 -5
  7. package/SECURITY.md +6 -0
  8. package/adapters/archive.js +279 -0
  9. package/adapters/caseSensitivity.js +66 -0
  10. package/adapters/default.js +51 -8
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +15 -6
  13. package/{lib → adapters}/legacy.js +20 -6
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/stateFile.js +99 -0
  16. package/adapters/store-fs.js +28 -1
  17. package/adapters/store.js +204 -0
  18. package/{lib → adapters}/tests.js +15 -7
  19. package/adapters/vcs-git.js +15 -1
  20. package/{lib → adapters}/workspaceLock.js +1 -1
  21. package/{lib → adapters}/worktreeOwnership.js +18 -7
  22. package/app/archive.js +188 -0
  23. package/app/batch.js +9 -10
  24. package/app/board.js +4 -4
  25. package/app/checkCompletion.js +23 -11
  26. package/app/enrichedMissions.js +2 -4
  27. package/app/findings.js +6 -7
  28. package/app/gate.js +32 -15
  29. package/app/init.js +59 -22
  30. package/app/merge.js +64 -51
  31. package/app/messages.js +7 -8
  32. package/app/mission.js +7 -5
  33. package/app/plan.js +20 -9
  34. package/app/provision.js +12 -14
  35. package/app/read.js +65 -6
  36. package/app/rebase.js +14 -16
  37. package/app/recording.js +7 -8
  38. package/app/review.js +17 -8
  39. package/app/roster.js +14 -15
  40. package/{lib → app}/spawn-prechecks.js +4 -3
  41. package/app/spawn.js +12 -13
  42. package/app/teardown.js +28 -8
  43. package/app/tower.js +121 -39
  44. package/docs/README.md +6 -2
  45. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  46. package/docs/adr/0002-single-state-json.md +50 -0
  47. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  48. package/docs/adr/0004-surface-budget.md +50 -0
  49. package/docs/adr/README.md +51 -0
  50. package/docs/agents/domain.md +38 -0
  51. package/docs/agents/issue-tracker.md +46 -0
  52. package/docs/agents/triage-labels.md +15 -0
  53. package/docs/agents/workflow.md +51 -0
  54. package/docs/architecture/overview.md +170 -37
  55. package/docs/guide/hot-reload.md +1 -1
  56. package/docs/guide/install-upgrade-migration.md +18 -1
  57. package/docs/guide/release.md +1 -1
  58. package/docs/operations/incident.md +82 -0
  59. package/docs/operations/runbook.md +12 -3
  60. package/docs/reference/blockers.md +3 -3
  61. package/docs/reference/config.generated.md +5 -0
  62. package/docs/reference/config.md +4 -0
  63. package/docs/reference/doctor.md +104 -8
  64. package/docs/reference/errors.generated.md +41 -35
  65. package/docs/reference/errors.md +54 -40
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +128 -0
  68. package/docs/reference/tools.generated.md +9 -8
  69. package/docs/reference/tools.md +9 -8
  70. package/docs/zh/guide/hot-reload.md +1 -1
  71. package/docs/zh/guide/install-upgrade-migration.md +13 -1
  72. package/docs/zh/reference/blockers.md +3 -3
  73. package/docs/zh/reference/config.md +4 -0
  74. package/docs/zh/reference/doctor.md +63 -2
  75. package/docs/zh/reference/errors.md +4 -2
  76. package/docs/zh/reference/tools.md +6 -6
  77. package/domain/batch.js +2 -2
  78. package/domain/config/schema.js +51 -4
  79. package/domain/contracts/outputs.js +33 -3
  80. package/domain/contracts/tool-inputs/missions.js +2 -1
  81. package/domain/contracts/tool-inputs/ops.js +1 -0
  82. package/domain/errors.js +297 -40
  83. package/domain/events.js +207 -0
  84. package/domain/integration/gate.js +41 -12
  85. package/domain/integration/init.js +40 -4
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +1 -1
  89. package/domain/missions/completion.js +72 -5
  90. package/domain/missions/lifecycle.js +11 -4
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/planning.js +6 -3
  94. package/domain/read/views.js +17 -1
  95. package/domain/report.js +9 -1
  96. package/domain/scope.js +83 -11
  97. package/domain/state/archive.js +257 -0
  98. package/domain/state/migrations.js +145 -0
  99. package/domain/state/schema.js +6 -0
  100. package/domain/state.js +154 -4
  101. package/domain/summary.js +2 -0
  102. package/domain/surface.js +248 -0
  103. package/host/doctor.js +555 -0
  104. package/{lib → host}/modeStore.js +1 -1
  105. package/{lib → host}/tool-registry.js +106 -31
  106. package/host/tower.js +32 -0
  107. package/index.js +12 -9
  108. package/package.json +10 -3
  109. package/ports/archive.js +18 -0
  110. package/ports/caseSensitivity.js +15 -0
  111. package/ports/clock.js +10 -2
  112. package/ports/config.js +19 -0
  113. package/ports/contract.js +38 -0
  114. package/ports/events.js +24 -0
  115. package/ports/ids.js +10 -2
  116. package/ports/index.js +54 -2
  117. package/ports/ownership.js +17 -0
  118. package/ports/state.js +16 -0
  119. package/ports/store.js +52 -3
  120. package/ports/vcs.js +25 -4
  121. package/scripts/architecture-waivers.json +19 -0
  122. package/scripts/architecture.mjs +634 -23
  123. package/scripts/check-docs.mjs +8 -35
  124. package/scripts/check-pack.mjs +68 -39
  125. package/scripts/check-philosophy.mjs +266 -0
  126. package/scripts/domain-metrics.mjs +13 -4
  127. package/scripts/generate-config-reference.mjs +14 -1
  128. package/scripts/generate-error-reference.mjs +68 -14
  129. package/scripts/generate-events-reference.mjs +83 -0
  130. package/scripts/generate-tools-reference.mjs +11 -0
  131. package/scripts/issue-audit.mjs +2 -2
  132. package/scripts/lib/surface.mjs +87 -0
  133. package/scripts/pack-manifest.txt +64 -19
  134. package/scripts/regression-audit.mjs +164 -0
  135. package/scripts/release-check.mjs +1 -1
  136. package/scripts/surface-baseline.json +21 -0
  137. package/scripts/surface-baseline.mjs +120 -0
  138. package/tools/adopt.js +2 -3
  139. package/tools/batch.js +2 -3
  140. package/tools/complete.js +3 -4
  141. package/tools/diff.js +3 -3
  142. package/tools/doctor.js +7 -10
  143. package/tools/finding.js +2 -3
  144. package/tools/findings.js +3 -3
  145. package/tools/import-legacy.js +3 -5
  146. package/tools/inbox.js +3 -3
  147. package/tools/index.js +7 -7
  148. package/tools/init.js +4 -7
  149. package/tools/interrupt.js +2 -3
  150. package/tools/legacy-map.js +3 -5
  151. package/tools/merge.js +3 -3
  152. package/tools/mission.js +2 -3
  153. package/tools/plan.js +7 -6
  154. package/tools/preflight.js +3 -3
  155. package/tools/rebase.js +3 -3
  156. package/tools/recover.js +38 -13
  157. package/tools/reload.js +3 -6
  158. package/tools/resource.js +3 -5
  159. package/tools/review.js +3 -4
  160. package/tools/roster.js +2 -3
  161. package/tools/send.js +2 -2
  162. package/tools/spawn.js +8 -5
  163. package/tools/status.js +16 -8
  164. package/tools/teardown.js +3 -4
  165. package/tools/test.js +2 -2
  166. package/types/index.d.ts +35 -1
  167. package/ui-bundle/README.md +37 -3
  168. package/ui-bundle/client.cards.js +356 -0
  169. package/ui-bundle/client.denied.js +142 -0
  170. package/ui-bundle/client.dock.js +333 -0
  171. package/ui-bundle/client.helpers.js +271 -0
  172. package/ui-bundle/client.js +79 -1747
  173. package/ui-bundle/client.locales.js +247 -0
  174. package/ui-bundle/client.mode.js +206 -0
  175. package/ui-bundle/client.overlay.js +64 -0
  176. package/ui-bundle/client.styles.js +151 -0
  177. package/ui-bundle/client.toolview.js +82 -0
  178. package/ui-bundle/package.json +1 -0
  179. package/lib/doctor.js +0 -270
  180. package/lib/persistence.js +0 -349
  181. package/lib/store.js +0 -118
  182. /package/{lib → app}/briefing.js +0 -0
  183. /package/{lib → app}/spawn-support.js +0 -0
  184. /package/{lib → host}/compatibility.js +0 -0
  185. /package/{lib → host}/config.js +0 -0
  186. /package/{lib → host}/hmr.js +0 -0
  187. /package/{lib → host}/modeState.js +0 -0
  188. /package/{lib → host}/presets.js +0 -0
@@ -5,11 +5,11 @@ import {
5
5
  assertCompletionRequest,
6
6
  assertCompletionInbox,
7
7
  completionReport,
8
+ applyCompletion,
8
9
  } from '../domain/missions/completion.js'
9
10
  import { fullMission } from '../domain/summary.js'
10
11
  import { validateState } from '../domain/state.js'
11
- import { countCommits, isClean } from '../lib/git.js'
12
- import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
12
+ import { sweepArchive } from './archive.js'
13
13
 
14
14
  export { normalizeChecks }
15
15
 
@@ -19,16 +19,21 @@ export async function checkCompletion(tower, input) {
19
19
  if (!mission) {
20
20
  throw new TowerToolError('invalid-argument', `unknown mission "${input.missionId}"`)
21
21
  }
22
- const worker = assertCompletionRequest({ mission, roster: state.roster, input })
22
+ const worker = assertCompletionRequest({
23
+ mission,
24
+ roster: state.roster,
25
+ input,
26
+ maxReportBytes: tower.config.gates?.maxReportBytes,
27
+ })
23
28
  await tower.assertWorktree(mission.worktree)
24
- const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
29
+ const commits = await tower.deps.vcs.countCommits(mission.worktree.path, mission.worktree.baseCommit)
25
30
  if (commits < 1) {
26
31
  throw new TowerToolError(
27
32
  'invalid-argument',
28
33
  `mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`,
29
34
  )
30
35
  }
31
- if (!(await isClean(mission.worktree.path))) {
36
+ if (!(await tower.deps.vcs.isClean(mission.worktree.path))) {
32
37
  const dirty = (await tower.git(mission.worktree.path, ['status', '--porcelain']))
33
38
  .split('\n')
34
39
  .filter(Boolean)
@@ -47,7 +52,7 @@ export async function complete(tower, input) {
47
52
  // Two-phase pilot (#51): expensive git/state checks run before the lock;
48
53
  // the lock only revalidates the state revision and commits the transition.
49
54
  const prepared = await tower.checkCompletion(input)
50
- return withLock(tower.paths, async () => {
55
+ return tower.deps.store.withLock(tower.paths, async () => {
51
56
  const state = await tower.load()
52
57
  if (state.revision !== prepared.state.revision) {
53
58
  throw new TowerToolError('stale-base', 'tower state changed while completion was being prepared; retry')
@@ -57,20 +62,27 @@ export async function complete(tower, input) {
57
62
  const { worker, commits } = prepared
58
63
  const checks = normalizeChecks(input.checks)
59
64
  const now = Date.now()
60
- mission.status = 'completed'
61
- mission.completionReport = completionReport({ report: input.report, worker, checks, now })
62
- mission.updatedAt = now
65
+ const storedReport = completionReport({
66
+ report: input.report,
67
+ worker,
68
+ checks,
69
+ now,
70
+ maxReportBytes: tower.config.gates?.maxReportBytes,
71
+ })
72
+ const applied = applyCompletion({ mission, report: storedReport, now })
73
+ await sweepArchive(tower, state, { now })
63
74
  state.revision += 1
64
75
  state.updatedAt = now
65
76
 
66
77
  validateState(state, tower.paths.state)
67
- await writeJsonAtomic(tower.paths.state, state)
68
- await appendJsonl(tower.paths.activity, {
78
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
79
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
69
80
  at: now,
70
81
  type: 'complete',
71
82
  missionId: mission.id,
72
83
  text: `${worker.name} reported completion (${commits} commit(s))`,
73
84
  })
85
+ tower.emitEvents(applied.events, { revision: state.revision, at: now })
74
86
  return { mission: fullMission(mission), commits, unread: 0 }
75
87
  })
76
88
  }
@@ -1,6 +1,4 @@
1
1
  // Dashboard mission enrichment use case.
2
- import { exists } from '../lib/store.js'
3
- import { revParse } from '../lib/git.js'
4
2
  import {
5
3
  enrichedMissionView,
6
4
  latestReviewForMission,
@@ -12,9 +10,9 @@ export async function enrichedMissions(tower, state, onlyIds) {
12
10
  for (const mission of missionsToEnrich(state.missions, onlyIds)) {
13
11
  const latest = latestReviewForMission(state, mission)
14
12
  let tipMoved = false
15
- if (latest && mission.worktree && (await exists(mission.worktree.path))) {
13
+ if (latest && mission.worktree && (await tower.deps.store.exists(mission.worktree.path))) {
16
14
  try {
17
- const tip = await revParse(mission.worktree.path, 'HEAD')
15
+ const tip = await tower.deps.vcs.revParse(mission.worktree.path, 'HEAD')
18
16
  tipMoved = tip !== latest.reviewedCommit
19
17
  } catch {
20
18
  tipMoved = false
package/app/findings.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // Findings use cases.
2
- import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
3
2
  import { validateState } from '../domain/state.js'
4
3
  import { TowerToolError } from '../domain/errors.js'
5
4
  import {
@@ -17,7 +16,7 @@ function fail(message, code = 'invalid-argument') {
17
16
  }
18
17
 
19
18
  export async function addFinding(tower, input) {
20
- return withLock(tower.paths, async () => {
19
+ return tower.deps.store.withLock(tower.paths, async () => {
21
20
  const state = await tower.load()
22
21
  if (!Array.isArray(state.findings)) state.findings = []
23
22
  const fields = normalizeFindingInput(input, state.missions)
@@ -30,8 +29,8 @@ export async function addFinding(tower, input) {
30
29
  state.updatedAt = now
31
30
 
32
31
  validateState(state, tower.paths.state)
33
- await writeJsonAtomic(tower.paths.state, state)
34
- await appendJsonl(tower.paths.activity, {
32
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
33
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
35
34
  at: now,
36
35
  type: 'finding',
37
36
  missionId: finding.missionId,
@@ -55,7 +54,7 @@ export async function addFinding(tower, input) {
55
54
  const actor = normalizeFindingActor(input.actor)
56
55
  if (!wantsFindingUpdate(input)) return tower.readFindings(input)
57
56
 
58
- return withLock(tower.paths, async () => {
57
+ return tower.deps.store.withLock(tower.paths, async () => {
59
58
  const state = await tower.load()
60
59
  const all = state.findings ?? []
61
60
  const finding = all.find((entry) => entry.id === input.id)
@@ -65,8 +64,8 @@ export async function addFinding(tower, input) {
65
64
  state.revision += 1
66
65
  state.updatedAt = now
67
66
  validateState(state, tower.paths.state)
68
- await writeJsonAtomic(tower.paths.state, state)
69
- await appendJsonl(tower.paths.activity, {
67
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
68
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
70
69
  at: now,
71
70
  type: 'finding',
72
71
  missionId: finding.missionId,
package/app/gate.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // Merge/preflight gate evaluation.
2
- import { currentBranch } from '../lib/git.js'
3
2
  import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
4
3
  import { completionBlockers, mergeGateBlockers } from '../domain/gate.js'
5
4
  import { fullMission } from '../domain/summary.js'
@@ -20,8 +19,23 @@ import {
20
19
 
21
20
  export async function evaluateGate(tower, input) {
22
21
  const state = await tower.load()
23
- const mission = state.missions.find((entry) => entry.id === input.missionId)
22
+ let mission = state.missions.find((entry) => entry.id === input.missionId)
23
+ let archivedEntry = null
24
+ if (!mission) {
25
+ // A completed mission may have been archived by policy; preflight and
26
+ // merge must behave the same for it as before the move (#73).
27
+ const archived = await tower.archivedMission(input.missionId)
28
+ if (archived.ok && archived.entry) {
29
+ archivedEntry = archived.entry
30
+ mission = archived.entry.mission
31
+ }
32
+ }
24
33
  if (!mission) return missingMissionGateResult(input.missionId)
34
+ const archiveIndex = await tower.archiveIndex()
35
+ const archivedMissions = new Map(
36
+ (archiveIndex.ok ? archiveIndex.missions : []).map((entry) => [entry.id, entry]),
37
+ )
38
+ const reviews = archivedEntry ? [...(state.reviews ?? []), ...(archivedEntry.reviews ?? [])] : state.reviews
25
39
  const activity = input.activity ?? {}
26
40
  const blockers = []
27
41
  const push = (code, detail) => blockers.push({ code, detail })
@@ -57,7 +71,7 @@ export async function evaluateGate(tower, input) {
57
71
  if (blocker) push(blocker.code, blocker.detail)
58
72
  }
59
73
  if (mission.kind !== 'survey') {
60
- const blocker = outOfScopeBlocker({ mission, changedFiles })
74
+ const blocker = outOfScopeBlocker({ mission, changedFiles, caseInsensitive: state.caseInsensitive === true })
61
75
  if (blocker) push(blocker.code, blocker.detail)
62
76
  }
63
77
  const dirty = (await tower.git(tower.root, ['status', '--porcelain']))
@@ -71,12 +85,12 @@ export async function evaluateGate(tower, input) {
71
85
  }
72
86
  }
73
87
 
74
- for (const blocker of dependencyBlockers({ deps: mission.deps ?? [], state })) {
88
+ for (const blocker of dependencyBlockers({ deps: mission.deps ?? [], state, archived: archivedMissions })) {
75
89
  push(blocker.code, blocker.detail)
76
90
  }
77
91
 
78
92
  const branch = worktree ? worktree.branch : null
79
- const latest = latestReview(reviewsForBranch(state.reviews, branch))
93
+ const latest = latestReview(reviewsForBranch(reviews, branch))
80
94
  for (const blocker of mergeGateBlockers({
81
95
  mission,
82
96
  roster: state.roster ?? [],
@@ -90,19 +104,22 @@ export async function evaluateGate(tower, input) {
90
104
  rebaseWaived: latest ? tower.rebaseWaived(mission, latest) : false,
91
105
  })) push(blocker.code, blocker.detail)
92
106
 
93
- const checkoutBranch = await currentBranch(tower.root)
107
+ const checkoutBranch = await tower.deps.vcs.currentBranch(tower.root)
94
108
  const wrongBranch = baseWrongBranchBlocker({ checkoutBranch, base: state.base })
95
109
  if (wrongBranch) push(wrongBranch.code, wrongBranch.detail)
96
110
 
97
- return gateResult({
98
- mission: fullMission(mission),
99
- worktree,
100
- branchTip,
101
- baseTip,
102
- changedFiles,
103
- review: latest,
104
- blockers,
105
- })
111
+ return {
112
+ ...gateResult({
113
+ mission: fullMission(mission),
114
+ worktree,
115
+ branchTip,
116
+ baseTip,
117
+ changedFiles,
118
+ review: latest,
119
+ blockers,
120
+ }),
121
+ ...(archivedEntry ? { archived: true } : {}),
122
+ }
106
123
  }
107
124
 
108
125
  /** Read-only mission diff for reviewers: branch changes against the merge base. */
package/app/init.js CHANGED
@@ -6,11 +6,11 @@
6
6
  // the repo-scoped git mutex, never under the state lock.
7
7
  // commit — one short state lock writes/claims/reanchors, revalidating the
8
8
  // state revision captured by prepare.
9
- import { withLock, withGitLock, exists, ensureDir, writeJsonAtomic, appendJsonl } from '../lib/store.js'
10
9
  import { validateState } from '../domain/state.js'
11
10
  import { TowerToolError } from '../domain/errors.js'
12
11
  import {
13
12
  SNAPSHOT_REF_PREFIX,
13
+ caseProbeActivityText,
14
14
  claimCaller,
15
15
  createdInitResult,
16
16
  existingInitResult,
@@ -33,6 +33,16 @@ function fail(message, code = 'invalid-argument') {
33
33
  throw new TowerToolError(code, message)
34
34
  }
35
35
 
36
+ /**
37
+ * Probe the checkout's own filesystem through the injected case-sensitivity
38
+ * port. tower_init probes once and records the answer in state (#90).
39
+ * @param {any} tower
40
+ * @returns {Promise<boolean | null>}
41
+ */
42
+ function detectCaseMode(tower) {
43
+ return tower.deps.caseSensitivity.detect(tower.root)
44
+ }
45
+
36
46
  /** Phase 1 of `Tower.init` (#51). A read-only init returns its result here. */
37
47
  async function prepareInit(tower, input) {
38
48
  const dirty = !(await tower.isClean(tower.root))
@@ -53,13 +63,17 @@ async function prepareInit(tower, input) {
53
63
  return /** @type {{ base: string }} */ (fallback).base
54
64
  }
55
65
 
56
- if (await exists(tower.paths.state)) {
66
+ if (await tower.deps.store.exists(tower.paths.state)) {
57
67
  const state = await tower.load()
58
68
  const owner = state.towerAgentId ? String(state.towerAgentId) : null
59
69
  const caller = input.towerAgentId ? String(input.towerAgentId) : null
60
70
  if (ownedByOther(owner, caller)) {
61
71
  fail(`tower state is owned by another agent; ${caller} is not the tower`, 'not-authorized')
62
72
  }
73
+ // "Detect once": a state that already carries a boolean is never re-probed,
74
+ // so a transient probe failure cannot silently flip the comparison mode.
75
+ const known = state.caseInsensitive === true || state.caseInsensitive === false
76
+ const caseInsensitive = known ? null : await detectCaseMode(tower)
63
77
  let reanchorBase = null
64
78
  if (input.reanchor === true) {
65
79
  const refusal = reanchorBlockedMessage(state.missions)
@@ -68,10 +82,10 @@ async function prepareInit(tower, input) {
68
82
  }
69
83
  const claim = claimCaller(owner, caller) !== null
70
84
  const reanchor = shouldReanchor({ reanchor: input.reanchor, reanchorBase, currentBase: state.base })
71
- if (!claim && !reanchor) {
85
+ if (!claim && !reanchor && caseInsensitive === null) {
72
86
  return { early: existingInitResult({ state, statePath: tower.paths.state, dirty }) }
73
87
  }
74
- return { stateRevision: state.revision, claim, reanchor, base: reanchorBase, dirty }
88
+ return { stateRevision: state.revision, claim, reanchor, base: reanchorBase, dirty, caseInsensitive }
75
89
  }
76
90
 
77
91
  return {
@@ -79,6 +93,7 @@ async function prepareInit(tower, input) {
79
93
  base: await resolveBase(),
80
94
  snapshot: shouldSnapshot({ snapshotDirty: input.snapshotDirty, dirty }),
81
95
  dirty,
96
+ caseInsensitive: await detectCaseMode(tower),
82
97
  }
83
98
  }
84
99
 
@@ -88,8 +103,8 @@ async function prepareInit(tower, input) {
88
103
  * revalidated first so a concurrent init cannot leave an unreferenced snapshot.
89
104
  */
90
105
  async function actInitCreate(tower, prepared) {
91
- return withGitLock(tower.paths, async () => {
92
- if (await exists(tower.paths.state)) return { stale: true }
106
+ return tower.deps.store.withGitLock(tower.paths, async () => {
107
+ if (await tower.deps.store.exists(tower.paths.state)) return { stale: true }
93
108
  if (!prepared.snapshot) return { snapshotCommit: null, snapshotRef: null }
94
109
  const snapshotCommit = ((await tower.tryGit(tower.root, ['stash', 'create'])) ?? '').trim() || null
95
110
  if (!snapshotCommit) {
@@ -100,12 +115,12 @@ async function actInitCreate(tower, prepared) {
100
115
  const snapshotRef = snapshotRefFor(snapshotCommit)
101
116
  await tower.git(tower.root, ['update-ref', snapshotRef, snapshotCommit])
102
117
  return { snapshotCommit, snapshotRef }
103
- })
118
+ }, tower.gitMutexOptions({ phase: 'init' }))
104
119
  }
105
120
 
106
121
  /** Phase 2b of `Tower.init` (#51): snapshot-ref cleanup for a base reanchor. */
107
122
  async function actInitReanchor(tower, prepared) {
108
- return withGitLock(tower.paths, async () => {
123
+ return tower.deps.store.withGitLock(tower.paths, async () => {
109
124
  const state = await tower.load()
110
125
  if (state.revision !== prepared.stateRevision) return { staleRevision: state.revision }
111
126
  if (state.base === prepared.base) return { deleted: [] }
@@ -123,26 +138,26 @@ async function actInitReanchor(tower, prepared) {
123
138
  }
124
139
  }
125
140
  return { deleted }
126
- })
141
+ }, tower.gitMutexOptions({ phase: 'init' }))
127
142
  }
128
143
 
129
144
  /** Restore snapshot refs when the reanchor commit is rejected after phase 2. */
130
145
  async function restoreInitRefs(tower, deleted) {
131
146
  const entries = deleted.filter((entry) => typeof entry.sha === 'string' && entry.sha.length > 0)
132
147
  if (entries.length === 0) return
133
- await withGitLock(tower.paths, async () => {
148
+ await tower.deps.store.withGitLock(tower.paths, async () => {
134
149
  for (const entry of entries) {
135
150
  await tower.git(tower.root, ['update-ref', entry.ref, entry.sha]).catch(() => {})
136
151
  }
137
- })
152
+ }, tower.gitMutexOptions({ phase: 'init' }))
138
153
  }
139
154
 
140
155
  /** Phase 3a of `Tower.init` (#51): write the initial state under a short lock. */
141
156
  async function commitInitCreate(tower, prepared, acted, input) {
142
- return withLock(tower.paths, async () => {
157
+ return tower.deps.store.withLock(tower.paths, async () => {
143
158
  // Revision revalidation for the create path: prepare saw "absent", so a
144
159
  // state file that appeared meanwhile must never be overwritten.
145
- if (await exists(tower.paths.state)) {
160
+ if (await tower.deps.store.exists(tower.paths.state)) {
146
161
  throw new TowerToolError('stale-base', 'tower state appeared while init was being prepared; retry')
147
162
  }
148
163
  const now = Date.now()
@@ -154,14 +169,20 @@ async function commitInitCreate(tower, prepared, acted, input) {
154
169
  towerAgentId: input.towerAgentId ? String(input.towerAgentId) : null,
155
170
  snapshotCommit: acted.snapshotCommit,
156
171
  snapshotRef: acted.snapshotRef,
172
+ caseInsensitive: prepared.caseInsensitive,
157
173
  now,
158
174
  })
159
175
  validateState(state, tower.paths.state)
160
- await writeJsonAtomic(tower.paths.state, state)
161
- await appendJsonl(tower.paths.activity, {
176
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
177
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
162
178
  at: now,
163
179
  type: 'init',
164
- text: initActivityText({ base: prepared.base, snapshotCommit: acted.snapshotCommit, dirty: prepared.dirty }),
180
+ text: initActivityText({
181
+ base: prepared.base,
182
+ snapshotCommit: acted.snapshotCommit,
183
+ dirty: prepared.dirty,
184
+ caseInsensitive: prepared.caseInsensitive,
185
+ }),
165
186
  })
166
187
  return createdInitResult({
167
188
  repoRoot: tower.root,
@@ -171,13 +192,14 @@ async function commitInitCreate(tower, prepared, acted, input) {
171
192
  dirty: prepared.dirty,
172
193
  snapshotCommit: acted.snapshotCommit,
173
194
  snapshotRef: acted.snapshotRef,
195
+ caseInsensitive: prepared.caseInsensitive,
174
196
  })
175
197
  })
176
198
  }
177
199
 
178
200
  /** Phase 3b of `Tower.init` (#51): owner claim and reanchor writes under a short lock. */
179
201
  async function commitInitExisting(tower, prepared, input) {
180
- return withLock(tower.paths, async () => {
202
+ return tower.deps.store.withLock(tower.paths, async () => {
181
203
  const state = await tower.load()
182
204
  if (state.revision !== prepared.stateRevision) {
183
205
  throw new TowerToolError(
@@ -197,14 +219,29 @@ async function commitInitExisting(tower, prepared, input) {
197
219
  state.revision += 1
198
220
  state.updatedAt = now
199
221
  validateState(state, tower.paths.state)
200
- await writeJsonAtomic(tower.paths.state, state)
201
- await appendJsonl(tower.paths.activity, {
222
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
223
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
202
224
  at: now,
203
225
  type: 'init',
204
226
  text: ownerClaimActivityText(claimant),
205
227
  })
206
228
  }
207
229
  let reanchored = false
230
+ // Record the one-time case-sensitivity probe (#90). An unknown result is
231
+ // not persisted, so a later init can retry it without bumping the revision
232
+ // on every call.
233
+ if (prepared.caseInsensitive !== null && state.caseInsensitive !== prepared.caseInsensitive) {
234
+ state.caseInsensitive = prepared.caseInsensitive
235
+ state.revision += 1
236
+ state.updatedAt = now
237
+ validateState(state, tower.paths.state)
238
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
239
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
240
+ at: now,
241
+ type: 'init',
242
+ text: caseProbeActivityText(prepared.caseInsensitive),
243
+ })
244
+ }
208
245
  if (prepared.reanchor && state.base !== prepared.base) {
209
246
  state.base = prepared.base
210
247
  state.baseChangedAt = now
@@ -214,8 +251,8 @@ async function commitInitExisting(tower, prepared, input) {
214
251
  state.revision += 1
215
252
  state.updatedAt = now
216
253
  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) })
254
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
255
+ await tower.deps.store.appendJsonl(tower.paths.activity, { at: now, type: 'init', text: reanchorActivityText(prepared.base) })
219
256
  }
220
257
  return existingInitWriteResult({ state, statePath: tower.paths.state, dirty: prepared.dirty, reanchored })
221
258
  })
@@ -223,7 +260,7 @@ async function commitInitExisting(tower, prepared, input) {
223
260
 
224
261
  /** #51 two-phase init: prepare/act/commit with stale-state retries. */
225
262
  export async function init(tower, input = {}) {
226
- await ensureDir(tower.paths.dir)
263
+ await tower.deps.store.ensureDir(tower.paths.dir)
227
264
  const prepared = await prepareInit(tower, input)
228
265
  if (prepared.early) return prepared.early
229
266
  if (prepared.create) {