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
package/app/merge.js CHANGED
@@ -17,10 +17,11 @@
17
17
  // from. A writer that slipped in between observation and write makes the
18
18
  // prepare stale, so the whole prepare is retried, never partially applied.
19
19
  import path from 'node:path'
20
- import { withLock, withGitLock, writeJsonAtomic, appendJsonl, exists } from '../lib/store.js'
21
20
  import { validateState } from '../domain/state.js'
22
21
  import {
23
22
  alreadyMergedResult,
23
+ applyLandedMerge,
24
+ applyMergeSuccess,
24
25
  buildMergeIntent,
25
26
  classifyCommitOutcome,
26
27
  gateTipDrift,
@@ -37,7 +38,6 @@ import {
37
38
  mergeIntentEvidence,
38
39
  mergeIntentReconcileNote,
39
40
  mergeShapeDetail,
40
- mergeSuccessNote,
41
41
  missionMissingResult,
42
42
  pendingMergeIntentResult,
43
43
  staleBaseResult,
@@ -83,7 +83,7 @@ async function observeMergeIntent(tower, state, mission) {
83
83
  } catch {
84
84
  return null
85
85
  }
86
- const mergeInProgress = await exists(path.join(tower.commonDir, 'MERGE_HEAD'))
86
+ const mergeInProgress = await tower.deps.store.exists(path.join(tower.commonDir, 'MERGE_HEAD'))
87
87
  if (mergeInProgress) return null
88
88
  if (head === intent.baseTip) return mergeIntentEvidence({ head, baseTip: intent.baseTip, mergeInProgress, landed: null })
89
89
  const tips = new Set([intent.branchTip, intent.baseTip].filter(Boolean))
@@ -98,7 +98,7 @@ async function observeMergeIntent(tower, state, mission) {
98
98
  * the new state instead of overwriting it.
99
99
  */
100
100
  async function applyMergeIntentReconcile(tower, stateRevision, missionId, evidence) {
101
- return withLock(tower.paths, async () => {
101
+ return tower.deps.store.withLock(tower.paths, async () => {
102
102
  const state = await tower.load()
103
103
  if (state.revision !== stateRevision) return { retry: true }
104
104
  const mission = state.missions.find((entry) => entry.id === missionId)
@@ -108,8 +108,8 @@ async function applyMergeIntentReconcile(tower, stateRevision, missionId, eviden
108
108
  delete state.mergeIntent
109
109
  state.revision += 1
110
110
  state.updatedAt = now
111
- await writeJsonAtomic(tower.paths.state, state)
112
- await appendJsonl(tower.paths.activity, {
111
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
112
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
113
113
  at: now,
114
114
  type: 'merge',
115
115
  missionId: mission.id,
@@ -173,31 +173,30 @@ async function observeLandedMerge(tower, state, mission) {
173
173
 
174
174
  /** Locked write half of `reconcileLandedMerge`, guarded by the revision CAS. */
175
175
  async function applyLandedMergeReconcile(tower, stateRevision, missionId, observed) {
176
- return withLock(tower.paths, async () => {
176
+ return tower.deps.store.withLock(tower.paths, async () => {
177
177
  const state = await tower.load()
178
178
  if (state.revision !== stateRevision) return { retry: true }
179
179
  const mission = state.missions.find((entry) => entry.id === missionId)
180
180
  if (!mission) return { result: missionMissingResult(missionId) }
181
181
  const now = Date.now()
182
182
  if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
183
- mission.status = 'merged'
184
- mission.mergedAt = now
185
- mission.mergedCommit = observed.mergedCommit
186
- mission.blockers = []
187
- mission.notes.push(
188
- landedMergeNote({ found: observed.found, mergedCommit: observed.mergedCommit, base: state.base }),
189
- )
190
- mission.updatedAt = now
183
+ const applied = applyLandedMerge({
184
+ mission,
185
+ mergedCommit: observed.mergedCommit,
186
+ note: landedMergeNote({ found: observed.found, mergedCommit: observed.mergedCommit, base: state.base }),
187
+ now,
188
+ })
191
189
  state.revision += 1
192
190
  state.updatedAt = now
193
191
  validateState(state, tower.paths.state)
194
- await writeJsonAtomic(tower.paths.state, state)
195
- await appendJsonl(tower.paths.activity, {
192
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
193
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
196
194
  at: now,
197
195
  type: 'merge',
198
196
  missionId: mission.id,
199
197
  text: `reconciled already-landed ${observed.mergedCommit.slice(0, 7)} on ${state.base}`,
200
198
  })
199
+ tower.emitEvents(applied.events, { revision: state.revision, at: now })
201
200
  return { result: landedMergeResult(observed) }
202
201
  })
203
202
  }
@@ -232,6 +231,12 @@ async function prepareFromState(tower, input, deps) {
232
231
  const { mergeTimeoutMs, MERGE_MAX_BUFFER } = deps
233
232
  let state = await tower.load()
234
233
  let mission = state.missions.find((entry) => entry.id === input.missionId)
234
+ if (!mission) {
235
+ // An archived terminal mission still answers `alreadyMerged` (or the same
236
+ // blockers) instead of turning into mission-missing after the move (#73).
237
+ const archived = await tower.archivedMission(input.missionId)
238
+ if (archived.ok && archived.entry) mission = archived.entry.mission
239
+ }
235
240
  if (!mission) return { result: missionMissingResult(input.missionId) }
236
241
 
237
242
  // Reconcile a stale merge intent: Git runs here, outside every state lock.
@@ -296,7 +301,7 @@ async function prepareFromState(tower, input, deps) {
296
301
  * next tower_merge/preflight instead of leaving an unexplained base tip.
297
302
  */
298
303
  async function recordMergeIntent(tower, plan) {
299
- return withLock(tower.paths, async () => {
304
+ return tower.deps.store.withLock(tower.paths, async () => {
300
305
  const state = await tower.load()
301
306
  // State revision policy: the plan was built from `plan.stateRevision`; a
302
307
  // writer that slipped in before any Git mutation makes the plan stale and
@@ -330,7 +335,7 @@ async function recordMergeIntent(tower, plan) {
330
335
  })
331
336
  state.revision += 1
332
337
  state.updatedAt = now
333
- await writeJsonAtomic(tower.paths.state, state)
338
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
334
339
  return { intent: state.mergeIntent, intentRevision: state.revision }
335
340
  })
336
341
  }
@@ -355,7 +360,7 @@ async function runMergeGit(tower, plan, intent, deps) {
355
360
  }
356
361
 
357
362
  const newTip = await tower.revParse(tower.root, 'HEAD')
358
- const parents = await commitParents(tower.root, newTip)
363
+ const parents = await commitParents(tower, tower.root, newTip)
359
364
  const expectedShape = isExpectedMergeShape(parents, intent.baseTip, intent.branchTip)
360
365
  const warnings = []
361
366
 
@@ -378,12 +383,12 @@ async function runMergeGit(tower, plan, intent, deps) {
378
383
  const conflict = unmerged.trim().length > 0
379
384
  let cleanup = 'none'
380
385
  const mergeHeadPath = path.join(tower.commonDir, 'MERGE_HEAD')
381
- if (await exists(mergeHeadPath)) {
386
+ if (await tower.deps.store.exists(mergeHeadPath)) {
382
387
  try {
383
388
  await tower.git(tower.root, ['merge', '--abort'])
384
389
  cleanup = 'aborted'
385
390
  } catch {
386
- await removeStaleGitLocks(tower.commonDir)
391
+ await removeStaleGitLocks(tower, tower.commonDir)
387
392
  try {
388
393
  await tower.git(tower.root, ['merge', '--abort'])
389
394
  cleanup = 'aborted-after-lock-cleanup'
@@ -405,7 +410,7 @@ async function runMergeGit(tower, plan, intent, deps) {
405
410
  if (dirtyAfter.length > 0) {
406
411
  const changed = new Set(plan.changedFiles)
407
412
  if (newTip === intent.baseTip && dirtyAfter.every((file) => changed.has(file))) {
408
- await removeStaleGitLocks(tower.commonDir)
413
+ await removeStaleGitLocks(tower, tower.commonDir)
409
414
  await tower.git(tower.root, ['reset', '--hard', intent.baseTip]).catch(() => {})
410
415
  cleanup = cleanup === 'none' ? 'reset' : `${cleanup}+reset`
411
416
  } else {
@@ -429,7 +434,7 @@ async function runMergeGit(tower, plan, intent, deps) {
429
434
 
430
435
  /** Phase 3: apply the observed outcome under a short state lock. */
431
436
  async function commitMerge(tower, plan, recorded, outcome) {
432
- return withLock(tower.paths, async () => {
437
+ return tower.deps.store.withLock(tower.paths, async () => {
433
438
  const state = await tower.load()
434
439
  const mission = state.missions.find((entry) => entry.id === plan.missionId)
435
440
  if (!mission) return missionMissingResult(plan.missionId)
@@ -457,21 +462,24 @@ async function commitMerge(tower, plan, recorded, outcome) {
457
462
  if (kind === 'merged') {
458
463
  const now = Date.now()
459
464
  if (intentActive) delete state.mergeIntent
460
- mission.status = 'merged'
461
- mission.mergedAt = now
462
- mission.mergedCommit = outcome.newTip
463
- mission.updatedAt = now
464
- mission.notes.push(mergeSuccessNote(recorded.intent.branchTip, state.base, outcome.newTip))
465
+ const applied = applyMergeSuccess({
466
+ mission,
467
+ branchTip: recorded.intent.branchTip,
468
+ base: state.base,
469
+ newTip: outcome.newTip,
470
+ now,
471
+ })
465
472
  state.revision += 1
466
473
  state.updatedAt = now
467
474
  validateState(state, tower.paths.state)
468
- await writeJsonAtomic(tower.paths.state, state)
469
- await appendJsonl(tower.paths.activity, {
475
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
476
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
470
477
  at: now,
471
478
  type: 'merge',
472
479
  missionId: mission.id,
473
480
  text: `${recorded.intent.branchTip.slice(0, 7)} -> ${outcome.newTip.slice(0, 7)} on ${state.base}`,
474
481
  })
482
+ tower.emitEvents(applied.events, { revision: state.revision, at: now })
475
483
  return {
476
484
  ok: true,
477
485
  mergedCommit: outcome.newTip,
@@ -491,8 +499,8 @@ async function commitMerge(tower, plan, recorded, outcome) {
491
499
  state.mergeIntent.problem = { code: 'merge-shape', newTip: outcome.newTip, parents: outcome.parents, at: Date.now() }
492
500
  state.revision += 1
493
501
  state.updatedAt = Date.now()
494
- await writeJsonAtomic(tower.paths.state, state)
495
- await appendJsonl(tower.paths.activity, {
502
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
503
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
496
504
  at: state.updatedAt,
497
505
  type: 'merge',
498
506
  missionId: mission.id,
@@ -522,8 +530,8 @@ async function commitMerge(tower, plan, recorded, outcome) {
522
530
  mission.updatedAt = now
523
531
  state.revision += 1
524
532
  state.updatedAt = now
525
- await writeJsonAtomic(tower.paths.state, state)
526
- await appendJsonl(tower.paths.activity, {
533
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
534
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
527
535
  at: now,
528
536
  type: 'merge',
529
537
  missionId: mission.id,
@@ -544,23 +552,28 @@ export async function merge(tower, input, deps) {
544
552
  const plan = await prepareMerge(tower, input, deps)
545
553
  if (plan.result) return plan.result
546
554
 
547
- const acted = await withGitLock(tower.paths, async () => {
548
- // No Git mutation has happened yet: a base that moved after prepare is a
549
- // clean retryable stale-base rejection, with no intent left behind.
550
- const head = await tower.revParse(tower.root, 'HEAD')
551
- if (head !== plan.baseTip) {
552
- return {
553
- result: staleBaseResult(
554
- plan.missionId,
555
- `base moved after gate (${plan.baseTip.slice(0, 7)} -> ${head.slice(0, 7)}); re-run preflight`,
556
- ),
555
+ const acted = await tower.deps.store.withGitLock(
556
+ tower.paths,
557
+ async () => {
558
+ // No Git mutation has happened yet: a base that moved after prepare is a
559
+ // clean retryable stale-base rejection, with no intent left behind.
560
+ const head = await tower.revParse(tower.root, 'HEAD')
561
+ if (head !== plan.baseTip) {
562
+ return {
563
+ result: staleBaseResult(
564
+ plan.missionId,
565
+ `base moved after gate (${plan.baseTip.slice(0, 7)} -> ${head.slice(0, 7)}); re-run preflight`,
566
+ ),
567
+ }
557
568
  }
558
- }
559
- const recorded = await recordMergeIntent(tower, plan)
560
- if (recorded.result) return recorded
561
- const outcome = await runMergeGit(tower, plan, recorded.intent, deps)
562
- return { recorded, outcome }
563
- })
569
+ const recorded = await recordMergeIntent(tower, plan)
570
+ if (recorded.result) return recorded
571
+ const outcome = await runMergeGit(tower, plan, recorded.intent, deps)
572
+ return { recorded, outcome }
573
+ },
574
+ // The merge itself may hold the mutex for plan.timeoutMs (#93).
575
+ tower.gitMutexOptions({ phase: 'merge', mergeTimeoutMs: plan.timeoutMs }),
576
+ )
564
577
  if (acted.result) return acted.result
565
578
 
566
579
  return commitMerge(tower, plan, acted.recorded, acted.outcome)
package/app/messages.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // Inbox/message 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 {
@@ -14,7 +13,7 @@ function fail(message) {
14
13
  }
15
14
 
16
15
  export async function recordMessage(tower, input) {
17
- return withLock(tower.paths, async () => {
16
+ return tower.deps.store.withLock(tower.paths, async () => {
18
17
  const state = await tower.load()
19
18
  if (!Array.isArray(state.messages)) state.messages = []
20
19
  assertMessageRecipient({ roster: state.roster ?? [], to: input.to, from: input.from })
@@ -27,8 +26,8 @@ export async function recordMessage(tower, input) {
27
26
  state.updatedAt = now
28
27
 
29
28
  validateState(state, tower.paths.state)
30
- await writeJsonAtomic(tower.paths.state, state)
31
- await appendJsonl(tower.paths.activity, {
29
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
30
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
32
31
  at: now,
33
32
  type: 'message',
34
33
  text: `${input.from} -> ${input.to}: ${input.subject}`,
@@ -38,20 +37,20 @@ export async function recordMessage(tower, input) {
38
37
  }
39
38
 
40
39
  export async function recordDelivery(tower, messageId, results) {
41
- return withLock(tower.paths, async () => {
40
+ return tower.deps.store.withLock(tower.paths, async () => {
42
41
  const state = await tower.load()
43
42
  const message = (state.messages ?? []).find((entry) => entry.id === messageId)
44
43
  if (!message) fail(`unknown message "${messageId}"`)
45
44
  addDelivery(message, results)
46
45
  state.revision += 1
47
46
  state.updatedAt = Date.now()
48
- await writeJsonAtomic(tower.paths.state, state)
47
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
49
48
  return message.delivery
50
49
  })
51
50
  }
52
51
 
53
52
  export async function inbox(tower, input) {
54
- return withLock(tower.paths, async () => {
53
+ return tower.deps.store.withLock(tower.paths, async () => {
55
54
  const state = await tower.load()
56
55
  const now = Date.now()
57
56
  const { payload, changed } = inboxView(state.messages ?? [], input, now)
@@ -59,7 +58,7 @@ export async function recordMessage(tower, input) {
59
58
  state.revision += 1
60
59
  state.updatedAt = now
61
60
  validateState(state, tower.paths.state)
62
- await writeJsonAtomic(tower.paths.state, state)
61
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
63
62
  }
64
63
  return payload
65
64
  })
package/app/mission.js CHANGED
@@ -3,7 +3,7 @@ import { TowerToolError } from '../domain/errors.js'
3
3
  import { applyMissionUpdate } from '../domain/missions/lifecycle.js'
4
4
  import { validateState } from '../domain/state.js'
5
5
  import { fullMission } from '../domain/summary.js'
6
- import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
6
+ import { sweepArchive } from './archive.js'
7
7
 
8
8
  function fail(message, code = 'invalid-argument') {
9
9
  throw new TowerToolError(code, message)
@@ -12,24 +12,26 @@ function fail(message, code = 'invalid-argument') {
12
12
  export async function updateMission(tower, input = {}) {
13
13
  const missionId = typeof input.id === 'string' ? input.id.trim() : input.id
14
14
  if (typeof missionId !== 'string' || missionId.length === 0) fail('tower_mission requires id')
15
- return withLock(tower.paths, async () => {
15
+ return tower.deps.store.withLock(tower.paths, async () => {
16
16
  const state = await tower.load()
17
17
  const mission = state.missions.find((entry) => entry.id === missionId)
18
18
  if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
19
- const { changes } = applyMissionUpdate({ mission, state, input, config: tower.config })
19
+ const { changes, events } = applyMissionUpdate({ mission, state, input, config: tower.config })
20
20
  const now = Date.now()
21
21
  mission.updatedAt = now
22
+ await sweepArchive(tower, state, { now })
22
23
  state.revision += 1
23
24
  state.updatedAt = now
24
25
 
25
26
  validateState(state, tower.paths.state)
26
- await writeJsonAtomic(tower.paths.state, state)
27
- await appendJsonl(tower.paths.activity, {
27
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
28
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
28
29
  at: now,
29
30
  type: 'mission',
30
31
  missionId: mission.id,
31
32
  text: changes.join('; '),
32
33
  })
34
+ tower.emitEvents(events, { revision: state.revision, at: now })
33
35
  return { revision: state.revision, mission: fullMission(mission) }
34
36
  })
35
37
  }
package/app/plan.js CHANGED
@@ -5,38 +5,49 @@ import {
5
5
  assignMissionIds,
6
6
  assertKnownDependencies,
7
7
  newMission,
8
+ applyPlan,
8
9
  } from '../domain/missions/plan.js'
9
10
  import { validateState } from '../domain/state.js'
10
11
  import { fullMission } from '../domain/summary.js'
11
- import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
12
+ import { sweepArchive } from './archive.js'
12
13
 
13
14
  export async function plan(tower, input = {}) {
14
15
  const prepared = normalizePlanMissions(input)
15
- return withLock(tower.paths, async () => {
16
+ return tower.deps.store.withLock(tower.paths, async () => {
16
17
  const state = await tower.load()
17
18
  const now = Date.now()
18
- const assigned = assignMissionIds(prepared, nextMissionNumber(state.missions))
19
+ // Archived ids stay in the known set: a new mission must not reuse an id
20
+ // and a merged dependency may live in the archive index (#73).
21
+ const archive = await tower.archiveIndex()
22
+ const known = [...state.missions, ...(archive.ok ? archive.missions : [])]
23
+ const assigned = assignMissionIds(prepared, nextMissionNumber(known))
19
24
 
20
- assertKnownDependencies(assigned, state.missions)
25
+ assertKnownDependencies(assigned, known)
21
26
 
22
- addAutoDeps(assigned, state.missions)
23
- assertAcyclic([...state.missions, ...assigned])
27
+ addAutoDeps(assigned, known, { caseInsensitive: state.caseInsensitive === true })
28
+ assertAcyclic([...known, ...assigned])
24
29
 
25
30
  const created = assigned.map((mission) => newMission(mission.id, mission, now))
26
- state.missions.push(...created)
31
+ const applied = applyPlan(state, created)
32
+ const swept = await sweepArchive(tower, state, { now })
27
33
  state.revision += 1
28
34
  state.updatedAt = now
29
35
 
30
36
  validateState(state, tower.paths.state)
31
- await writeJsonAtomic(tower.paths.state, state)
32
- await appendJsonl(tower.paths.activity, {
37
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
38
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
33
39
  at: now,
34
40
  type: 'plan',
35
41
  text: `created ${created.map((mission) => mission.id).join(', ')}`,
36
42
  })
43
+ // Persist first, then describe: the event carries the revision the caller
44
+ // can read back from state.json (#75).
45
+ tower.emitEvents(applied.events, { revision: state.revision, at: now })
37
46
  return {
38
47
  revision: state.revision,
39
48
  missions: created.map((mission) => fullMission(mission)),
49
+ ...(swept.archived.length > 0 ? { archived: swept.archived } : {}),
50
+ ...(swept.error ? { archiveError: swept.error } : {}),
40
51
  }
41
52
  })
42
53
  }
package/app/provision.js CHANGED
@@ -7,9 +7,7 @@
7
7
  // mission still has no worktree, then records it. A rejected
8
8
  // commit rolls the created worktree/branch back outside the lock.
9
9
  import path from 'node:path'
10
- import { withLock, withGitLock, exists, ensureDir, writeJsonAtomic, appendJsonl, shortHash } from '../lib/store.js'
11
10
  import { validateState } from '../domain/state.js'
12
- import { canonicalPath } from '../lib/worktreeOwnership.js'
13
11
  import { TowerToolError } from '../domain/errors.js'
14
12
  import {
15
13
  baseCommitChoice,
@@ -53,13 +51,13 @@ async function prepareProvision(tower, input) {
53
51
  worktreeRoot: input.worktreeRoot,
54
52
  configuredRoot: tower.config.worktrees.root,
55
53
  repoRoot: tower.root,
56
- commonDirHash: shortHash(tower.commonDir),
54
+ commonDirHash: tower.deps.ids.shortHash(tower.commonDir),
57
55
  })
58
56
  if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
59
57
  tower.assertOutside(root)
60
- await ensureDir(root)
58
+ await tower.deps.store.ensureDir(root)
61
59
  const wtPath = worktreePath(root, mission.id)
62
- if (await exists(wtPath)) fail(`worktree path already exists: ${wtPath}`)
60
+ if (await tower.deps.store.exists(wtPath)) fail(`worktree path already exists: ${wtPath}`)
63
61
  if ((await tower.tryGit(tower.root, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])) !== null) {
64
62
  fail(`branch already exists: ${branch}`)
65
63
  }
@@ -68,7 +66,7 @@ async function prepareProvision(tower, input) {
68
66
 
69
67
  /** Phase 2 of `Tower.provision` (#51): no state lock, worktree add under the git mutex. */
70
68
  async function actProvision(tower, prepared) {
71
- return withGitLock(tower.paths, async () => {
69
+ return tower.deps.store.withGitLock(tower.paths, async () => {
72
70
  let fresh
73
71
  try {
74
72
  fresh = await tower.load()
@@ -84,7 +82,7 @@ async function actProvision(tower, prepared) {
84
82
  await tower.assertWorktree(mission.worktree)
85
83
  return { phase: 'existing', mission, worktree: mission.worktree }
86
84
  }
87
- if (await exists(prepared.wtPath)) fail(`worktree path already exists: ${prepared.wtPath}`)
85
+ if (await tower.deps.store.exists(prepared.wtPath)) fail(`worktree path already exists: ${prepared.wtPath}`)
88
86
  if ((await tower.tryGit(tower.root, ['show-ref', '--verify', '--quiet', `refs/heads/${prepared.branch}`])) !== null) {
89
87
  fail(`branch already exists: ${prepared.branch}`)
90
88
  }
@@ -95,15 +93,15 @@ async function actProvision(tower, prepared) {
95
93
  branch: prepared.branch,
96
94
  baseCommit: prepared.baseCommit,
97
95
  createdAt: Date.now(),
98
- realPath: await canonicalPath(prepared.wtPath),
96
+ realPath: await tower.deps.ownership.canonicalPath(prepared.wtPath),
99
97
  })
100
98
  return { phase: 'created', worktree }
101
- })
99
+ }, tower.gitMutexOptions({ phase: 'provision' }))
102
100
  }
103
101
 
104
102
  /** Phase 3 of `Tower.provision` (#51): record the worktree under a short lock. */
105
103
  async function commitProvision(tower, prepared, acted) {
106
- return withLock(tower.paths, async () => {
104
+ return tower.deps.store.withLock(tower.paths, async () => {
107
105
  const state = await tower.load()
108
106
  const mission = state.missions.find((entry) => entry.id === prepared.missionId)
109
107
  if (!mission) throw new TowerToolError('mission-not-found', `unknown mission "${prepared.missionId}"`)
@@ -119,11 +117,11 @@ async function commitProvision(tower, prepared, acted) {
119
117
  state.updatedAt = now
120
118
  try {
121
119
  validateState(state, tower.paths.state)
122
- await writeJsonAtomic(tower.paths.state, state)
120
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
123
121
  } catch (error) {
124
122
  return { phase: 'persist-error', error }
125
123
  }
126
- await appendJsonl(tower.paths.activity, {
124
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
127
125
  at: now,
128
126
  type: 'worktree',
129
127
  missionId: prepared.missionId,
@@ -135,10 +133,10 @@ async function commitProvision(tower, prepared, acted) {
135
133
 
136
134
  /** Undo a created worktree/branch outside the state lock (git mutex only). */
137
135
  async function rollbackProvision(tower, prepared) {
138
- await withGitLock(tower.paths, async () => {
136
+ await tower.deps.store.withGitLock(tower.paths, async () => {
139
137
  await tower.git(tower.root, ['worktree', 'remove', '--force', prepared.wtPath]).catch(() => {})
140
138
  await tower.git(tower.root, ['branch', '-D', prepared.branch]).catch(() => {})
141
- })
139
+ }, tower.gitMutexOptions({ phase: 'provision' }))
142
140
  }
143
141
 
144
142
  /** #51 two-phase provision: prepare/act/commit with rollback on rejection. */
package/app/read.js CHANGED
@@ -1,30 +1,89 @@
1
1
  // Read-only dashboard/detail use cases.
2
2
  import { TowerToolError } from '../domain/errors.js'
3
3
  import { diffView, missionDetailView, statusView } from '../domain/read/views.js'
4
- import { readActivityTail } from '../lib/store.js'
4
+ import { archiveView, archivedSummary } from './archive.js'
5
5
 
6
6
  function fail(message, code = 'invalid-argument') {
7
7
  throw new TowerToolError(code, message)
8
8
  }
9
9
 
10
+ /**
11
+ * Full detail for one mission. A mission that was archived is reconstructed
12
+ * from its year file, including the reviews, findings and activity that left
13
+ * state.json with it. An unreadable archive is a diagnostic, never a throw:
14
+ * `tower_status` must keep rendering the hot state.
15
+ */
10
16
  export async function missionDetail(tower, missionId) {
11
17
  const state = await tower.load()
12
18
  const mission = state.missions.find((entry) => entry.id === missionId)
13
- if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
14
- const activity = await readActivityTail(tower.paths.activity, 200)
15
- return missionDetailView({ state, mission, activity })
19
+ const activity = await tower.deps.store.readActivityTail(tower.paths.activity, 200)
20
+ if (mission) return missionDetailView({ state, mission, activity })
21
+
22
+ const archived = await tower.archivedMission(missionId)
23
+ if (archived.ok && archived.entry) {
24
+ const entry = archived.entry
25
+ const hotFindings = (state.findings ?? []).filter((finding) => finding.missionId === missionId)
26
+ const byId = new Map()
27
+ for (const finding of [...(entry.findings ?? []), ...hotFindings]) {
28
+ if (finding && typeof finding.id === 'string') byId.set(finding.id, finding)
29
+ }
30
+ return {
31
+ ...missionDetailView({
32
+ state: /** @type {any} */ ({ reviews: entry.reviews ?? [], findings: [...byId.values()] }),
33
+ mission: /** @type {any} */ (entry.mission),
34
+ activity,
35
+ }),
36
+ archived: true,
37
+ archivedAt: entry.archivedAt,
38
+ archivedYear: entry.archivedYear,
39
+ branch: entry.branch ?? null,
40
+ }
41
+ }
42
+ if (archived.unreadable) {
43
+ return {
44
+ archived: true,
45
+ archivedAt: null,
46
+ branch: null,
47
+ mission: null,
48
+ reviews: [],
49
+ findings: [],
50
+ activity: [],
51
+ error: archived.error ?? `tower archive is unreadable; mission ${missionId} cannot be loaded`,
52
+ }
53
+ }
54
+ return fail(`unknown mission "${missionId}"`, 'mission-not-found')
16
55
  }
17
56
 
18
57
  export async function status(tower, input = {}) {
19
58
  const state = await tower.load()
20
- const activity = await readActivityTail(tower.paths.activity, 12)
59
+ const activity = await tower.deps.store.readActivityTail(tower.paths.activity, 12)
60
+ // The repo-scoped git mutex can be held for a whole merge; expose the
61
+ // holder so "the tower is stuck" has a visible cause (#93).
62
+ const gitLock = tower.deps.store.gitLockView(await tower.deps.store.readGitLock(tower.paths))
63
+ const index = await tower.archiveIndex()
64
+ const activeIds = new Set(state.missions.map((mission) => mission.id))
65
+ const wanted = Array.isArray(input.missionIds) ? new Set(input.missionIds) : null
66
+ // An explicit missionId lookup includes its archived summary; the dashboard
67
+ // includes them only on demand. Archived summaries never carry report bodies.
68
+ const includeArchived = input.includeArchived === true || wanted !== null
69
+ const missions = await tower.enrichedMissions(state, input.missionIds)
70
+ if (includeArchived && index.ok) {
71
+ for (const entry of index.missions) {
72
+ if (activeIds.has(entry.id)) continue
73
+ if (wanted !== null && !wanted.has(entry.id)) continue
74
+ missions.push(archivedSummary(entry))
75
+ }
76
+ }
21
77
  return statusView({
22
78
  state,
23
79
  statePath: tower.paths.state,
24
80
  activity,
25
81
  unreadTower: tower.unreadFor(state, 'tower').length,
26
82
  config: tower.config,
27
- missions: await tower.enrichedMissions(state, input.missionIds),
83
+ missions,
84
+ archivedMissions: index.ok ? index.missions.length : null,
85
+ archive: archiveView(index),
86
+ gitLock,
28
87
  })
29
88
  }
30
89