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/tools/rebase.js CHANGED
@@ -9,15 +9,15 @@
9
9
  // Pure intent/waiver decisions live in domain/integration.
10
10
  // See docs/reference/tools.generated.md for the public shape.
11
11
  // tower_rebase: rebase a mission branch inside its worktree.
12
- import { Tower } from '../app/tower.js'
13
12
  import { SCHEMAS, output } from '../domain/contracts/outputs.js'
14
13
  import { RebaseInput } from '../domain/contracts/tool-inputs/index.js'
15
14
 
16
15
  /**
17
16
  * @param {any} ctx
18
- * @param {{ define: import('../types/index.js').DefineTool, actorFor: any, requireTowerActor: any, observeActivity: any }} deps
17
+ * @param {{ define: import('../types/index.js').DefineTool, openTower: any, actorFor: any, requireTowerActor: any, observeActivity: any }} deps
19
18
  */
20
19
  export function registerRebaseTool(ctx, deps) {
20
+ // openTower is injected by the composition root (#69).
21
21
  const { define, actorFor, requireTowerActor, observeActivity } = deps
22
22
  define(ctx, {
23
23
  name: 'tower_rebase',
@@ -29,7 +29,7 @@ export function registerRebaseTool(ctx, deps) {
29
29
  board: true,
30
30
  output: output(SCHEMAS.rebase),
31
31
  async execute(args, exec) {
32
- const tower = await Tower.open(args.repoRoot)
32
+ const tower = await deps.openTower(args.repoRoot)
33
33
  const actor = await actorFor(tower, exec)
34
34
  requireTowerActor(actor, 'rebase missions')
35
35
  const { activity } = await observeActivity(ctx, tower)
package/tools/recover.js CHANGED
@@ -1,31 +1,39 @@
1
1
  // tower_recover: explicit stale state-lock diagnostics/repair.
2
- import { Tower } from '../app/tower.js'
3
- import { readStateLock } from '../lib/store.js'
4
2
  import { SCHEMAS, output } from '../domain/contracts/outputs.js'
5
3
  import { TowerToolError } from '../domain/errors.js'
6
- import { fsp } from '../adapters/store-fs.js'
7
4
  import { RecoverInput } from '../domain/contracts/tool-inputs/index.js'
8
5
  import { RECOVERY_ACTION, RECOVERY_CONFIRM, ROLE } from '../domain/vocabulary.js'
9
6
 
10
7
  /**
11
8
  * @param {any} ctx
12
- * @param {{ define: import('../types/index.js').DefineTool, actorFor: any, requireTowerActor: any }} deps
9
+ * @param {{ define: import('../types/index.js').DefineTool, openTower: any, actorFor: any, requireTowerActor: any, readStateLock: any, lockTiming: any }} deps
13
10
  */
14
11
  export function registerRecoverTool(ctx, deps) {
15
- const { define, actorFor, requireTowerActor } = deps
12
+ const { define, actorFor, requireTowerActor, readStateLock, lockTiming } = deps
16
13
  define(ctx, {
17
14
  name: 'tower_recover',
18
15
  description:
19
- 'Read-only recovery diagnostics and an explicit stale-state-lock repair. status reports the state lock owner; clear-stale-lock requires confirm:"CLEAR_STALE_LOCK" and only moves a lock whose PID is provably dead to a backup file. It never resets Tower state or worktrees.',
16
+ 'Read-only recovery diagnostics and an explicit stale-state-lock repair. status reports the state lock owner with its age, heartbeat age and staleness; clear-stale-lock requires confirm:"CLEAR_STALE_LOCK" and only moves a lock whose PID is provably dead to a backup file. force:true additionally clears a live-but-silent PID (a recycled PID) and still renames the record to a .recovered-<ts> backup. It never resets Tower state or worktrees.',
20
17
  parameters: RecoverInput,
21
18
  output: output(SCHEMAS.recover),
22
19
  async execute(args, exec) {
23
- const tower = await Tower.open(args.repoRoot)
20
+ const tower = await deps.openTower(args.repoRoot)
24
21
  const actor = await actorFor(tower, exec)
25
22
  requireTowerActor(actor, 'recover Tower state')
26
- const lock = await readStateLock(tower.paths)
23
+ const now = Date.now()
24
+ const lock = await readStateLock(tower.paths, { now })
25
+ const timing = lockTiming(lock.record, { now })
27
26
  const publicLock = lock.record
28
- ? { pid: lock.record.pid ?? null, startedAt: lock.record.startedAt ?? null, at: lock.record.at ?? null, alive: lock.alive }
27
+ ? {
28
+ pid: lock.record.pid ?? null,
29
+ startedAt: lock.record.startedAt ?? null,
30
+ at: lock.record.at ?? null,
31
+ alive: lock.alive,
32
+ ageMs: timing.ageMs,
33
+ heartbeatAgeMs: timing.heartbeatAgeMs,
34
+ staleAfterMs: timing.staleAfterMs,
35
+ stale: timing.stale,
36
+ }
29
37
  : null
30
38
  if (args.action === RECOVERY_ACTION.status) {
31
39
  return { ok: true, action: RECOVERY_ACTION.status, stateLock: publicLock, cleared: false }
@@ -34,12 +42,29 @@ export function registerRecoverTool(ctx, deps) {
34
42
  throw new TowerToolError('invalid-argument', 'clear-stale-lock requires confirm:"CLEAR_STALE_LOCK"')
35
43
  }
36
44
  if (!lock.record) return { ok: true, action: RECOVERY_ACTION.clearStaleLock, stateLock: null, cleared: false }
37
- if (lock.alive !== false) {
38
- throw new TowerToolError('state-locked', `state lock pid ${lock.record.pid ?? ROLE.unknown} is still alive; refusing to clear it`)
45
+ const forced = args.force === true
46
+ if (lock.alive !== false && !forced) {
47
+ // A live PID is not proof of a live writer (#92): report the heartbeat
48
+ // and require the explicit force opt-in before moving the record.
49
+ throw new TowerToolError(
50
+ 'state-locked',
51
+ `state lock pid ${lock.record.pid ?? ROLE.unknown} is still alive (last heartbeat ${timing.heartbeatAgeMs ?? 'unknown'}ms ago, stale after ${timing.staleAfterMs}ms); pass force:true with confirm:"CLEAR_STALE_LOCK" to move it to a backup after confirming no writer is active`,
52
+ )
39
53
  }
54
+ // Always rename, never unlink: the recovered bytes stay auditable. Use the
55
+ // plain port primitive, not `renameWithRetry`: the retrying helper has
56
+ // temp-file semantics and unlinks its source when the rename finally
57
+ // fails, which would destroy the record we are trying to preserve.
40
58
  const backup = `${tower.paths.lock}.recovered-${Date.now()}`
41
- await fsp.rename(tower.paths.lock, backup)
42
- return { ok: true, action: RECOVERY_ACTION.clearStaleLock, stateLock: publicLock, cleared: true, backup }
59
+ await tower.deps.store.rename(tower.paths.lock, backup)
60
+ return {
61
+ ok: true,
62
+ action: RECOVERY_ACTION.clearStaleLock,
63
+ stateLock: publicLock,
64
+ cleared: true,
65
+ backup,
66
+ forced,
67
+ }
43
68
  },
44
69
  })
45
70
  }
package/tools/reload.js CHANGED
@@ -1,17 +1,14 @@
1
1
  // tower_reload: dev-only HMR reload request.
2
- import { Tower } from '../app/tower.js'
3
2
  import { SCHEMAS, output } from '../domain/contracts/outputs.js'
4
3
  import { TowerToolError } from '../domain/errors.js'
5
- import { runtimeConfig } from '../lib/config.js'
6
- import { createHmrBridge } from '../lib/hmr.js'
7
4
  import { ReloadInput } from '../domain/contracts/tool-inputs/index.js'
8
5
 
9
6
  /**
10
7
  * @param {any} ctx
11
- * @param {{ define: import('../types/index.js').DefineTool, actorFor: any, requireTowerActor: any, sharedHmrBridge: any }} deps
8
+ * @param {{ define: import('../types/index.js').DefineTool, openTower: any, actorFor: any, requireTowerActor: any, sharedHmrBridge: any, runtimeConfig: any, createHmrBridge: any }} deps
12
9
  */
13
10
  export function registerReloadTool(ctx, deps) {
14
- const { define, actorFor, requireTowerActor, sharedHmrBridge } = deps
11
+ const { define, actorFor, requireTowerActor, sharedHmrBridge, runtimeConfig, createHmrBridge } = deps
15
12
  // Dev-only hot reload. The tool is registered whenever dev.hotReload is on;
16
13
  // it reports reload-unavailable when the profile has no HMR service. When the
17
14
  // Host passed a shared bridge, its status() reflects the command's reloads too.
@@ -32,7 +29,7 @@ export function registerReloadTool(ctx, deps) {
32
29
  output: output(SCHEMAS.reload),
33
30
  async execute(args, exec) {
34
31
  if (typeof args.repoRoot === 'string') {
35
- const tower = await Tower.open(args.repoRoot)
32
+ const tower = await deps.openTower(args.repoRoot)
36
33
  const actor = await actorFor(tower, exec)
37
34
  requireTowerActor(actor, 'reload the Tower plugin')
38
35
  }
package/tools/resource.js CHANGED
@@ -1,17 +1,15 @@
1
1
  // tower_resource: advisory resource leases.
2
- import { Tower } from '../app/tower.js'
3
2
  import { SCHEMAS, output } from '../domain/contracts/outputs.js'
4
3
  import { TowerToolError } from '../domain/errors.js'
5
- import { acquireLease, releaseLease, inspectLeases } from '../lib/leases.js'
6
4
  import { ResourceInput } from '../domain/contracts/tool-inputs/index.js'
7
5
  import { LEASE_ACTION } from '../domain/vocabulary.js'
8
6
 
9
7
  /**
10
8
  * @param {any} ctx
11
- * @param {{ define: import('../types/index.js').DefineTool, actorFor: any, requireTowerActor: any }} deps
9
+ * @param {{ define: import('../types/index.js').DefineTool, openTower: any, actorFor: any, requireTowerActor: any, acquireLease: any, releaseLease: any, inspectLeases: any }} deps
12
10
  */
13
11
  export function registerResourceTool(ctx, deps) {
14
- const { define, actorFor, requireTowerActor } = deps
12
+ const { define, actorFor, requireTowerActor, acquireLease, releaseLease, inspectLeases } = deps
15
13
  define(ctx, {
16
14
  name: 'tower_resource',
17
15
  description:
@@ -19,7 +17,7 @@ export function registerResourceTool(ctx, deps) {
19
17
  parameters: ResourceInput,
20
18
  output: output(SCHEMAS.resource),
21
19
  async execute(args, exec) {
22
- const tower = await Tower.open(args.repoRoot)
20
+ const tower = await deps.openTower(args.repoRoot)
23
21
  const actor = await actorFor(tower, exec)
24
22
  const redact = (leases) => leases.map(({ token, ...lease }) => lease)
25
23
  if (args.action === LEASE_ACTION.status) {
package/tools/review.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // tower_review: independent review verdict.
2
- import { Tower } from '../app/tower.js'
3
2
  import { SCHEMAS, output } from '../domain/contracts/outputs.js'
4
3
  import { TowerToolError } from '../domain/errors.js'
5
4
  import { ReviewInput } from '../domain/contracts/tool-inputs/index.js'
@@ -7,21 +6,21 @@ import { isReviewerRole, isTowerRole } from '../domain/vocabulary.js'
7
6
 
8
7
  /**
9
8
  * @param {any} ctx
10
- * @param {{ define: import('../types/index.js').DefineTool, actorFor: any }} deps
9
+ * @param {{ define: import('../types/index.js').DefineTool, openTower: any, actorFor: any }} deps
11
10
  */
12
11
  export function registerReviewTool(ctx, deps) {
13
12
  const { define, actorFor } = deps
14
13
  define(ctx, {
15
14
  name: 'tower_review',
16
15
  description:
17
- 'Submit an independent review verdict for a completed mission, bound to the current branch tip. status is "clean", "p1-Nitems", or "p2-Nitems"; merge is "merge", "fix-then-merge", or "hold" (only clean + merge is later approval). Non-clean returns the mission to active. Reviewers are capped at 5 rounds per branch; a tower-submitted review is break-glass and requires breakGlassReason.',
16
+ 'Submit an independent review verdict bound to the current branch tip: a completed mission starts a review round, and while the mission is active a later route may still record a verdict for that same reviewed tip, appending to the same round without changing the status (a moved tip is rejected with an explanation). status is "clean", "p1-Nitems", or "p2-Nitems"; merge is "merge", "fix-then-merge", or "hold" (only clean + merge is later approval). Non-clean returns the mission to active. Reviewers are capped at 5 rounds per branch; a tower-submitted review is break-glass and requires breakGlassReason.',
18
17
  parameters: ReviewInput,
19
18
  aliases: {"missionId":["id"]},
20
19
  requiresMissionId: true,
21
20
  board: true,
22
21
  output: output(SCHEMAS.review),
23
22
  async execute(args, exec) {
24
- const tower = await Tower.open(args.repoRoot)
23
+ const tower = await deps.openTower(args.repoRoot)
25
24
  const actor = await actorFor(tower, exec)
26
25
  if (!isReviewerRole(actor.role) && !isTowerRole(actor.role)) {
27
26
  throw new TowerToolError('not-authorized', `only an assigned reviewer or the tower may review; ${actor.name} is ${actor.role}`)
package/tools/roster.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // tower_roster: authoritative roster activity.
2
- import { Tower } from '../app/tower.js'
3
2
  import { SCHEMAS, output } from '../domain/contracts/outputs.js'
4
3
  import { TowerToolError } from '../domain/errors.js'
5
4
  import { RosterInput } from '../domain/contracts/tool-inputs/index.js'
@@ -7,7 +6,7 @@ import { PARENT_STATUS_OK, ROSTER_ACTIVITY, ROSTER_GONE_STATES } from '../domain
7
6
 
8
7
  /**
9
8
  * @param {any} ctx
10
- * @param {{ define: import('../types/index.js').DefineTool }} deps
9
+ * @param {{ define: import('../types/index.js').DefineTool, openTower: any }} deps
11
10
  */
12
11
  export function registerRosterTool(ctx, deps) {
13
12
  const { define } = deps
@@ -18,7 +17,7 @@ export function registerRosterTool(ctx, deps) {
18
17
  parameters: RosterInput,
19
18
  output: output(SCHEMAS.roster),
20
19
  async execute(args) {
21
- const tower = await Tower.open(args.repoRoot)
20
+ const tower = await deps.openTower(args.repoRoot)
22
21
  const view = await tower.rosterView()
23
22
  const subagents = ctx.get('subagents')
24
23
  if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot observe roster')
package/tools/send.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // tower_send: durable inbox message delivery.
2
- import { Tower } from '../app/tower.js'
3
2
  import { SCHEMAS, output } from '../domain/contracts/outputs.js'
4
3
  import { TowerToolError } from '../domain/errors.js'
5
4
  import { SendInput } from '../domain/contracts/tool-inputs/index.js'
@@ -17,6 +16,7 @@ import {
17
16
  * @param {any} ctx
18
17
  * @param {{
19
18
  * define: import('../types/index.js').DefineTool,
19
+ * openTower: any,
20
20
  * actorFor: any,
21
21
  * checkSendRate: any,
22
22
  * }} deps
@@ -31,7 +31,7 @@ export function registerSendTool(ctx, deps) {
31
31
  aliases: { consentRef: ['consent_ref'] },
32
32
  output: output(SCHEMAS.send),
33
33
  async execute(args, exec) {
34
- const tower = await Tower.open(args.repoRoot)
34
+ const tower = await deps.openTower(args.repoRoot)
35
35
  checkSendRate(tower.config, args.repoRoot)
36
36
  const actor = await actorFor(tower, exec)
37
37
  const view = await tower.rosterView()
package/tools/spawn.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // tower_spawn: provision and start a worker/reviewer.
2
- import { Tower } from '../app/tower.js'
3
2
  import { SCHEMAS, output } from '../domain/contracts/outputs.js'
4
3
  import { TowerToolError, classifyError } from '../domain/errors.js'
5
4
  import { SpawnInput } from '../domain/contracts/tool-inputs/index.js'
@@ -9,13 +8,14 @@ import {
9
8
  precheckSpawnServices,
10
9
  precheckWorkerOwnership,
11
10
  resolveRosterName,
12
- } from '../lib/spawn-prechecks.js'
13
- import { buildAgentOptions, buildSpawnBriefing, waitForSpawnResult } from '../lib/spawn-support.js'
11
+ } from '../app/spawn-prechecks.js'
12
+ import { buildAgentOptions, buildSpawnBriefing, waitForSpawnResult } from '../app/spawn-support.js'
14
13
 
15
14
  /**
16
15
  * @param {any} ctx
17
16
  * @param {{
18
17
  * define: import('../types/index.js').DefineTool,
18
+ * openTower: any,
19
19
  * actorFor: any,
20
20
  * requireTowerActor: any,
21
21
  * enqueueSpawn: any,
@@ -25,6 +25,7 @@ import { buildAgentOptions, buildSpawnBriefing, waitForSpawnResult } from '../li
25
25
  * recordSpawnSuccess: any,
26
26
  * runMissionDiscovery: any,
27
27
  * textOf: any,
28
+ * inspectLeases: any,
28
29
  * }} deps
29
30
  */
30
31
  export function registerSpawnTool(ctx, deps) {
@@ -43,7 +44,7 @@ export function registerSpawnTool(ctx, deps) {
43
44
  board: true,
44
45
  output: output(SCHEMAS.spawn),
45
46
  async execute(args, exec) {
46
- const tower = await Tower.open(args.repoRoot)
47
+ const tower = await deps.openTower(args.repoRoot)
47
48
  const actor = await actorFor(tower, exec)
48
49
  requireTowerActor(actor, 'spawn tower agents')
49
50
  const role = args.role ?? DEFAULT_ROLE
@@ -68,7 +69,9 @@ export function registerSpawnTool(ctx, deps) {
68
69
  role, tower, ctx, view, missionId: args.missionId, takeover: args.takeover,
69
70
  maxLiveWorkers: config.spawn.maxLiveWorkers, budget: backoff.budget, observeActivity,
70
71
  })
71
- const { subagents, provider } = await precheckSpawnServices({ tower, ctx, config, preset, actor, exec })
72
+ const { subagents, provider } = await precheckSpawnServices({
73
+ tower, ctx, config, preset, actor, exec, inspectLeases: deps.inspectLeases,
74
+ })
72
75
 
73
76
  const provisioned = await tower.provision({
74
77
  missionId: args.missionId,
package/tools/status.js CHANGED
@@ -1,14 +1,15 @@
1
1
  // tower_status: dashboard and mission detail.
2
- import { Tower } from '../app/tower.js'
3
2
  import { SCHEMAS, output } from '../domain/contracts/outputs.js'
4
3
  import { classifyError } from '../domain/errors.js'
5
4
  import { StatusInput } from '../domain/contracts/tool-inputs/index.js'
6
5
  import { ROSTER_INACTIVE_STATES, isMarkdownFormat, isReservedState, isWorkerRole } from '../domain/vocabulary.js'
6
+ import { archiveView } from '../app/archive.js'
7
7
 
8
8
  /**
9
9
  * @param {any} ctx
10
10
  * @param {{
11
11
  * define: import('../types/index.js').DefineTool,
12
+ * openTower: any,
12
13
  * workspaceHook: any,
13
14
  * spawnBackoffView: any,
14
15
  * observeActivity: any,
@@ -19,12 +20,12 @@ export function registerStatusTool(ctx, deps) {
19
20
  define(ctx, {
20
21
  name: 'tower_status',
21
22
  description:
22
- 'Read the Tower dashboard for a repository: state path, base branch, revision, mission status counts, paged mission summaries (scope/deps/tasks/worktree/owner), roster size, findings, config path and the activity tail. Pass missionId for one full mission (tasks, notes, reviews, findings, completion report); offset/limit page the mission list (default 20, max 100); includeGate adds preflight gate digests for the page; format:"markdown" returns the human-readable mission board. Read-only.',
23
+ 'Read the Tower dashboard for a repository: state path, base branch, revision, mission status counts, paged mission summaries (scope/deps/tasks/worktree/owner), roster size, findings, archive count, config path and the activity tail. Also reports the repo-scoped git lock holder (`gitLock: {held,pid,phase,heldMs}`) so a merge holding the mutex is visible. Pass missionId for one full mission (tasks, notes, reviews, findings, completion report; archived missions are loaded from their year file); offset/limit page the mission list (default 20, max 100); includeGate adds preflight gate digests for the page; includeArchived appends compact archived summaries after the active page; format:"markdown" returns the human-readable mission board (active missions plus an archived count). Read-only.',
23
24
  parameters: StatusInput,
24
- aliases: { mission_id: ['missionId'], include_gate: ['includeGate'] },
25
+ aliases: { mission_id: ['missionId'], include_gate: ['includeGate'], include_archived: ['includeArchived'] },
25
26
  output: output(SCHEMAS.status),
26
27
  async execute(args, exec) {
27
- const tower = await Tower.open(args.repoRoot)
28
+ const tower = await deps.openTower(args.repoRoot)
28
29
  const boardPath = tower.boardFile()
29
30
  const mode = workspaceHook?.modeInfo?.(exec) ?? null
30
31
  const statusState = await tower.load()
@@ -48,11 +49,18 @@ export function registerStatusTool(ctx, deps) {
48
49
  }
49
50
  }
50
51
  const state = await tower.load()
51
- const total = state.missions.length
52
+ const index = await tower.archiveIndex()
53
+ const activeIds = state.missions.map((mission) => mission.id)
54
+ const archivedIds = index.ok
55
+ ? index.missions.map((entry) => entry.id).filter((id) => !activeIds.includes(id))
56
+ : []
57
+ // Hot state pages first, then archived summaries on demand (#73).
58
+ const ids = args.includeArchived === true ? [...activeIds, ...archivedIds] : activeIds
59
+ const total = ids.length
52
60
  const offset = typeof args.offset === 'number' && Number.isInteger(args.offset) && args.offset > 0 ? args.offset : 0
53
61
  const limit = typeof args.limit === 'number' && Number.isInteger(args.limit) ? Math.max(1, Math.min(100, args.limit)) : 20
54
- const pageIds = state.missions.slice(offset, offset + limit).map((mission) => mission.id)
55
- const status = await tower.status({ missionIds: pageIds })
62
+ const pageIds = ids.slice(offset, offset + limit)
63
+ const status = await tower.status({ missionIds: pageIds, includeArchived: args.includeArchived === true })
56
64
  let page = status.missions
57
65
  if (args.includeGate === true) {
58
66
  const { activity, parentStatus } = await observeActivity(ctx, tower)
@@ -86,7 +94,7 @@ export function registerStatusTool(ctx, deps) {
86
94
  if (isMarkdownFormat(args.format)) {
87
95
  const state = await tower.load()
88
96
  const output = /** @type {any} */ (result)
89
- output.markdown = tower.boardMarkdown(state)
97
+ output.markdown = tower.boardMarkdown(state, archiveView(index))
90
98
  }
91
99
  return result
92
100
  },
package/tools/teardown.js CHANGED
@@ -8,26 +8,25 @@
8
8
  // Pure eligibility decisions live in domain/integration.
9
9
  // See docs/reference/tools.generated.md for the public shape.
10
10
  // tower_teardown: remove eligible mission worktrees.
11
- import { Tower } from '../app/tower.js'
12
11
  import { SCHEMAS, output } from '../domain/contracts/outputs.js'
13
12
  import { TeardownInput } from '../domain/contracts/tool-inputs/index.js'
14
13
 
15
14
  /**
16
15
  * @param {any} ctx
17
- * @param {{ define: import('../types/index.js').DefineTool, actorFor: any, requireTowerActor: any, observeActivity: any }} deps
16
+ * @param {{ define: import('../types/index.js').DefineTool, openTower: any, actorFor: any, requireTowerActor: any, observeActivity: any }} deps
18
17
  */
19
18
  export function registerTeardownTool(ctx, deps) {
20
19
  const { define, actorFor, requireTowerActor, observeActivity } = deps
21
20
  define(ctx, {
22
21
  name: 'tower_teardown',
23
22
  description:
24
- 'Tear down eligible mission worktrees after their missions are merged or abandoned. A worktree whose roster agent is running is always kept (force cannot override); dirty worktrees need force; exclude keeps named missions. dry_run previews the remove/keep decisions without changing anything.',
23
+ 'Tear down eligible mission worktrees after their missions are merged or abandoned. A worktree whose roster agent is running is always kept (force cannot override); dirty worktrees need force; exclude keeps named missions. dry_run previews the remove/keep decisions without changing anything. Terminal missions whose worktree is gone and whose configured archive policy is due (archive.afterDays / archive.maxMissions) are moved to <git-common-dir>/dsh-tower/archive/ so state.json stops rewriting them; nothing is deleted.',
25
24
  parameters: TeardownInput,
26
25
  aliases: {"dry_run":["dryRun"]},
27
26
  board: true,
28
27
  output: output(SCHEMAS.teardown),
29
28
  async execute(args, exec) {
30
- const tower = await Tower.open(args.repoRoot)
29
+ const tower = await deps.openTower(args.repoRoot)
31
30
  const actor = await actorFor(tower, exec)
32
31
  requireTowerActor(actor, 'tear down worktrees')
33
32
  const { activity } = await observeActivity(ctx, tower)
package/tools/test.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // tower_test: run declared worktree test discovery.
2
- import { Tower } from '../app/tower.js'
3
2
  import { SCHEMAS, output } from '../domain/contracts/outputs.js'
4
3
  import { TowerToolError } from '../domain/errors.js'
5
4
  import { TestInput } from '../domain/contracts/tool-inputs/index.js'
@@ -9,6 +8,7 @@ import { isReviewerRole, isWorkerRole } from '../domain/vocabulary.js'
9
8
  * @param {any} ctx
10
9
  * @param {{
11
10
  * define: import('../types/index.js').DefineTool,
11
+ * openTower: any,
12
12
  * actorFor: any,
13
13
  * requireKnownActor: any,
14
14
  * runMissionDiscovery: any,
@@ -25,7 +25,7 @@ export function registerTestTool(ctx, deps) {
25
25
  requiresMissionId: true,
26
26
  output: output(SCHEMAS.test),
27
27
  async execute(args, exec) {
28
- const tower = await Tower.open(args.repoRoot)
28
+ const tower = await deps.openTower(args.repoRoot)
29
29
  const actor = await actorFor(tower, exec)
30
30
  requireKnownActor(actor, 'tower_test')
31
31
  const context = await tower.missionContext(args.missionId)
package/types/index.d.ts CHANGED
@@ -99,12 +99,14 @@ export interface CompletionCheck {
99
99
 
100
100
  export interface CompletionReport {
101
101
  report?: string
102
+ /** UTF-8 byte length of `report`; absent on documents written before #89. */
103
+ reportBytes?: number
102
104
  by?: string
103
105
  at?: number
104
106
  checks?: CompletionCheck[]
105
107
  }
106
108
 
107
- /** One recorded test-discovery receipt (lib/tests.js). */
109
+ /** One recorded test-discovery receipt (adapters/tests.js). */
108
110
  export interface TestRun {
109
111
  version?: number
110
112
  ok?: boolean
@@ -153,6 +155,34 @@ export interface Mission {
153
155
  authorizationRef?: string | null
154
156
  }
155
157
 
158
+ /**
159
+ * Compact archived-mission entry (`archive/index.json`). It keeps enough of the
160
+ * mission for ordering, dependency lookups and id allocation without loading the
161
+ * full year file, and it is what `tower_status includeArchived` pages from (#73).
162
+ */
163
+ export interface ArchiveIndexEntry {
164
+ id: string
165
+ title: string
166
+ status: MissionStatus
167
+ kind?: MissionKind
168
+ scope?: string[]
169
+ deps?: string[]
170
+ owner?: string | null
171
+ branch?: string | null
172
+ mergedCommit?: string | null
173
+ mergedAt?: number | null
174
+ updatedAt?: number | null
175
+ archivedAt: number
176
+ archivedYear: number
177
+ }
178
+
179
+ /** Full archived record (`archive/<year>.json`): mission + linked history. */
180
+ export interface ArchivedMissionRecord extends ArchiveIndexEntry {
181
+ mission: Mission
182
+ reviews: Review[]
183
+ findings: Finding[]
184
+ }
185
+
156
186
  export interface Review {
157
187
  id?: string
158
188
  seq?: number
@@ -220,6 +250,8 @@ export interface TowerState {
220
250
  createdAt?: number
221
251
  updatedAt?: number
222
252
  dirtyBase?: boolean
253
+ /** Detected once at tower_init; null means "not detected yet" (#90). */
254
+ caseInsensitive?: boolean | null
223
255
  towerAgentId?: string | null
224
256
  snapshotCommit?: string | null
225
257
  snapshotRef?: string | null
@@ -268,6 +300,8 @@ export interface MissionSummary {
268
300
  reviewPolicy: ReviewPolicy
269
301
  mergedCommit: string | null
270
302
  mergedAt: number | null
303
+ /** Size of the completion report without loading its body; null when absent. */
304
+ reportBytes: number | null
271
305
  updatedAt?: number
272
306
  }
273
307
 
@@ -1,6 +1,6 @@
1
1
  # Tower UI (optional Client bundle)
2
2
 
3
- An **optional** Web Client bundle for the Host `dsh-tower` plugin. It never touches Tower state directly: everything it renders is derived from the conversation the user is already looking at, or from the tool results already in that conversation. There is no Host → Client remote channel, no `host.call`, no DOM manipulation, no iframe, and no import of Harness Client packages (the only module it requires is `react` from the browser module table).
3
+ An **optional** Web Client bundle for the Host `dsh-tower` plugin. It never touches Tower state directly: everything it renders is derived from the conversation the user is already looking at, or from the tool results already in that conversation. There is no Host → Client remote channel, no `host.call`, no DOM manipulation, no iframe, and no import of Harness Client packages (the only browser module-table word it requires is `react`).
4
4
 
5
5
  Version: **0.2.2** (private, not published). The Host plugin version is unchanged.
6
6
 
@@ -20,6 +20,39 @@ The cards are deliberately compact: mission status/counts, task checklist progre
20
20
 
21
21
  The overlay strip is `pointer-events: none` end-to-end and uses `role="status"` / `aria-live="polite"`; it never blocks the composer or the page.
22
22
 
23
+ ## Source layout (#71)
24
+
25
+ The bundle is split along the surfaces above. `client.js` is the entry: it owns
26
+ the diagnostics seam and the registrations, and loads the package-local chunks
27
+ through the DSH client module system's `require.async("./client.<surface>.js")`
28
+ (bundles execute as classic scripts, so the entry cannot require a sibling
29
+ synchronously). Every chunk registers through the same `window.__ModuleLoader__`
30
+ surface as `{ id: 'dsh-tower-ui', chunk: 'client.<surface>.js', factory }` and
31
+ receives the shared helpers/styles/locales through the `env` the entry composes;
32
+ no chunk reaches the Host or another chunk.
33
+
34
+ | File | Surface |
35
+ |---|---|
36
+ | `client.js` | entry: diagnostics, `apply`, the five registrations |
37
+ | `client.toolview.js` | toolview card: lifecycle + `TowerToolCard` |
38
+ | `client.cards.js` | the per-tool card models behind the card |
39
+ | `client.dock.js` | composer dock: mode chip, unknown state, latest summary |
40
+ | `client.mode.js` | history-derived mode model + observable |
41
+ | `client.denied.js` | mode-aware `Tower mode · disabled` rows |
42
+ | `client.overlay.js` | transient `shell.overlay` status strip + notice store |
43
+ | `client.locales.js` | `en`/`zh` dictionaries and the `t`-seat helpers |
44
+ | `client.styles.js` | the `--dsw-alias-*` inline style table |
45
+ | `client.helpers.js` | tool vocabulary + defensive payload helpers |
46
+ | `index.js` | Host half (empty `apply`) |
47
+
48
+ The client tier is gated by `scripts/architecture.mjs` like any other source
49
+ root: `client-no-node` (no `node:*` imports; DOM globals are fine),
50
+ `client-size` (800-line ceiling per file) and `client-ambient` (no module-level
51
+ `let`; state lives in the `create*` closure), each with a seeded fixture in
52
+ `tests/architecture.test.mjs`. Every client source file stays under 400 lines
53
+ and the split preserves the pre-split line total within 10%
54
+ (`tests/ui-bundle.test.mjs`).
55
+
23
56
  ## Persistent Tower-mode indicator (#11)
24
57
 
25
58
  Tower mode is a Host-side session stance and there is no Client RPC for it. The dock derives it from the durable session history that the Chat projection exposes in `useChat(snapshot.legacy)`:
@@ -38,7 +71,7 @@ The Host guard returns a plain tool error like `tower mode: <tool> is disabled w
38
71
 
39
72
  While the displayed session is in Tower mode the bundle dynamically registers `tool.call.toolview` entries for:
40
73
 
41
- - `tower_mode.deniedWhileActive` when the latest `tower_mode` output lists it, otherwise `lib/config.js` `DEFAULT_DENY_TOOLS` (`create_goal`, `todo_write`, `team_task_create`, `team_task_update`, `team_task_reopen`, `team_task_list`, `spawn_teammate`, `wait_agent`, `workflow`, `ralph`, `subagent`, `subagent_fork`);
74
+ - `tower_mode.deniedWhileActive` when the latest `tower_mode` output lists it, otherwise `host/config.js` `DEFAULT_DENY_TOOLS` (`create_goal`, `todo_write`, `team_task_create`, `team_task_update`, `team_task_reopen`, `team_task_list`, `spawn_teammate`, `wait_agent`, `workflow`, `ralph`, `subagent`, `subagent_fork`);
42
75
  - `update_goal` unconditionally, because the guard special-cases its `action: "resume"`.
43
76
 
44
77
  Those entries register with `priority: -10`. The slot core renders the lowest priority for a key, so this shadows the native keyed views without replacing them; disposing the entries (mode off) restores the native views exactly. A denial result renders as a distinct **`Tower mode · disabled`** row (warn marker, label, the preserved denial message, and the `/tower off` hint) — not the generic error text styling. `update_goal` resume denials get the same row. A non-denial result that lands in one of those slots while the mode is on renders a defensive generic row (there is no native view to delegate to while the registration shadows it); ordinary tool errors are not misclassified as Tower-mode denials.
@@ -56,7 +89,7 @@ Verified **statically** against the installed DSH **0.2.1-rc.2** sources:
56
89
  - Locale: `ctx.locale.register(ns, { en, zh })`, dictionaries are flat `key → template` maps with `{param}` interpolation; a slot declaration's `locale` option supplies the bound `t` seat.
57
90
  - The bundle's `dsh.client.inject` orders activation after `ui-conversation` and `ui-tool`, the two packages that declare the slots it targets. `ctx.slots.inject` only installs a registration while its owner declaration exists; each injection is guarded and reported in diagnostics instead of crashing the whole Client half.
58
91
 
59
- Automated offline coverage: `tests/ui-bundle.test.mjs` loads `client.js` through a fake `window.__ModuleLoader__`, asserts the 12 keys / dock id / overlay id, en–zh dictionary parity, theme-token discipline, and renders every card plus the dock/overlay components with fake hooks. It also drives fake chat history through the dock to assert the off hint, the on chip (including refresh re-derivation), `tower_mode`/`tower_control`/command updates, dynamic registration for default and configured deny lists, disposal on mode off, `update_goal`, denial-row treatment, generic fallbacks, and locale parity — no browser required.
92
+ Automated offline coverage: `tests/ui-bundle.test.mjs` loads `client.js` through a fake `window.__ModuleLoader__` (and each split chunk through the same loader when the entry calls `require.async`), asserts the 12 keys / dock id / overlay id, en–zh dictionary parity, theme-token discipline, every file's loader format and the split-size budget, and renders every card plus the dock/overlay components with fake hooks. It also drives fake chat history through the dock to assert the off hint, the on chip (including refresh re-derivation), `tower_mode`/`tower_control`/command updates, dynamic registration for default and configured deny lists, disposal on mode off, `update_goal`, denial-row treatment, generic fallbacks, and locale parity — no browser required.
60
93
 
61
94
  Live/browser verification is **opt-in** (`tests/live/ui-slots.cjs`) because it needs an isolated profile with the bundle installed. From a teammate/subagent session the Client `Slots` inspect query times out by design (the page answers only the agent whose session it displays), so this phase did not run a live inspection; the source-level contract above is the evidence. When the script runs against an installed profile it binds a session with a bare `/tower` command and asserts, through the read-only `window.__TOWER_UI_DIAG__` report, that apply() ran without errors, that all 12 toolview keys and the dock/overlay entries registered, that the dock outlet is visible, and — since the bind command turns the Host mode on — that the history-derived `Tower · on` chip rendered (#11). The chip can be inspected visually in the same run (`data-tower-ui="dock-mode"`).
62
95
 
@@ -65,6 +98,7 @@ Live/browser verification is **opt-in** (`tests/live/ui-slots.cjs`) because it n
65
98
  ```powershell
66
99
  # offline, no browser
67
100
  node --check ui-bundle/client.js
101
+ Get-ChildItem ui-bundle -Filter 'client*.js' | ForEach-Object { node --check $_.FullName }
68
102
  node --test tests/ui-bundle.test.mjs
69
103
 
70
104
  # opt-in live check (isolated profile only)