dsh-tower 2.0.4 → 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 (171) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +40 -3
  5. package/README.md +38 -3
  6. package/SECURITY.md +27 -0
  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 +3 -1
  33. package/docs/architecture/overview.md +111 -12
  34. package/docs/board.md +72 -0
  35. package/docs/guide/hot-reload.md +1 -1
  36. package/docs/guide/install-upgrade-migration.md +8 -1
  37. package/docs/guide/release.md +125 -0
  38. package/docs/operations/security.md +47 -2
  39. package/docs/reference/config.generated.md +66 -0
  40. package/docs/reference/config.md +4 -1
  41. package/docs/reference/errors.generated.md +39 -0
  42. package/docs/reference/errors.md +4 -2
  43. package/docs/reference/tools.generated.md +33 -0
  44. package/docs/reference/tools.md +1 -1
  45. package/docs/zh/README.md +3 -2
  46. package/docs/zh/guide/hot-reload.md +1 -1
  47. package/docs/zh/guide/install-upgrade-migration.md +6 -1
  48. package/docs/zh/operations/security.md +30 -1
  49. package/docs/zh/reference/config.md +4 -1
  50. package/docs/zh/reference/errors.md +3 -1
  51. package/docs/zh/reference/tools.md +1 -1
  52. package/domain/collab/batch.js +112 -0
  53. package/domain/collab/messages.js +127 -0
  54. package/domain/collab/roster.js +158 -0
  55. package/domain/collab/spawn.js +188 -0
  56. package/domain/config/schema.js +404 -0
  57. package/domain/contracts/outputs.js +279 -0
  58. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  59. package/domain/contracts/tool-inputs/findings.js +29 -0
  60. package/domain/contracts/tool-inputs/index.js +94 -0
  61. package/domain/contracts/tool-inputs/integration.js +37 -0
  62. package/domain/contracts/tool-inputs/missions.js +94 -0
  63. package/domain/contracts/tool-inputs/ops.js +75 -0
  64. package/domain/contracts/tool-inputs/shared.js +25 -0
  65. package/domain/errors.js +96 -0
  66. package/domain/gate.js +125 -0
  67. package/domain/integration/gate.js +176 -0
  68. package/domain/integration/init.js +225 -0
  69. package/domain/integration/liveness.js +31 -0
  70. package/domain/integration/merge.js +377 -0
  71. package/domain/integration/provision.js +124 -0
  72. package/domain/integration/rebase.js +236 -0
  73. package/domain/integration/review.js +52 -0
  74. package/domain/integration/teardown.js +118 -0
  75. package/domain/mission.js +68 -0
  76. package/domain/missions/completion.js +114 -0
  77. package/domain/missions/findings.js +201 -0
  78. package/domain/missions/lifecycle.js +185 -0
  79. package/domain/missions/plan.js +149 -0
  80. package/domain/missions/review.js +179 -0
  81. package/domain/planning.js +118 -0
  82. package/domain/read/enrichedMissions.js +58 -0
  83. package/domain/read/missionContext.js +53 -0
  84. package/domain/read/views.js +128 -0
  85. package/domain/report.js +59 -0
  86. package/domain/roster.js +40 -0
  87. package/domain/scope.js +105 -0
  88. package/domain/state/schema.js +233 -0
  89. package/domain/state/semantics.js +117 -0
  90. package/domain/state.js +33 -0
  91. package/domain/summary.js +114 -0
  92. package/domain/vocabulary.js +179 -0
  93. package/index.js +63 -20
  94. package/lib/briefing.js +176 -0
  95. package/lib/compatibility.js +108 -0
  96. package/lib/config.js +33 -375
  97. package/lib/doctor.js +97 -4
  98. package/lib/git.js +60 -16
  99. package/lib/hmr.js +4 -3
  100. package/lib/leases.js +3 -2
  101. package/lib/legacy.js +20 -9
  102. package/lib/modeState.js +39 -0
  103. package/lib/modeStore.js +11 -43
  104. package/lib/persistence.js +349 -0
  105. package/lib/presets.js +35 -0
  106. package/lib/spawn-prechecks.js +168 -0
  107. package/lib/spawn-support.js +79 -0
  108. package/lib/store.js +54 -137
  109. package/lib/tests.js +11 -10
  110. package/lib/tool-registry.js +544 -0
  111. package/lib/workspaceLock.js +2 -2
  112. package/lib/worktreeOwnership.js +5 -4
  113. package/locale/en.json +23 -1
  114. package/locale/zh.json +22 -0
  115. package/package.json +32 -4
  116. package/ports/clock.js +6 -0
  117. package/ports/ids.js +7 -0
  118. package/ports/index.js +2 -0
  119. package/ports/store.js +11 -0
  120. package/ports/vcs.js +11 -0
  121. package/scripts/architecture.mjs +94 -0
  122. package/scripts/check-docs.mjs +9 -3
  123. package/scripts/check-pack.mjs +49 -0
  124. package/scripts/domain-metrics.mjs +48 -0
  125. package/scripts/generate-config-reference.mjs +46 -0
  126. package/scripts/generate-error-reference.mjs +29 -0
  127. package/scripts/generate-tools-reference.mjs +56 -0
  128. package/scripts/issue-audit.mjs +259 -0
  129. package/scripts/lib/cli.mjs +76 -0
  130. package/scripts/lib/tmp.mjs +20 -0
  131. package/scripts/mount-smoke/fixtures.mjs +162 -0
  132. package/scripts/mount-smoke/host.json +4 -0
  133. package/scripts/mount-smoke/probe.mjs +125 -0
  134. package/scripts/mount-smoke/surface.mjs +141 -0
  135. package/scripts/mount-smoke.mjs +270 -0
  136. package/scripts/pack-manifest.txt +203 -0
  137. package/scripts/release-check.mjs +303 -0
  138. package/tools/adopt.js +72 -0
  139. package/tools/batch.js +78 -0
  140. package/tools/complete.js +53 -0
  141. package/tools/diff.js +39 -0
  142. package/tools/doctor.js +50 -0
  143. package/tools/finding.js +40 -0
  144. package/tools/findings.js +39 -0
  145. package/tools/import-legacy.js +42 -0
  146. package/tools/inbox.js +39 -0
  147. package/tools/index.js +39 -0
  148. package/tools/init.js +63 -0
  149. package/tools/interrupt.js +42 -0
  150. package/tools/legacy-map.js +44 -0
  151. package/tools/merge.js +39 -0
  152. package/tools/mission.js +41 -0
  153. package/tools/plan.js +39 -0
  154. package/tools/preflight.js +44 -0
  155. package/tools/rebase.js +39 -0
  156. package/tools/recover.js +45 -0
  157. package/tools/reload.js +52 -0
  158. package/tools/resource.js +54 -0
  159. package/tools/review.js +45 -0
  160. package/tools/roster.js +60 -0
  161. package/tools/send.js +147 -0
  162. package/tools/spawn.js +196 -0
  163. package/tools/status.js +94 -0
  164. package/tools/teardown.js +42 -0
  165. package/tools/test.js +61 -0
  166. package/types/cordis.d.ts +6 -0
  167. package/types/index.d.ts +339 -0
  168. package/lib/errors.js +0 -123
  169. package/lib/tools.js +0 -2460
  170. package/lib/tower.js +0 -2912
  171. /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
@@ -12,10 +12,11 @@
12
12
  // plugin from disk when the HMR service is present (dev/linked installs).
13
13
  import fs from 'node:fs'
14
14
  import { randomUUID } from 'node:crypto'
15
- import { registerTowerTools } from './lib/tools.js'
15
+ import { registerTowerTools } from './lib/tool-registry.js'
16
16
  import { loadModes, saveModes, storePath } from './lib/modeStore.js'
17
+ import { ModeState } from './lib/modeState.js'
17
18
  import { Config as ConfigSchema, DEFAULT_DENY_TOOLS, configureRuntime, runtimeConfig } from './lib/config.js'
18
- import { TowerToolError } from './lib/errors.js'
19
+ import { TowerToolError } from './domain/errors.js'
19
20
  import { resolveRepo } from './lib/git.js'
20
21
  import {
21
22
  acquireWorkspaceLock,
@@ -25,16 +26,17 @@ import {
25
26
  workspaceLockToken,
26
27
  } from './lib/workspaceLock.js'
27
28
  import { createHmrBridge } from './lib/hmr.js'
29
+ import { assertPresetsAvailable } from './lib/presets.js'
28
30
 
29
31
  export const name = 'dsh-tower'
30
32
  export const inject = ['tools', 'systemPrompt']
31
33
  export const Config = ConfigSchema
32
34
 
33
- let MODE = new Map()
35
+ const modeState = new ModeState()
34
36
  let denyTools = new Set(DEFAULT_DENY_TOOLS)
35
37
 
36
38
  function modeOn(key) {
37
- const entry = key ? MODE.get(key) : undefined
39
+ const entry = key ? modeState.get(key) : undefined
38
40
  return Boolean(entry && entry.on === true)
39
41
  }
40
42
  const READ_ONLY_TOOL_NAMES = new Set([
@@ -86,7 +88,9 @@ function configSection(config, allowAgentControl) {
86
88
 
87
89
  function persistModes() {
88
90
  const config = runtimeConfig()
89
- 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
+ })
90
94
  }
91
95
 
92
96
  /** Parse `/tower` arguments: bare and free text both mean "ensure on". */
@@ -156,7 +160,7 @@ function clearPlanModeForTower(ctx, agent) {
156
160
  }
157
161
 
158
162
  function enterMode(ctx, key, agent, by, reason) {
159
- const previous = MODE.get(key)
163
+ const previous = modeState.get(key)
160
164
  const wasOn = Boolean(previous && previous.on)
161
165
  const plan = clearPlanModeForTower(ctx, agent)
162
166
  if (plan.refused) {
@@ -177,12 +181,14 @@ function enterMode(ctx, key, agent, by, reason) {
177
181
  }
178
182
  }
179
183
  const now = Date.now()
180
- MODE.set(key, {
184
+ modeState.set(key, {
181
185
  on: true,
182
186
  pausedGoal,
183
187
  lock: previous?.lock ?? null,
184
188
  baseBranch: previous?.baseBranch ?? null,
185
189
  planModeCleared: plan.cleared,
190
+ humanAckAt: by === 'user' ? previous?.humanAckAt ?? now : previous?.humanAckAt ?? null,
191
+ lastDisclosure: previous?.lastDisclosure ?? 'full',
186
192
  lastChange: { by, action: 'on', reason: reason ?? null, at: now },
187
193
  at: now,
188
194
  })
@@ -191,12 +197,12 @@ function enterMode(ctx, key, agent, by, reason) {
191
197
  }
192
198
 
193
199
  async function exitMode(key, by, reason) {
194
- const previous = MODE.get(key)
200
+ const previous = modeState.get(key)
195
201
  if (!previous) return { wasOn: false, pausedGoal: null, goalNote: null }
196
202
  const wasOn = previous.on === true
197
203
  // Keep a timestamped off tombstone so a concurrent save from another
198
204
  // session cannot resurrect this mode entry.
199
- MODE.set(key, { on: false, at: Date.now() })
205
+ modeState.set(key, { on: false, at: Date.now(), humanAckAt: previous.humanAckAt ?? null, lastDisclosure: previous.lastDisclosure ?? 'exit' })
200
206
  persistModes()
201
207
  if (previous.lock) {
202
208
  await releaseWorkspaceLock(previous.lock.commonDir, { token: previous.lock.token }).catch(() => {})
@@ -246,7 +252,8 @@ export function apply(ctx, rawConfig) {
246
252
  const config = configureRuntime(rawConfig)
247
253
  const allowAgentControl = config.mode.allowAgentControl === true
248
254
  const reminder = configSection(config, allowAgentControl)
249
- MODE = loadModes(config.mode.storePath || undefined)
255
+ modeState.replace(loadModes(config.mode.storePath || undefined))
256
+ if (typeof ctx.provide === 'function') ctx.provide('tower', modeState)
250
257
  denyTools = new Set(config.mode.denyTools)
251
258
  const hotReloadEnabled = config.dev?.hotReload !== false
252
259
  const hmrBridge = hotReloadEnabled
@@ -264,7 +271,7 @@ export function apply(ctx, rawConfig) {
264
271
  if (runtimeConfig().mode.workspaceLockScope === 'mutations' && READ_ONLY_TOOL_NAMES.has(toolName)) return
265
272
  const key = agentKey(exec?.agent)
266
273
  if (!key) return
267
- const entry = MODE.get(key)
274
+ const entry = modeState.get(key)
268
275
  if (!entry || entry.on !== true) return
269
276
  const repo = await resolveRepo(repoRoot)
270
277
  const existing = entry.lock
@@ -313,7 +320,26 @@ export function apply(ctx, rawConfig) {
313
320
  },
314
321
  basePreference(exec) {
315
322
  const key = agentKey(exec?.agent)
316
- return key && modeOn(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
+ }
317
343
  },
318
344
  }
319
345
 
@@ -323,7 +349,7 @@ export function apply(ctx, rawConfig) {
323
349
  // process still owns; a reloaded plugin restores the entries from modes.json.
324
350
  ctx.effect(() => () => {
325
351
  if (hmrBridge) hmrBridge.dispose?.()
326
- for (const entry of MODE.values()) {
352
+ for (const entry of modeState.values()) {
327
353
  if (entry?.lock) {
328
354
  releaseWorkspaceLock(entry.lock.commonDir, { token: entry.lock.token }).catch(() => {})
329
355
  }
@@ -374,7 +400,7 @@ export function apply(ctx, rawConfig) {
374
400
  // Workspace-lock heartbeat; only sessions holding a lock do I/O.
375
401
  ctx.effect(() => {
376
402
  const timer = setInterval(() => {
377
- for (const entry of MODE.values()) {
403
+ for (const entry of modeState.values()) {
378
404
  if (!entry?.lock) continue
379
405
  touchWorkspaceLock(entry.lock.commonDir, { token: entry.lock.token }).catch(() => {})
380
406
  }
@@ -411,12 +437,13 @@ export function apply(ctx, rawConfig) {
411
437
  },
412
438
  async execute(_args, exec) {
413
439
  const key = agentKey(exec.agent)
414
- const entry = key ? MODE.get(key) : undefined
440
+ const entry = key ? modeState.get(key) : undefined
415
441
  return {
416
442
  mode: entry && entry.on === true ? 'on' : key ? 'off' : 'unknown',
417
443
  agentId: key,
418
444
  allowAgentControl,
419
445
  lastChange: entry?.lastChange ?? null,
446
+ lastDisclosure: entry?.lastDisclosure ?? null,
420
447
  pausedGoal: entry?.pausedGoal ?? null,
421
448
  deniedWhileActive: [...denyTools],
422
449
  persistedAt: storePath(config.mode.storePath || undefined),
@@ -473,12 +500,20 @@ export function apply(ctx, rawConfig) {
473
500
  const key = agentKey(exec.agent)
474
501
  if (!key) return { ok: false, error: 'tower_control requires a live calling agent' }
475
502
  if (typeof args.reason !== 'string' || args.reason.trim().length === 0) {
476
- throw new Error('tower_control requires a non-empty reason')
503
+ throw new TowerToolError('invalid-argument', 'tower_control requires a non-empty reason')
477
504
  }
478
505
  const reason = args.reason.trim()
506
+ if (args.action === 'enter' && !modeOn(key)) {
507
+ await assertPresetsAvailable(ctx, config)
508
+ }
479
509
  const outcome =
480
510
  args.action === 'enter'
481
- ? 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
+ })()
482
517
  : await exitMode(key, 'agent', reason)
483
518
  return {
484
519
  ok: true,
@@ -499,13 +534,20 @@ export function apply(ctx, rawConfig) {
499
534
  commandCtx.commands.register({
500
535
  name: 'tower',
501
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',
502
- input: { hint: '[on|off|reload|base <branch>] [message]', attachments: true },
537
+ input: { hint: '[on|off|reload|ack|base <branch>] [message]', attachments: true },
503
538
  handler: async ({ agent, rawInput, attachments }) => {
504
539
  const key = agentKey(agent)
505
540
  if (!key) return { kind: 'error', text: 'Tower mode requires a live agent.' }
506
- 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)
507
549
  if (baseMatch) {
508
- const entry = MODE.get(key)
550
+ const entry = modeState.get(key)
509
551
  if (!entry || entry.on !== true) return { kind: 'error', text: 'Run /tower on first, then /tower base <branch>.' }
510
552
  entry.baseBranch = baseMatch[1]
511
553
  persistModes()
@@ -526,6 +568,7 @@ export function apply(ctx, rawConfig) {
526
568
  }
527
569
  }
528
570
  const { active, message } = parsed
571
+ if (active) await assertPresetsAvailable(ctx, config)
529
572
  const outcome = active
530
573
  ? enterMode(ctx, key, agent, 'user', message || null)
531
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
+ }
@@ -0,0 +1,108 @@
1
+ // Release compatibility provenance. COMPATIBILITY.md is shipped in the package;
2
+ // no network lookup is involved.
3
+ import { fs } from '../adapters/store-fs.js'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
8
+
9
+ export function parseVersion(value) {
10
+ const text = String(value ?? '').trim()
11
+ const match = text.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/)
12
+ if (!match) return null
13
+ return {
14
+ numbers: [Number(match[1]), Number(match[2]), Number(match[3])],
15
+ prerelease: match[4] ? match[4].split('.') : [],
16
+ }
17
+ }
18
+
19
+ function comparePrerelease(a, b) {
20
+ if (a.length === 0 && b.length === 0) return 0
21
+ if (a.length === 0) return 1
22
+ if (b.length === 0) return -1
23
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
24
+ if (a[index] === undefined) return -1
25
+ if (b[index] === undefined) return 1
26
+ const aNumber = /^\d+$/.test(a[index])
27
+ const bNumber = /^\d+$/.test(b[index])
28
+ if (aNumber && bNumber) {
29
+ const delta = Number(a[index]) - Number(b[index])
30
+ if (delta !== 0) return delta
31
+ } else if (aNumber !== bNumber) {
32
+ return aNumber ? -1 : 1
33
+ } else if (a[index] !== b[index]) {
34
+ return a[index] < b[index] ? -1 : 1
35
+ }
36
+ }
37
+ return 0
38
+ }
39
+
40
+ export function compareVersions(a, b) {
41
+ const left = typeof a === 'string' ? parseVersion(a) : a
42
+ const right = typeof b === 'string' ? parseVersion(b) : b
43
+ if (!left || !right) return null
44
+ for (let index = 0; index < 3; index += 1) {
45
+ if (left.numbers[index] !== right.numbers[index]) return left.numbers[index] < right.numbers[index] ? -1 : 1
46
+ }
47
+ return comparePrerelease(left.prerelease, right.prerelease)
48
+ }
49
+
50
+ export function parseCompatibility(text) {
51
+ const rows = []
52
+ for (const line of String(text ?? '').split('\n')) {
53
+ if (!/^\|/.test(line)) continue
54
+ const cells = line.split('|').slice(1, -1).map((cell) => cell.trim())
55
+ if (cells.length < 4) continue
56
+ if (!parseVersion(cells[0])) continue
57
+ rows.push({ hostVersion: cells[0], commit: cells[1], verifiedOn: cells[2], ran: cells[3] })
58
+ }
59
+ return rows
60
+ }
61
+
62
+ export function readCompatibility(file = path.join(PACKAGE_ROOT, 'COMPATIBILITY.md')) {
63
+ try {
64
+ return parseCompatibility(fs.readFileSync(file, 'utf8'))
65
+ } catch {
66
+ return []
67
+ }
68
+ }
69
+
70
+ export function assessHostVersion(hostVersion, rows) {
71
+ const parsed = parseVersion(hostVersion)
72
+ if (!parsed) return { verdict: 'unknown', row: null, detail: 'cannot parse the running host version' }
73
+ const exact = rows.find((row) => compareVersions(row.hostVersion, parsed) === 0)
74
+ if (exact) return { verdict: 'verified', row: exact, detail: `${exact.hostVersion} verified on ${exact.verifiedOn}` }
75
+ const known = rows
76
+ .map((row) => ({ row, cmp: compareVersions(parsed, row.hostVersion) }))
77
+ .filter((entry) => entry.cmp !== null)
78
+ if (known.length === 0) return { verdict: 'unknown', row: null, detail: 'COMPATIBILITY.md has no version rows' }
79
+ const newest = known.reduce((best, entry) => (entry.cmp > best.cmp ? entry : best), known[0])
80
+ if (newest.cmp > 0) {
81
+ return {
82
+ verdict: 'untested-newer',
83
+ row: newest.row,
84
+ detail: `${hostVersion} is newer than every verified build (latest row ${newest.row.hostVersion})`,
85
+ }
86
+ }
87
+ return {
88
+ verdict: 'untested-older',
89
+ row: newest.row,
90
+ detail: `${hostVersion} is older than the verified builds (latest row ${newest.row.hostVersion})`,
91
+ }
92
+ }
93
+
94
+ export function detectHostVersion(ctx, env = process.env) {
95
+ for (const value of [env.DSH_VERSION, env.DSH_HOST_VERSION, env.npm_config_dsh_version]) {
96
+ if (typeof value === 'string' && parseVersion(value)) return value
97
+ }
98
+ for (const serviceName of ['app', 'runtime', 'host']) {
99
+ try {
100
+ const service = ctx?.get?.(serviceName)
101
+ const value = service && (service.version ?? service.build?.version)
102
+ if (typeof value === 'string' && parseVersion(value)) return value
103
+ } catch {
104
+ // defensive: a missing service is not a doctor failure
105
+ }
106
+ }
107
+ return null
108
+ }