dsh-tower 2.0.3 → 2.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +59 -16
  5. package/README.md +57 -16
  6. package/SECURITY.md +31 -1
  7. package/adapters/default.js +10 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/batch.js +79 -0
  11. package/app/board.js +25 -0
  12. package/app/checkCompletion.js +76 -0
  13. package/app/enrichedMissions.js +26 -0
  14. package/app/findings.js +78 -0
  15. package/app/gate.js +108 -0
  16. package/app/init.js +249 -0
  17. package/app/merge.js +567 -0
  18. package/app/messages.js +66 -0
  19. package/app/mission.js +35 -0
  20. package/app/missionContext.js +14 -0
  21. package/app/plan.js +42 -0
  22. package/app/provision.js +193 -0
  23. package/app/read.js +57 -0
  24. package/app/rebase.js +302 -0
  25. package/app/recording.js +72 -0
  26. package/app/review.js +145 -0
  27. package/app/roster.js +123 -0
  28. package/app/spawn.js +125 -0
  29. package/app/teardown.js +184 -0
  30. package/app/tower.js +524 -0
  31. package/cordis.patch.yml +61 -53
  32. package/docs/README.md +33 -1
  33. package/docs/architecture/overview.md +114 -14
  34. package/docs/board.md +72 -0
  35. package/docs/examples/first-mission.md +2 -1
  36. package/docs/faq.md +11 -7
  37. package/docs/guide/hot-reload.md +1 -1
  38. package/docs/guide/install-upgrade-migration.md +22 -6
  39. package/docs/guide/quickstart.md +12 -7
  40. package/docs/guide/release.md +125 -0
  41. package/docs/guide/scenarios-and-philosophy.md +86 -69
  42. package/docs/operations/security.md +65 -14
  43. package/docs/reference/blockers.md +11 -10
  44. package/docs/reference/config.generated.md +66 -0
  45. package/docs/reference/config.md +11 -7
  46. package/docs/reference/errors.generated.md +39 -0
  47. package/docs/reference/errors.md +10 -2
  48. package/docs/reference/events.md +7 -4
  49. package/docs/reference/testing.md +2 -1
  50. package/docs/reference/tools.generated.md +33 -0
  51. package/docs/reference/tools.md +8 -7
  52. package/docs/zh/README.md +4 -3
  53. package/docs/zh/faq.md +9 -6
  54. package/docs/zh/guide/hot-reload.md +1 -1
  55. package/docs/zh/guide/install-upgrade-migration.md +17 -5
  56. package/docs/zh/guide/quickstart.md +13 -8
  57. package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
  58. package/docs/zh/operations/security.md +43 -7
  59. package/docs/zh/reference/blockers.md +10 -10
  60. package/docs/zh/reference/config.md +11 -7
  61. package/docs/zh/reference/errors.md +4 -1
  62. package/docs/zh/reference/events.md +6 -3
  63. package/docs/zh/reference/testing.md +3 -2
  64. package/docs/zh/reference/tools.md +8 -7
  65. package/domain/collab/batch.js +112 -0
  66. package/domain/collab/messages.js +127 -0
  67. package/domain/collab/roster.js +158 -0
  68. package/domain/collab/spawn.js +188 -0
  69. package/domain/config/schema.js +404 -0
  70. package/domain/contracts/outputs.js +279 -0
  71. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  72. package/domain/contracts/tool-inputs/findings.js +29 -0
  73. package/domain/contracts/tool-inputs/index.js +94 -0
  74. package/domain/contracts/tool-inputs/integration.js +37 -0
  75. package/domain/contracts/tool-inputs/missions.js +94 -0
  76. package/domain/contracts/tool-inputs/ops.js +75 -0
  77. package/domain/contracts/tool-inputs/shared.js +25 -0
  78. package/domain/errors.js +96 -0
  79. package/domain/gate.js +125 -0
  80. package/domain/integration/gate.js +176 -0
  81. package/domain/integration/init.js +225 -0
  82. package/domain/integration/liveness.js +31 -0
  83. package/domain/integration/merge.js +377 -0
  84. package/domain/integration/provision.js +124 -0
  85. package/domain/integration/rebase.js +236 -0
  86. package/domain/integration/review.js +52 -0
  87. package/domain/integration/teardown.js +118 -0
  88. package/domain/mission.js +68 -0
  89. package/domain/missions/completion.js +114 -0
  90. package/domain/missions/findings.js +201 -0
  91. package/domain/missions/lifecycle.js +185 -0
  92. package/domain/missions/plan.js +149 -0
  93. package/domain/missions/review.js +179 -0
  94. package/domain/planning.js +118 -0
  95. package/domain/read/enrichedMissions.js +58 -0
  96. package/domain/read/missionContext.js +53 -0
  97. package/domain/read/views.js +128 -0
  98. package/domain/report.js +59 -0
  99. package/domain/roster.js +40 -0
  100. package/domain/scope.js +105 -0
  101. package/domain/state/schema.js +233 -0
  102. package/domain/state/semantics.js +117 -0
  103. package/domain/state.js +33 -0
  104. package/domain/summary.js +114 -0
  105. package/domain/vocabulary.js +179 -0
  106. package/index.js +86 -34
  107. package/lib/briefing.js +176 -0
  108. package/lib/compatibility.js +108 -0
  109. package/lib/config.js +54 -302
  110. package/lib/doctor.js +108 -9
  111. package/lib/git.js +93 -15
  112. package/lib/hmr.js +4 -3
  113. package/lib/leases.js +3 -2
  114. package/lib/legacy.js +20 -9
  115. package/lib/modeState.js +39 -0
  116. package/lib/modeStore.js +35 -33
  117. package/lib/persistence.js +349 -0
  118. package/lib/presets.js +35 -0
  119. package/lib/spawn-prechecks.js +168 -0
  120. package/lib/spawn-support.js +79 -0
  121. package/lib/store.js +57 -85
  122. package/lib/tests.js +46 -11
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +46 -22
  125. package/lib/worktreeOwnership.js +5 -4
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +33 -4
  129. package/ports/clock.js +6 -0
  130. package/ports/ids.js +7 -0
  131. package/ports/index.js +2 -0
  132. package/ports/store.js +11 -0
  133. package/ports/vcs.js +11 -0
  134. package/scripts/architecture.mjs +94 -0
  135. package/scripts/check-docs.mjs +44 -3
  136. package/scripts/check-pack.mjs +49 -0
  137. package/scripts/domain-metrics.mjs +48 -0
  138. package/scripts/generate-config-reference.mjs +46 -0
  139. package/scripts/generate-error-reference.mjs +29 -0
  140. package/scripts/generate-tools-reference.mjs +56 -0
  141. package/scripts/issue-audit.mjs +259 -0
  142. package/scripts/lib/cli.mjs +76 -0
  143. package/scripts/lib/tmp.mjs +20 -0
  144. package/scripts/mount-smoke/fixtures.mjs +162 -0
  145. package/scripts/mount-smoke/host.json +4 -0
  146. package/scripts/mount-smoke/probe.mjs +125 -0
  147. package/scripts/mount-smoke/surface.mjs +141 -0
  148. package/scripts/mount-smoke.mjs +270 -0
  149. package/scripts/pack-manifest.txt +203 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/skills/tower-recovery.md +7 -5
  152. package/tools/adopt.js +72 -0
  153. package/tools/batch.js +78 -0
  154. package/tools/complete.js +53 -0
  155. package/tools/diff.js +39 -0
  156. package/tools/doctor.js +50 -0
  157. package/tools/finding.js +40 -0
  158. package/tools/findings.js +39 -0
  159. package/tools/import-legacy.js +42 -0
  160. package/tools/inbox.js +39 -0
  161. package/tools/index.js +39 -0
  162. package/tools/init.js +63 -0
  163. package/tools/interrupt.js +42 -0
  164. package/tools/legacy-map.js +44 -0
  165. package/tools/merge.js +39 -0
  166. package/tools/mission.js +41 -0
  167. package/tools/plan.js +39 -0
  168. package/tools/preflight.js +44 -0
  169. package/tools/rebase.js +39 -0
  170. package/tools/recover.js +45 -0
  171. package/tools/reload.js +52 -0
  172. package/tools/resource.js +54 -0
  173. package/tools/review.js +45 -0
  174. package/tools/roster.js +60 -0
  175. package/tools/send.js +147 -0
  176. package/tools/spawn.js +196 -0
  177. package/tools/status.js +94 -0
  178. package/tools/teardown.js +42 -0
  179. package/tools/test.js +61 -0
  180. package/types/cordis.d.ts +6 -0
  181. package/types/index.d.ts +339 -0
  182. package/ui-bundle/README.md +111 -0
  183. package/ui-bundle/client.js +1980 -0
  184. package/ui-bundle/cordis.patch.yml +3 -0
  185. package/ui-bundle/icon.svg +6 -0
  186. package/ui-bundle/index.js +2 -0
  187. package/ui-bundle/locale/en.json +6 -0
  188. package/ui-bundle/locale/zh.json +6 -0
  189. package/ui-bundle/package.json +35 -0
  190. package/lib/errors.js +0 -111
  191. package/lib/tools.js +0 -2250
  192. package/lib/tower.js +0 -2689
  193. /package/{lib → domain}/batch.js +0 -0
@@ -0,0 +1,179 @@
1
+ // Pure shared vocabulary (#56 P5): every status/type/action enum used by the
2
+ // tool contracts and handlers. Keeping one canonical value set here lets
3
+ // tools/** import constants/predicates instead of spelling literals.
4
+ // No fs, git, or config.
5
+ // @ts-check
6
+
7
+ import {
8
+ FINDING_SEVERITIES,
9
+ FINDING_STATUSES,
10
+ FINDING_TYPES,
11
+ MERGE_VERDICTS,
12
+ MISSION_STATUSES,
13
+ PAUSE_CAUSES,
14
+ } from './mission.js'
15
+ import { ROSTER_INACTIVE_STATES } from './roster.js'
16
+
17
+ export {
18
+ FINDING_SEVERITIES,
19
+ FINDING_STATUSES,
20
+ FINDING_TYPES,
21
+ MERGE_VERDICTS,
22
+ MISSION_STATUSES,
23
+ PAUSE_CAUSES,
24
+ ROSTER_INACTIVE_STATES,
25
+ }
26
+
27
+ // Mission inputs accepted by tower_plan.
28
+ /** @type {readonly string[]} */
29
+ export const MISSION_KINDS = ['build', 'survey']
30
+ /** @type {readonly string[]} */
31
+ export const REVIEW_POLICIES = ['required-clean', 'none']
32
+
33
+ // Roster actors (the caller resolved by actorFor) and createable agent roles.
34
+ export const ROLE = Object.freeze({
35
+ tower: 'tower',
36
+ worker: 'worker',
37
+ reviewer: 'reviewer',
38
+ unknown: 'unknown',
39
+ })
40
+ /** @type {readonly string[]} */
41
+ export const ROLES = Object.freeze([ROLE.worker, ROLE.reviewer])
42
+ /** @type {readonly string[]} */
43
+ export const ACTOR_ROLES = Object.freeze([ROLE.tower, ROLE.worker, ROLE.reviewer, ROLE.unknown])
44
+ export const DEFAULT_ROLE = ROLE.worker
45
+
46
+ /** @param {string | null | undefined} role */
47
+ export function isTowerRole(role) {
48
+ return role === ROLE.tower
49
+ }
50
+
51
+ /** @param {string | null | undefined} role */
52
+ export function isWorkerRole(role) {
53
+ return role === ROLE.worker
54
+ }
55
+
56
+ /** @param {string | null | undefined} role */
57
+ export function isReviewerRole(role) {
58
+ return role === ROLE.reviewer
59
+ }
60
+
61
+ /** @param {string | null | undefined} role */
62
+ export function isUnknownRole(role) {
63
+ return role === ROLE.unknown
64
+ }
65
+
66
+ // Inbox recipients: a roster name, the tower, or a broadcast.
67
+ export const RECIPIENT = Object.freeze({ tower: ROLE.tower, all: 'all' })
68
+
69
+ /** @param {string | null | undefined} to */
70
+ export function isTowerRecipient(to) {
71
+ return to === RECIPIENT.tower
72
+ }
73
+
74
+ /** @param {string | null | undefined} to */
75
+ export function isBroadcast(to) {
76
+ return to === RECIPIENT.all
77
+ }
78
+
79
+ // Action discriminators for the repository tools.
80
+ export const BATCH_ACTION = Object.freeze({
81
+ create: 'create',
82
+ status: 'status',
83
+ close: 'close',
84
+ abandon: 'abandon',
85
+ })
86
+ /** @type {readonly string[]} */
87
+ export const BATCH_ACTIONS = Object.freeze(Object.values(BATCH_ACTION))
88
+
89
+ export const RECOVERY_ACTION = Object.freeze({ status: 'status', clearStaleLock: 'clear-stale-lock' })
90
+ /** @type {readonly string[]} */
91
+ export const RECOVERY_ACTIONS = Object.freeze(Object.values(RECOVERY_ACTION))
92
+ export const RECOVERY_CONFIRM = 'CLEAR_STALE_LOCK'
93
+
94
+ export const LEASE_ACTION = Object.freeze({ acquire: 'acquire', release: 'release', status: 'status' })
95
+ /** @type {readonly string[]} */
96
+ export const LEASE_ACTIONS = Object.freeze(Object.values(LEASE_ACTION))
97
+
98
+ export const LEGACY_MAP_ACTION = Object.freeze({ map: 'map', status: 'status' })
99
+ /** @type {readonly string[]} */
100
+ export const LEGACY_MAP_ACTIONS = Object.freeze(Object.values(LEGACY_MAP_ACTION))
101
+
102
+ export const STATUS_FORMAT = Object.freeze({ json: 'json', markdown: 'markdown' })
103
+ /** @type {readonly string[]} */
104
+ export const STATUS_FORMATS = Object.freeze(Object.values(STATUS_FORMAT))
105
+
106
+ /** @param {string | null | undefined} format */
107
+ export function isMarkdownFormat(format) {
108
+ return format === STATUS_FORMAT.markdown
109
+ }
110
+
111
+ // Finding filters extend the lifecycle statuses with the read-only "all".
112
+ export const FINDING_FILTER_ALL = 'all'
113
+ /** @type {readonly string[]} */
114
+ export const FINDING_FILTER_STATUSES = Object.freeze([...FINDING_STATUSES, FINDING_FILTER_ALL])
115
+
116
+ // Test admission modes read from config by tower_spawn/tower_complete.
117
+ export const TESTS_ADMISSION = Object.freeze({ off: 'off', spawn: 'spawn', complete: 'complete' })
118
+ /** @type {readonly string[]} */
119
+ export const TESTS_ADMISSIONS = Object.freeze(Object.values(TESTS_ADMISSION))
120
+
121
+ /** @param {string | null | undefined} admission */
122
+ export function isSpawnAdmission(admission) {
123
+ return admission === TESTS_ADMISSION.spawn
124
+ }
125
+
126
+ /** @param {string | null | undefined} admission */
127
+ export function isCompleteAdmission(admission) {
128
+ return admission === TESTS_ADMISSION.complete
129
+ }
130
+
131
+ // Roster row states and observed catalog activity.
132
+ export const ROSTER_STATE = Object.freeze({
133
+ reserved: 'reserved',
134
+ active: 'active',
135
+ inactive: 'inactive',
136
+ retired: 'retired',
137
+ failed: 'failed',
138
+ interrupted: 'interrupted',
139
+ missing: 'missing',
140
+ stale: 'stale',
141
+ })
142
+ export const ROSTER_STATES = Object.freeze([...ROSTER_INACTIVE_STATES, ROSTER_STATE.reserved, ROSTER_STATE.active])
143
+ /** States treated as gone by roster reporting, excluding "inactive". */
144
+ export const ROSTER_GONE_STATES = Object.freeze([
145
+ ROSTER_STATE.retired,
146
+ ROSTER_STATE.failed,
147
+ ROSTER_STATE.interrupted,
148
+ ROSTER_STATE.missing,
149
+ ROSTER_STATE.stale,
150
+ ])
151
+ export const ROSTER_ACTIVITY = Object.freeze({
152
+ running: 'running',
153
+ inactive: 'inactive',
154
+ missing: 'missing',
155
+ unknown: 'unknown',
156
+ })
157
+ export const PARENT_STATUS_OK = 'ok'
158
+
159
+ /** @param {string | null | undefined} state */
160
+ export function isReservedState(state) {
161
+ return state === ROSTER_STATE.reserved
162
+ }
163
+
164
+ /** @param {string | null | undefined} activity */
165
+ export function isRunningActivity(activity) {
166
+ return activity === ROSTER_ACTIVITY.running
167
+ }
168
+
169
+ // Spawn completion outcomes recorded in the roster.
170
+ export const SPAWN_OUTCOME = Object.freeze({ failed: 'failed', inactive: 'inactive' })
171
+ export const STOP_REASON = Object.freeze({ completed: 'completed' })
172
+
173
+ /** @param {string | null | undefined} reason */
174
+ export function isCompletedStopReason(reason) {
175
+ return reason === STOP_REASON.completed
176
+ }
177
+
178
+ // Doctor check severities.
179
+ export const DOCTOR_STATUS = Object.freeze({ pass: 'pass', warn: 'warn', fail: 'fail' })
package/index.js CHANGED
@@ -1,20 +1,22 @@
1
1
  // dsh-tower — Host entry.
2
2
  //
3
- // Tower mode is a session-level stance. The human owns it through the
4
- // `/tower` command: bare `/tower`, `/tower on [message]`, `/tower off
5
- // [message]`, and `/tower <message>` all work, and any trailing text is
6
- // forwarded to the model as the next user message. With
7
- // config.allowAgentControl the model may also enter or leave the mode through
8
- // `tower_control` when the user intent or task state clearly calls for it.
3
+ // Tower mode is a session-level stance. It is intentionally operable by both
4
+ // sides: the `/tower` command (bare `/tower`, `/tower on [message]`, `/tower
5
+ // off [message]`, and `/tower <message>`; trailing text is forwarded to the
6
+ // model as the next user message) and, when config.allowAgentControl is true
7
+ // (the default), the model through `tower_control`. Every change is recorded
8
+ // with its actor and reason; set allowAgentControl:false for human-only mode
9
+ // control.
9
10
  //
10
11
  // `/tower reload` asks DSH HMR to reconcile this profile and reload the Tower
11
12
  // plugin from disk when the HMR service is present (dev/linked installs).
12
13
  import fs from 'node:fs'
13
14
  import { randomUUID } from 'node:crypto'
14
- import { registerTowerTools } from './lib/tools.js'
15
+ import { registerTowerTools } from './lib/tool-registry.js'
15
16
  import { loadModes, saveModes, storePath } from './lib/modeStore.js'
17
+ import { ModeState } from './lib/modeState.js'
16
18
  import { Config as ConfigSchema, DEFAULT_DENY_TOOLS, configureRuntime, runtimeConfig } from './lib/config.js'
17
- import { TowerToolError } from './lib/errors.js'
19
+ import { TowerToolError } from './domain/errors.js'
18
20
  import { resolveRepo } from './lib/git.js'
19
21
  import {
20
22
  acquireWorkspaceLock,
@@ -24,13 +26,19 @@ import {
24
26
  workspaceLockToken,
25
27
  } from './lib/workspaceLock.js'
26
28
  import { createHmrBridge } from './lib/hmr.js'
29
+ import { assertPresetsAvailable } from './lib/presets.js'
27
30
 
28
31
  export const name = 'dsh-tower'
29
32
  export const inject = ['tools', 'systemPrompt']
30
33
  export const Config = ConfigSchema
31
34
 
32
- let MODE = new Map()
35
+ const modeState = new ModeState()
33
36
  let denyTools = new Set(DEFAULT_DENY_TOOLS)
37
+
38
+ function modeOn(key) {
39
+ const entry = key ? modeState.get(key) : undefined
40
+ return Boolean(entry && entry.on === true)
41
+ }
34
42
  const READ_ONLY_TOOL_NAMES = new Set([
35
43
  'tower_status',
36
44
  'tower_roster',
@@ -44,11 +52,11 @@ const BASE_REMINDER = [
44
52
  'Tower mode is active for this session. You are the control tower: plan missions, spawn workers and reviewers, and integrate only through the Tower tools.',
45
53
  'Mission tracking lives in tower_plan / tower_mission / tower_status, not todo_write or the Team task board.',
46
54
  'Do not create or resume goals, do not spawn teammates, and never merge without a green tower_preflight.',
47
- 'Only the human can normally change this mode with /tower on|off.',
55
+ 'Tower mode changes are explicit and recorded; the user can always use /tower on|off.',
48
56
  ].join(' ')
49
57
 
50
58
  const AGENT_CONTROL_NOTE =
51
- 'If tower_control is available you may also enter or leave Tower mode yourself when the user intent or task state clearly calls for it, and you must state a concrete reason.'
59
+ 'You may also enter or leave Tower mode with tower_control when the user intent or task state clearly calls for it; always state a concrete reason.'
52
60
 
53
61
  function readPluginVersion() {
54
62
  if (pluginVersion !== null) return pluginVersion
@@ -80,7 +88,9 @@ function configSection(config, allowAgentControl) {
80
88
 
81
89
  function persistModes() {
82
90
  const config = runtimeConfig()
83
- saveModes(MODE, config.mode.storePath || undefined, config.mode.maxEntries)
91
+ saveModes(modeState, config.mode.storePath || undefined, config.mode.maxEntries, {
92
+ timeoutMs: config.mode.lockTimeoutMs,
93
+ })
84
94
  }
85
95
 
86
96
  /** Parse `/tower` arguments: bare and free text both mean "ensure on". */
@@ -150,7 +160,7 @@ function clearPlanModeForTower(ctx, agent) {
150
160
  }
151
161
 
152
162
  function enterMode(ctx, key, agent, by, reason) {
153
- const previous = MODE.get(key)
163
+ const previous = modeState.get(key)
154
164
  const wasOn = Boolean(previous && previous.on)
155
165
  const plan = clearPlanModeForTower(ctx, agent)
156
166
  if (plan.refused) {
@@ -171,12 +181,14 @@ function enterMode(ctx, key, agent, by, reason) {
171
181
  }
172
182
  }
173
183
  const now = Date.now()
174
- MODE.set(key, {
184
+ modeState.set(key, {
175
185
  on: true,
176
186
  pausedGoal,
177
187
  lock: previous?.lock ?? null,
178
188
  baseBranch: previous?.baseBranch ?? null,
179
189
  planModeCleared: plan.cleared,
190
+ humanAckAt: by === 'user' ? previous?.humanAckAt ?? now : previous?.humanAckAt ?? null,
191
+ lastDisclosure: previous?.lastDisclosure ?? 'full',
180
192
  lastChange: { by, action: 'on', reason: reason ?? null, at: now },
181
193
  at: now,
182
194
  })
@@ -185,14 +197,17 @@ function enterMode(ctx, key, agent, by, reason) {
185
197
  }
186
198
 
187
199
  async function exitMode(key, by, reason) {
188
- const previous = MODE.get(key)
200
+ const previous = modeState.get(key)
189
201
  if (!previous) return { wasOn: false, pausedGoal: null, goalNote: null }
190
- MODE.delete(key)
202
+ const wasOn = previous.on === true
203
+ // Keep a timestamped off tombstone so a concurrent save from another
204
+ // session cannot resurrect this mode entry.
205
+ modeState.set(key, { on: false, at: Date.now(), humanAckAt: previous.humanAckAt ?? null, lastDisclosure: previous.lastDisclosure ?? 'exit' })
191
206
  persistModes()
192
207
  if (previous.lock) {
193
208
  await releaseWorkspaceLock(previous.lock.commonDir, { token: previous.lock.token }).catch(() => {})
194
209
  }
195
- return { wasOn: true, pausedGoal: previous.pausedGoal ?? null, goalNote: null, by, reason: reason ?? null }
210
+ return { wasOn, pausedGoal: previous.pausedGoal ?? null, goalNote: null, by, reason: reason ?? null }
196
211
  }
197
212
 
198
213
  function forwardInstruction(agent, message, attachments) {
@@ -237,7 +252,8 @@ export function apply(ctx, rawConfig) {
237
252
  const config = configureRuntime(rawConfig)
238
253
  const allowAgentControl = config.mode.allowAgentControl === true
239
254
  const reminder = configSection(config, allowAgentControl)
240
- MODE = loadModes(config.mode.storePath || undefined)
255
+ modeState.replace(loadModes(config.mode.storePath || undefined))
256
+ if (typeof ctx.provide === 'function') ctx.provide('tower', modeState)
241
257
  denyTools = new Set(config.mode.denyTools)
242
258
  const hotReloadEnabled = config.dev?.hotReload !== false
243
259
  const hmrBridge = hotReloadEnabled
@@ -255,8 +271,8 @@ export function apply(ctx, rawConfig) {
255
271
  if (runtimeConfig().mode.workspaceLockScope === 'mutations' && READ_ONLY_TOOL_NAMES.has(toolName)) return
256
272
  const key = agentKey(exec?.agent)
257
273
  if (!key) return
258
- const entry = MODE.get(key)
259
- if (!entry) return
274
+ const entry = modeState.get(key)
275
+ if (!entry || entry.on !== true) return
260
276
  const repo = await resolveRepo(repoRoot)
261
277
  const existing = entry.lock
262
278
  if (existing && existing.commonDir !== repo.commonDir) {
@@ -304,7 +320,26 @@ export function apply(ctx, rawConfig) {
304
320
  },
305
321
  basePreference(exec) {
306
322
  const key = agentKey(exec?.agent)
307
- return key ? MODE.get(key)?.baseBranch ?? null : null
323
+ return key && modeOn(key) ? modeState.get(key)?.baseBranch ?? null : null
324
+ },
325
+ modeInfo(exec) {
326
+ const key = agentKey(exec?.agent)
327
+ if (!key) return null
328
+ const entry = modeState.get(key)
329
+ const history = [...modeState.values()]
330
+ .map((value) => value?.lastChange)
331
+ .filter((change) => change && typeof change.at === 'number')
332
+ .sort((a, b) => b.at - a.at)
333
+ .slice(0, 10)
334
+ return {
335
+ mode: modeOn(key) ? 'on' : entry ? 'off' : 'unknown',
336
+ allowAgentControl: runtimeConfig().mode.allowAgentControl === true,
337
+ lastChange: entry?.lastChange ?? null,
338
+ history,
339
+ workspaceLock: entry?.lock
340
+ ? { enabled: runtimeConfig().mode.workspaceLock === true, commonDir: entry.lock.commonDir }
341
+ : null,
342
+ }
308
343
  },
309
344
  }
310
345
 
@@ -314,7 +349,7 @@ export function apply(ctx, rawConfig) {
314
349
  // process still owns; a reloaded plugin restores the entries from modes.json.
315
350
  ctx.effect(() => () => {
316
351
  if (hmrBridge) hmrBridge.dispose?.()
317
- for (const entry of MODE.values()) {
352
+ for (const entry of modeState.values()) {
318
353
  if (entry?.lock) {
319
354
  releaseWorkspaceLock(entry.lock.commonDir, { token: entry.lock.token }).catch(() => {})
320
355
  }
@@ -324,7 +359,7 @@ export function apply(ctx, rawConfig) {
324
359
  ctx.effect(() =>
325
360
  ctx.tools.guard((execution) => {
326
361
  const key = agentKey(execution.agent)
327
- if (!key || !MODE.has(key)) return undefined
362
+ if (!key || !modeOn(key)) return undefined
328
363
  if (denyTools.has(execution.name)) {
329
364
  return `tower mode: ${execution.name} is disabled while Tower mode is active; use the Tower tools or /tower off`
330
365
  }
@@ -345,7 +380,7 @@ export function apply(ctx, rawConfig) {
345
380
  typeof ctx.on === 'function'
346
381
  ? ctx.on('agent/pre-step', (payload, next) => {
347
382
  const key = agentKey(payload?.agent)
348
- if (key && MODE.has(key)) {
383
+ if (key && modeOn(key)) {
349
384
  // Once Tower is active, Tower wins regardless of entry policy: clear
350
385
  // any plan state so the two policies cannot coexist.
351
386
  const state = planModeState(ctx, payload.agent)
@@ -359,13 +394,13 @@ export function apply(ctx, rawConfig) {
359
394
  ctx.systemPrompt.section({
360
395
  name: 'tower:mode',
361
396
  order: 15000,
362
- text: (context) => (context && MODE.has(agentKey(context.agent)) ? reminder : ''),
397
+ text: (context) => (context && modeOn(agentKey(context.agent)) ? reminder : ''),
363
398
  })
364
399
 
365
400
  // Workspace-lock heartbeat; only sessions holding a lock do I/O.
366
401
  ctx.effect(() => {
367
402
  const timer = setInterval(() => {
368
- for (const entry of MODE.values()) {
403
+ for (const entry of modeState.values()) {
369
404
  if (!entry?.lock) continue
370
405
  touchWorkspaceLock(entry.lock.commonDir, { token: entry.lock.token }).catch(() => {})
371
406
  }
@@ -402,12 +437,13 @@ export function apply(ctx, rawConfig) {
402
437
  },
403
438
  async execute(_args, exec) {
404
439
  const key = agentKey(exec.agent)
405
- const entry = key ? MODE.get(key) : undefined
440
+ const entry = key ? modeState.get(key) : undefined
406
441
  return {
407
442
  mode: entry && entry.on === true ? 'on' : key ? 'off' : 'unknown',
408
443
  agentId: key,
409
444
  allowAgentControl,
410
445
  lastChange: entry?.lastChange ?? null,
446
+ lastDisclosure: entry?.lastDisclosure ?? null,
411
447
  pausedGoal: entry?.pausedGoal ?? null,
412
448
  deniedWhileActive: [...denyTools],
413
449
  persistedAt: storePath(config.mode.storePath || undefined),
@@ -464,17 +500,25 @@ export function apply(ctx, rawConfig) {
464
500
  const key = agentKey(exec.agent)
465
501
  if (!key) return { ok: false, error: 'tower_control requires a live calling agent' }
466
502
  if (typeof args.reason !== 'string' || args.reason.trim().length === 0) {
467
- throw new Error('tower_control requires a non-empty reason')
503
+ throw new TowerToolError('invalid-argument', 'tower_control requires a non-empty reason')
468
504
  }
469
505
  const reason = args.reason.trim()
506
+ if (args.action === 'enter' && !modeOn(key)) {
507
+ await assertPresetsAvailable(ctx, config)
508
+ }
470
509
  const outcome =
471
510
  args.action === 'enter'
472
- ? enterMode(ctx, key, exec.agent, 'agent', reason)
511
+ ? (() => {
512
+ if (!modeOn(key) && runtimeConfig().mode.requireHumanAckOnFirstEntry === true && !modeState.get(key)?.humanAckAt) {
513
+ throw new TowerToolError('not-authorized', 'this profile requires a human acknowledgement; run /tower ack first')
514
+ }
515
+ return enterMode(ctx, key, exec.agent, 'agent', reason)
516
+ })()
473
517
  : await exitMode(key, 'agent', reason)
474
518
  return {
475
519
  ok: true,
476
520
  action: args.action,
477
- mode: MODE.has(key) ? 'on' : 'off',
521
+ mode: modeOn(key) ? 'on' : 'off',
478
522
  changed: args.action === 'enter' ? !outcome.wasOn : outcome.wasOn,
479
523
  changedBy: 'agent',
480
524
  reason,
@@ -490,14 +534,21 @@ export function apply(ctx, rawConfig) {
490
534
  commandCtx.commands.register({
491
535
  name: 'tower',
492
536
  description: 'Enter or leave Tower mode, optionally carrying a user instruction; /tower reload reloads via HMR; /tower base <branch> sets the next init base',
493
- input: { hint: '[on|off|reload|base <branch>] [message]', attachments: true },
537
+ input: { hint: '[on|off|reload|ack|base <branch>] [message]', attachments: true },
494
538
  handler: async ({ agent, rawInput, attachments }) => {
495
539
  const key = agentKey(agent)
496
540
  if (!key) return { kind: 'error', text: 'Tower mode requires a live agent.' }
497
- const baseMatch = String(rawInput ?? '').trim().match(/^base\s+(\S+)\s*$/i)
541
+ const raw = String(rawInput ?? '').trim()
542
+ if (/^ack$/i.test(raw)) {
543
+ const previous = modeState.get(key) ?? { on: false }
544
+ modeState.set(key, { ...previous, on: previous.on === true, humanAckAt: Date.now(), at: Date.now() })
545
+ persistModes()
546
+ return { kind: 'success', text: 'Human acknowledgement recorded; the model may now make the first Tower-mode entry in this session.' }
547
+ }
548
+ const baseMatch = raw.match(/^base\s+(\S+)\s*$/i)
498
549
  if (baseMatch) {
499
- const entry = MODE.get(key)
500
- if (!entry) return { kind: 'error', text: 'Run /tower on first, then /tower base <branch>.' }
550
+ const entry = modeState.get(key)
551
+ if (!entry || entry.on !== true) return { kind: 'error', text: 'Run /tower on first, then /tower base <branch>.' }
501
552
  entry.baseBranch = baseMatch[1]
502
553
  persistModes()
503
554
  return { kind: 'success', text: `Tower base preference set to "${entry.baseBranch}"; tower_init without an explicit base will use it.` }
@@ -517,6 +568,7 @@ export function apply(ctx, rawConfig) {
517
568
  }
518
569
  }
519
570
  const { active, message } = parsed
571
+ if (active) await assertPresetsAvailable(ctx, config)
520
572
  const outcome = active
521
573
  ? enterMode(ctx, key, agent, 'user', message || null)
522
574
  : await exitMode(key, 'user', message || null)
@@ -0,0 +1,176 @@
1
+ // Mission briefings: pure text builders for worker/reviewer prompts.
2
+
3
+ function clip(text, max = 2000) {
4
+ const value = String(text ?? '')
5
+ return value.length > max ? `${value.slice(0, max)}\n… truncated` : value
6
+ }
7
+
8
+ export function workerBriefing({
9
+ repoRoot,
10
+ base,
11
+ mission,
12
+ deps,
13
+ worktree,
14
+ instructions,
15
+ reviews = [],
16
+ findings = [],
17
+ completionReport = null,
18
+ }) {
19
+ const completion = /** @type {import('../types/index.js').CompletionReport | null} */ (completionReport)
20
+ const lines = []
21
+ lines.push(`# Tower mission ${mission.id} — ${mission.title}`)
22
+ lines.push('')
23
+ lines.push(`You are the tower worker for mission ${mission.id} in repository ${repoRoot}.`)
24
+ lines.push('')
25
+ lines.push(`Work ONLY in this git worktree: ${worktree.path}`)
26
+ lines.push(`Branch: ${worktree.branch} (based on ${base} @ ${String(worktree.baseCommit).slice(0, 12)})`)
27
+ lines.push('')
28
+ lines.push(`Kind: ${mission.kind}`)
29
+ lines.push(
30
+ `Scope (only files you may touch): ${mission.scope.length > 0 ? mission.scope.join(', ') : '(none — survey is read-only)'}`,
31
+ )
32
+ if (mission.tasks.length > 0) {
33
+ lines.push('Tasks:')
34
+ for (const task of mission.tasks) lines.push(`- [${task.status}] ${task.text}`)
35
+ }
36
+ if (deps.length > 0) {
37
+ lines.push('Dependencies:')
38
+ for (const dep of deps) lines.push(`- ${dep.id} ${dep.title ?? ''} (${dep.status})`)
39
+ }
40
+ if (mission.context) {
41
+ lines.push('Context (verbatim from the user):')
42
+ lines.push(mission.context)
43
+ }
44
+ if ((mission.references ?? []).length > 0) {
45
+ lines.push('References (read-only pointers; read them before deciding):')
46
+ for (const reference of mission.references) lines.push(`- ${reference}`)
47
+ }
48
+ if (mission.reviewPolicy === 'none') {
49
+ lines.push(`Review policy: none (user exemption: ${mission.reviewExemptionReason ?? 'missing reason'})`)
50
+ }
51
+ if (completion) {
52
+ lines.push('')
53
+ lines.push(`Previous completion report (by ${completion.by ?? 'worker'}):`)
54
+ lines.push(clip(completion.report, 1500))
55
+ if ((completion.checks ?? []).length > 0) {
56
+ lines.push('Previous checks:')
57
+ for (const check of completion.checks ?? []) lines.push(`- ${check.name}${check.exitCode !== undefined ? ` (exit ${check.exitCode})` : ''}`)
58
+ }
59
+ }
60
+ if (reviews.length > 0) {
61
+ lines.push('')
62
+ lines.push('Previous review rounds for this branch (newest first) — address every finding:')
63
+ for (const review of reviews) {
64
+ lines.push(`- R${review.round} by ${review.reviewer}: ${review.status} / ${review.mergeVerdict}`)
65
+ if (review.findings) lines.push(` findings: ${clip(review.findings, 1200).replaceAll('\n', '\n ')}`)
66
+ if (review.decision) lines.push(` decision: ${clip(review.decision, 600)}`)
67
+ }
68
+ }
69
+ if (findings.length > 0) {
70
+ lines.push('')
71
+ lines.push('Findings related to this mission:')
72
+ for (const finding of findings) lines.push(`- ${finding.id} [${finding.status}] ${finding.severity ?? 'severity?'} ${finding.title}`)
73
+ }
74
+ lines.push('')
75
+ lines.push('Rules:')
76
+ lines.push(`- Pass workdir="${worktree.path}" on every shell command; never edit the main checkout.`)
77
+ lines.push('- Stay inside the declared scope; out-of-scope changes are rejected later.')
78
+ lines.push('- Commit your work on the mission branch. Do not merge, push, or spawn other agents.')
79
+ lines.push(
80
+ `- Update progress: tower_mission({repoRoot:"${repoRoot}",id:"${mission.id}",task_done:"..."}) or blocker:"...".`,
81
+ )
82
+ lines.push(
83
+ `- When the work is finished and every task is closed, call tower_complete({repoRoot:"${repoRoot}",missionId:"${mission.id}",report:"<full handoff>",checks:[{name,command,exitCode,evidence}]}). Unread inbox messages block completion, so read them with tower_inbox first.`,
84
+ )
85
+ lines.push('- Your final message is the full handoff: files changed, how you verified, what remains.')
86
+ if (instructions) {
87
+ lines.push('')
88
+ lines.push('Extra tower instructions:')
89
+ lines.push(instructions)
90
+ }
91
+ return lines.join('\n')
92
+ }
93
+
94
+ export function reviewerBriefing({
95
+ repoRoot,
96
+ base,
97
+ mission,
98
+ deps,
99
+ worktree,
100
+ instructions,
101
+ reviews = [],
102
+ findings = [],
103
+ completionReport = null,
104
+ }) {
105
+ const completion = /** @type {import('../types/index.js').CompletionReport | null} */ (completionReport)
106
+ const lines = []
107
+ lines.push(`# Tower review — mission ${mission.id} — ${mission.title}`)
108
+ lines.push('')
109
+ lines.push(`You are the independent reviewer for mission ${mission.id} in repository ${repoRoot}.`)
110
+ lines.push('')
111
+ lines.push(`Review branch: ${worktree.branch}`)
112
+ lines.push(`Worktree: ${worktree.path}`)
113
+ lines.push(`Reviewed base: ${base} @ ${String(worktree.baseCommit).slice(0, 12)}`)
114
+ lines.push('')
115
+ lines.push(`Kind: ${mission.kind}`)
116
+ if (mission.tasks.length > 0) {
117
+ lines.push('The worker was asked to complete:')
118
+ for (const task of mission.tasks) lines.push(`- [${task.status}] ${task.text}`)
119
+ }
120
+ if (deps.length > 0) {
121
+ lines.push('Dependencies:')
122
+ for (const dep of deps) lines.push(`- ${dep.id} ${dep.title ?? ''} (${dep.status})`)
123
+ }
124
+ if (mission.context) {
125
+ lines.push('Context (verbatim from the user):')
126
+ lines.push(mission.context)
127
+ }
128
+ if ((mission.references ?? []).length > 0) {
129
+ lines.push('References (read-only pointers):')
130
+ for (const reference of mission.references) lines.push(`- ${reference}`)
131
+ }
132
+ if (completion) {
133
+ lines.push('')
134
+ lines.push(`Completion report (by ${completion.by ?? 'worker'}):`)
135
+ lines.push(clip(completion.report, 2000))
136
+ if ((completion.checks ?? []).length > 0) {
137
+ lines.push('Completion checks the worker ran:')
138
+ for (const check of completion.checks ?? []) {
139
+ lines.push(
140
+ `- ${check.name}${check.exitCode !== undefined ? ` (exit ${check.exitCode})` : ''}${check.evidence ? ` — ${clip(check.evidence, 400)}` : ''}`,
141
+ )
142
+ }
143
+ }
144
+ }
145
+ if (reviews.length > 0) {
146
+ lines.push('')
147
+ lines.push('Prior review rounds for this branch (newest first):')
148
+ for (const review of reviews) {
149
+ lines.push(`- R${review.round} by ${review.reviewer}: ${review.status} / ${review.mergeVerdict}`)
150
+ if (review.findings) lines.push(` findings: ${clip(review.findings, 1200).replaceAll('\n', '\n ')}`)
151
+ }
152
+ }
153
+ if (findings.length > 0) {
154
+ lines.push('')
155
+ lines.push('Related findings:')
156
+ for (const finding of findings) lines.push(`- ${finding.id} [${finding.status}] ${finding.severity ?? 'severity?'} ${finding.title}`)
157
+ }
158
+ lines.push('')
159
+ lines.push('Rules:')
160
+ lines.push('- Read-only: do not modify files, commit, or merge.')
161
+ lines.push('- Inspect the diff from the reviewed base to the branch tip and map every task to the diff.')
162
+ lines.push('- Check scope, correctness, tests, and security. Report severity and concrete evidence.')
163
+ lines.push(
164
+ `- Submit the formal verdict with tower_review({repoRoot:"${repoRoot}",missionId:"${mission.id}",status:"clean",merge:"merge",findings:"none",checks:["..."],decision:"..."}). Use status "p1-Nitems"/"p2-Nitems" and merge "fix-then-merge"/"hold" for problems; a non-clean verdict returns the mission to active.`,
165
+ )
166
+ lines.push(
167
+ `- File out-of-scope issues with tower_finding({repoRoot:"${repoRoot}",type:"bug",title:"...",summary:"...",details:"...",suggested_fix:"..."}) instead of fixing them.`,
168
+ )
169
+ lines.push('- Your final message is the review report; the tool call above is the formal verdict.')
170
+ if (instructions) {
171
+ lines.push('')
172
+ lines.push('Extra tower instructions:')
173
+ lines.push(instructions)
174
+ }
175
+ return lines.join('\n')
176
+ }