dsh-tower 2.0.4 → 2.1.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 (184) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/COMPATIBILITY.md +39 -0
  3. package/CONTRIBUTING.md +157 -2
  4. package/README.en.md +46 -3
  5. package/README.md +44 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +12 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/archive.js +190 -0
  11. package/app/batch.js +79 -0
  12. package/app/board.js +27 -0
  13. package/app/checkCompletion.js +89 -0
  14. package/app/enrichedMissions.js +26 -0
  15. package/app/findings.js +78 -0
  16. package/app/gate.js +126 -0
  17. package/app/init.js +291 -0
  18. package/app/merge.js +573 -0
  19. package/app/messages.js +66 -0
  20. package/app/mission.js +37 -0
  21. package/app/missionContext.js +14 -0
  22. package/app/plan.js +50 -0
  23. package/app/provision.js +193 -0
  24. package/app/read.js +113 -0
  25. package/app/rebase.js +302 -0
  26. package/app/recording.js +72 -0
  27. package/app/review.js +147 -0
  28. package/app/roster.js +123 -0
  29. package/app/spawn.js +125 -0
  30. package/app/teardown.js +205 -0
  31. package/app/tower.js +552 -0
  32. package/cordis.patch.yml +61 -53
  33. package/docs/README.md +3 -1
  34. package/docs/agents/domain.md +38 -0
  35. package/docs/agents/issue-tracker.md +46 -0
  36. package/docs/agents/triage-labels.md +15 -0
  37. package/docs/architecture/overview.md +114 -13
  38. package/docs/board.md +72 -0
  39. package/docs/guide/hot-reload.md +1 -1
  40. package/docs/guide/install-upgrade-migration.md +25 -1
  41. package/docs/guide/release.md +125 -0
  42. package/docs/operations/security.md +47 -2
  43. package/docs/reference/blockers.md +1 -1
  44. package/docs/reference/config.generated.md +70 -0
  45. package/docs/reference/config.md +8 -1
  46. package/docs/reference/doctor.md +58 -8
  47. package/docs/reference/errors.generated.md +41 -0
  48. package/docs/reference/errors.md +7 -3
  49. package/docs/reference/tools.generated.md +33 -0
  50. package/docs/reference/tools.md +7 -6
  51. package/docs/zh/README.md +3 -2
  52. package/docs/zh/guide/hot-reload.md +1 -1
  53. package/docs/zh/guide/install-upgrade-migration.md +18 -1
  54. package/docs/zh/operations/security.md +30 -1
  55. package/docs/zh/reference/blockers.md +1 -1
  56. package/docs/zh/reference/config.md +8 -1
  57. package/docs/zh/reference/doctor.md +35 -2
  58. package/docs/zh/reference/errors.md +6 -2
  59. package/docs/zh/reference/tools.md +3 -3
  60. package/domain/collab/batch.js +112 -0
  61. package/domain/collab/messages.js +127 -0
  62. package/domain/collab/roster.js +158 -0
  63. package/domain/collab/spawn.js +188 -0
  64. package/domain/config/schema.js +427 -0
  65. package/domain/contracts/outputs.js +305 -0
  66. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  67. package/domain/contracts/tool-inputs/findings.js +29 -0
  68. package/domain/contracts/tool-inputs/index.js +94 -0
  69. package/domain/contracts/tool-inputs/integration.js +37 -0
  70. package/domain/contracts/tool-inputs/missions.js +95 -0
  71. package/domain/contracts/tool-inputs/ops.js +75 -0
  72. package/domain/contracts/tool-inputs/shared.js +25 -0
  73. package/domain/errors.js +98 -0
  74. package/domain/gate.js +125 -0
  75. package/domain/integration/gate.js +190 -0
  76. package/domain/integration/init.js +261 -0
  77. package/domain/integration/liveness.js +31 -0
  78. package/domain/integration/merge.js +377 -0
  79. package/domain/integration/provision.js +124 -0
  80. package/domain/integration/rebase.js +236 -0
  81. package/domain/integration/review.js +52 -0
  82. package/domain/integration/teardown.js +118 -0
  83. package/domain/mission.js +68 -0
  84. package/domain/missions/completion.js +167 -0
  85. package/domain/missions/findings.js +201 -0
  86. package/domain/missions/lifecycle.js +186 -0
  87. package/domain/missions/plan.js +149 -0
  88. package/domain/missions/review.js +179 -0
  89. package/domain/planning.js +121 -0
  90. package/domain/read/enrichedMissions.js +58 -0
  91. package/domain/read/missionContext.js +53 -0
  92. package/domain/read/views.js +141 -0
  93. package/domain/report.js +67 -0
  94. package/domain/roster.js +40 -0
  95. package/domain/scope.js +177 -0
  96. package/domain/state/archive.js +257 -0
  97. package/domain/state/migrations.js +145 -0
  98. package/domain/state/schema.js +239 -0
  99. package/domain/state/semantics.js +117 -0
  100. package/domain/state.js +183 -0
  101. package/domain/summary.js +116 -0
  102. package/domain/vocabulary.js +179 -0
  103. package/index.js +63 -20
  104. package/lib/archive.js +279 -0
  105. package/lib/briefing.js +176 -0
  106. package/lib/caseSensitivity.js +66 -0
  107. package/lib/compatibility.js +108 -0
  108. package/lib/config.js +33 -375
  109. package/lib/doctor.js +275 -4
  110. package/lib/git.js +60 -16
  111. package/lib/hmr.js +4 -3
  112. package/lib/leases.js +17 -7
  113. package/lib/legacy.js +39 -14
  114. package/lib/modeState.js +39 -0
  115. package/lib/modeStore.js +11 -43
  116. package/lib/persistence.js +356 -0
  117. package/lib/presets.js +35 -0
  118. package/lib/spawn-prechecks.js +168 -0
  119. package/lib/spawn-support.js +79 -0
  120. package/lib/stateFile.js +99 -0
  121. package/lib/store.js +67 -135
  122. package/lib/tests.js +17 -12
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +2 -2
  125. package/lib/worktreeOwnership.js +22 -10
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +32 -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 +9 -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 +212 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/tools/adopt.js +72 -0
  152. package/tools/batch.js +78 -0
  153. package/tools/complete.js +53 -0
  154. package/tools/diff.js +39 -0
  155. package/tools/doctor.js +50 -0
  156. package/tools/finding.js +40 -0
  157. package/tools/findings.js +39 -0
  158. package/tools/import-legacy.js +42 -0
  159. package/tools/inbox.js +39 -0
  160. package/tools/index.js +39 -0
  161. package/tools/init.js +63 -0
  162. package/tools/interrupt.js +42 -0
  163. package/tools/legacy-map.js +44 -0
  164. package/tools/merge.js +39 -0
  165. package/tools/mission.js +41 -0
  166. package/tools/plan.js +39 -0
  167. package/tools/preflight.js +45 -0
  168. package/tools/rebase.js +39 -0
  169. package/tools/recover.js +45 -0
  170. package/tools/reload.js +52 -0
  171. package/tools/resource.js +54 -0
  172. package/tools/review.js +45 -0
  173. package/tools/roster.js +60 -0
  174. package/tools/send.js +147 -0
  175. package/tools/spawn.js +196 -0
  176. package/tools/status.js +102 -0
  177. package/tools/teardown.js +42 -0
  178. package/tools/test.js +61 -0
  179. package/types/cordis.d.ts +6 -0
  180. package/types/index.d.ts +373 -0
  181. package/lib/errors.js +0 -123
  182. package/lib/tools.js +0 -2460
  183. package/lib/tower.js +0 -2912
  184. /package/{lib → domain}/batch.js +0 -0
@@ -0,0 +1,188 @@
1
+ // Pure spawn reservation/outcome policy: roster entry projections, role and
2
+ // live-worker limit decisions.
3
+ //
4
+ // app/spawn.js owns the state lock, load, Git spawn-tip capture, persistence,
5
+ // and activity log. Preset/provider selection happens in tools/spawn.js and is
6
+ // intentionally not duplicated here.
7
+ // @ts-check
8
+ import { TowerToolError } from '../errors.js'
9
+ import { ROSTER_INACTIVE, countLiveWorkers, workerEntries } from '../roster.js'
10
+ import { clearStale } from './roster.js'
11
+
12
+ /** @typedef {import('../../types/index.js').Mission} Mission */
13
+ /** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
14
+
15
+ /**
16
+ * @param {string} message
17
+ * @returns {never}
18
+ */
19
+ function fail(message) {
20
+ throw new TowerToolError('invalid-argument', message)
21
+ }
22
+
23
+ /**
24
+ * @param {unknown} role
25
+ * @returns {boolean}
26
+ */
27
+ export function isWorkerRole(role) {
28
+ return role === 'worker'
29
+ }
30
+
31
+ /**
32
+ * The configured live-worker ceiling, defaulting to four.
33
+ * @param {unknown} maxLiveWorkers
34
+ * @returns {number}
35
+ */
36
+ export function liveWorkerLimit(maxLiveWorkers) {
37
+ return Number(maxLiveWorkers) || 4
38
+ }
39
+
40
+ /**
41
+ * Record that a spawned agent failed or went idle.
42
+ * @param {RosterEntry} entry
43
+ * @param {{ state?: string, detail?: unknown }} input
44
+ * @param {number} now
45
+ * @returns {RosterEntry}
46
+ */
47
+ export function applySpawnOutcome(entry, input, now) {
48
+ entry.state = input.state ?? 'failed'
49
+ entry.outcome = input.detail ? String(input.detail).slice(0, 1000) : null
50
+ entry.outcomeAt = now
51
+ clearStale(entry)
52
+ return entry
53
+ }
54
+
55
+ /**
56
+ * Record that a live agent was interrupted by the tower.
57
+ * @param {RosterEntry} entry
58
+ * @param {{ reason?: unknown }} input
59
+ * @param {number} now
60
+ * @returns {RosterEntry}
61
+ */
62
+ export function applyInterrupt(entry, input, now) {
63
+ entry.state = 'interrupted'
64
+ entry.outcome = String(input.reason ?? 'interrupted by tower').slice(0, 1000)
65
+ entry.outcomeAt = now
66
+ clearStale(entry)
67
+ return entry
68
+ }
69
+
70
+ /**
71
+ * A reserved roster row: the name is claimed before activation so a failed
72
+ * spawn remains recoverable.
73
+ * @param {any} input
74
+ * @param {number} now
75
+ * @returns {RosterEntry}
76
+ */
77
+ export function reservedRosterEntry(input, now) {
78
+ return {
79
+ name: input.name,
80
+ role: input.role,
81
+ agentId: null,
82
+ sessionId: null,
83
+ parentSessionId: input.parentSessionId,
84
+ missionId: input.missionId,
85
+ cwd: input.cwd ?? null,
86
+ branch: input.branch ?? null,
87
+ spawnTip: null,
88
+ state: 'reserved',
89
+ reservedAt: now,
90
+ spawnedAt: now,
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Validate a reservation request and project its row.
96
+ * @param {{ roster: RosterEntry[], input: any, now: number }} params
97
+ * @returns {RosterEntry}
98
+ */
99
+ export function reserveRosterEntry({ roster, input, now }) {
100
+ if (roster.some((entry) => entry.name === input.name)) fail(`roster name already used: ${input.name}`)
101
+ return reservedRosterEntry(input, now)
102
+ }
103
+
104
+ /**
105
+ * Validate and apply a spawn record. Mutates the roster (retiring prior worker
106
+ * rows and inserting/reusing the entry) and the mission owner/status/gen.
107
+ * @param {{ roster: RosterEntry[], mission: Mission, input: any, spawnTip: string | null, maxLiveWorkers: unknown, now: number }} params
108
+ * @returns {RosterEntry}
109
+ */
110
+ export function recordSpawnEntry({ roster, mission, input, spawnTip, maxLiveWorkers, now }) {
111
+ const reserved = roster.find((entry) => entry.name === input.name && entry.state === 'reserved' && !entry.agentId)
112
+ if (!reserved && roster.some((entry) => entry.name === input.name)) {
113
+ fail(`roster name already used: ${input.name}`)
114
+ }
115
+ if (roster.some((entry) => entry.agentId && entry.agentId === input.agentId)) {
116
+ fail(`agent ${input.agentId} is already in the roster`)
117
+ }
118
+ /** @type {RosterEntry} */
119
+ const entry = {
120
+ name: input.name,
121
+ role: input.role,
122
+ agentId: input.agentId,
123
+ sessionId: input.sessionId ?? input.agentId,
124
+ parentSessionId: input.parentSessionId,
125
+ missionId: input.missionId,
126
+ cwd: input.cwd ?? null,
127
+ branch: input.branch ?? null,
128
+ spawnTip,
129
+ state: 'active',
130
+ spawnedAt: now,
131
+ }
132
+ if (isWorkerRole(input.role)) {
133
+ const snapshot = /** @type {any} */ ({ roster })
134
+ const previous = workerEntries(snapshot, mission.id)
135
+ const live = previous.find((row) => !ROSTER_INACTIVE.has(row.state) && row.state !== 'reserved')
136
+ if (live && !input.takeover) {
137
+ fail(
138
+ `mission ${mission.id} already has a live worker "${live.name}"; stop it with tower_interrupt, or pass takeover:true after it is no longer running`,
139
+ )
140
+ }
141
+ // A new owner always retires the mission's older worker entries, including
142
+ // idle `inactive` ones; otherwise the next spawn would see several entries
143
+ // for the same mission and the old idle worker would block forever.
144
+ for (const old of previous) {
145
+ if (old.state === 'retired' || old.state === 'reserved') continue
146
+ old.state = 'retired'
147
+ old.outcome = `replaced by ${input.name}${input.takeover ? ' (takeover)' : ''}`
148
+ old.outcomeAt = now
149
+ clearStale(old)
150
+ }
151
+ const max = liveWorkerLimit(maxLiveWorkers)
152
+ if (countLiveWorkers(snapshot) >= max) {
153
+ fail(`live worker limit reached (${max}); stop a worker or raise spawn.maxLiveWorkers`)
154
+ }
155
+ const generation = (mission.ownerGeneration ?? 0) + 1
156
+ entry.generation = generation
157
+ mission.ownerGeneration = generation
158
+ mission.owner = input.name
159
+ if (mission.status === 'planned') mission.status = 'active'
160
+ }
161
+ if (reserved) Object.assign(reserved, entry)
162
+ else roster.push(entry)
163
+ return reserved ?? entry
164
+ }
165
+
166
+ /**
167
+ * Pure read projection for `tower_roster`.
168
+ * @param {import('../../types/index.js').TowerState} state
169
+ * @returns {any}
170
+ */
171
+ export function rosterProjection(state) {
172
+ return {
173
+ revision: state.revision,
174
+ roster: (state.roster ?? []).map((entry) => ({ ...entry })),
175
+ missions: state.missions.map((mission) => ({
176
+ id: mission.id,
177
+ status: mission.status,
178
+ owner: mission.owner ?? null,
179
+ worktree: mission.worktree
180
+ ? {
181
+ path: mission.worktree.path,
182
+ branch: mission.worktree.branch,
183
+ baseCommit: mission.worktree.baseCommit,
184
+ }
185
+ : null,
186
+ })),
187
+ }
188
+ }
@@ -0,0 +1,427 @@
1
+ // Pure Tower configuration schema and default resolution (#56 P2a).
2
+ //
3
+ // This module is pure: it declares the Schemastery schema, validates raw config
4
+ // objects, and materializes defaults. Profile and repository loading (fs and the
5
+ // process-local profile snapshot) live in lib/config.js, which re-exports
6
+ // `Config` and `resolveTowerConfig` for compatibility.
7
+ //
8
+ // Layers, lowest precedence first:
9
+ // 1. code defaults (the schemastery `.default(...)` values)
10
+ // 2. profile config (the Cordis patch passes it to `apply()`)
11
+ // 3. repository config: <git-common-dir>/dsh-tower/config.json
12
+ // 4. per-tool arguments (e.g. worktreeRoot, timeoutMs)
13
+ //
14
+ // Defaults are exactly the 1.2.0 behavior; the shipped cordis patch keeps
15
+ // `allowAgentControl: true` via the legacy top-level key.
16
+ import Schema from '@deepseek-ai/schemastery'
17
+ import { TowerToolError } from '../errors.js'
18
+ import { DEFAULT_MAX_REPORT_BYTES } from '../missions/completion.js'
19
+
20
+ export const DEFAULT_DENY_TOOLS = [
21
+ 'create_goal',
22
+ 'todo_write',
23
+ 'team_task_create',
24
+ 'team_task_update',
25
+ 'team_task_reopen',
26
+ 'team_task_list',
27
+ 'spawn_teammate',
28
+ 'wait_agent',
29
+ 'workflow',
30
+ 'ralph',
31
+ 'subagent',
32
+ 'subagent_fork',
33
+ ]
34
+
35
+ const LEGACY_KEYS = ['section', 'allowAgentControl']
36
+ const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'git', 'worktrees', 'spawn', 'send', 'tests', 'gates', 'docs', 'archive', 'leases', 'dev', 'tools']
37
+
38
+ const SECTION_KEYS = {
39
+ init: ['autoGitInit', 'snapshotDirty', 'autoExclude'],
40
+ mode: [
41
+ 'promptSection',
42
+ 'sectionAppend',
43
+ 'allowAgentControl',
44
+ 'autoPauseGoal',
45
+ 'requireResumeAuthorization',
46
+ 'requireHumanAckOnFirstEntry',
47
+ 'workspaceLock',
48
+ 'workspaceLockScope',
49
+ 'planModePolicy',
50
+ 'workspaceStaleMs',
51
+ 'heartbeatMs',
52
+ 'denyTools',
53
+ 'storePath',
54
+ 'maxEntries',
55
+ 'lockTimeoutMs',
56
+ ],
57
+ review: ['requireReview', 'maxRoundsPerReviewer'],
58
+ merge: ['timeoutMs', 'maxBufferBytes'],
59
+ git: ['timeoutMs'],
60
+ worktrees: ['root'],
61
+ spawn: [
62
+ 'workerPreset',
63
+ 'reviewerPreset',
64
+ 'maxDepth',
65
+ 'provider',
66
+ 'workerModel',
67
+ 'reviewerModel',
68
+ 'maxLiveWorkers',
69
+ 'staleMs',
70
+ 'maxWaitMs',
71
+ 'requireLease',
72
+ ],
73
+ send: ['maxPerMinute', 'interruptDefault'],
74
+ tests: ['enabled', 'admission', 'timeoutMs', 'maxBytes', 'autoChecks', 'allowPackageDiscovery'],
75
+ gates: ['requireChecks', 'requireTests', 'allowReviewExemption', 'maxReportBytes'],
76
+ docs: ['board', 'boardLocation'],
77
+ archive: ['enabled', 'afterDays', 'maxMissions'],
78
+ tools: ['exposeOperational', 'exposeBatch', 'exposeImport', 'exposeLeases', 'exposeTests', 'exposeRecovery', 'exposeDoctor'],
79
+ leases: ['ttlMs'],
80
+ dev: ['hotReload', 'reloadDelayMs', 'reloadRequiredIds'],
81
+ }
82
+
83
+ const MODEL_KEYS = ['provider', 'model', 'reasoningEffort', 'maxTokens']
84
+
85
+ const ModelSchema = () =>
86
+ Schema.object({
87
+ provider: Schema.string().description('Provider route override for this role.'),
88
+ model: Schema.string().description('Model id override for this role.'),
89
+ reasoningEffort: Schema.string().description('Optional reasoning effort override.'),
90
+ maxTokens: Schema.natural().description('Optional output token cap.'),
91
+ })
92
+
93
+ export const Config = Schema.object({
94
+ section: Schema.string().description(
95
+ 'Legacy alias for mode.promptSection: replaces the whole Tower-mode system prompt text.',
96
+ ),
97
+ allowAgentControl: Schema.boolean().description(
98
+ 'Legacy alias for mode.allowAgentControl: register tower_control so the model may toggle the mode.',
99
+ ),
100
+ init: Schema.object({
101
+ autoGitInit: Schema.boolean()
102
+ .default(false)
103
+ .description('tower_init may run git init and create an empty initial commit when the directory is not a repository.'),
104
+ snapshotDirty: Schema.boolean()
105
+ .default(false)
106
+ .description('tower_init may snapshot uncommitted tracked changes into a dangling commit used as the mission baseline.'),
107
+ autoExclude: Schema.boolean()
108
+ .default(true)
109
+ .description('Add in-repo Tower artifacts (MISSIONS.md, in-repo worktree roots) to .git/info/exclude so they do not look like user dirt.'),
110
+ })
111
+ .default({})
112
+ .description('Repository initialization policy.'),
113
+ mode: Schema.object({
114
+ promptSection: Schema.string().description('Replacement Tower-mode system prompt text. Empty keeps the built-in reminder.'),
115
+ sectionAppend: Schema.string().description('Extra text appended to the Tower-mode system prompt section (team conventions).'),
116
+ allowAgentControl: Schema.boolean().description('Register tower_control so the model may enter/leave the mode with a reason.'),
117
+ autoPauseGoal: Schema.boolean().default(true).description('Pause an active goal when entering Tower mode.'),
118
+ requireResumeAuthorization: Schema.boolean()
119
+ .default(false)
120
+ .description('Require an authorization reference when resuming a mission paused with pauseCause=user.'),
121
+ requireHumanAckOnFirstEntry: Schema.boolean()
122
+ .default(false)
123
+ .description('Require a human /tower ack before the model may enter Tower mode for the first time in a session.'),
124
+ workspaceLock: Schema.boolean()
125
+ .default(false)
126
+ .description('Allow only one Tower session per repository (advisory <git-common-dir>/dsh-tower/tower.lock).'),
127
+ workspaceLockScope: Schema.union(['all', 'mutations'])
128
+ .default('all')
129
+ .description('all = every repo-scoped tool acquires the workspace lock; mutations = only tools that write state/worktrees do.'),
130
+ planModePolicy: Schema.union(['clear', 'refuse'])
131
+ .default('clear')
132
+ .description('On Tower entry: clear an active plan mode, or refuse to enter until the plan is exited.'),
133
+ workspaceStaleMs: Schema.natural()
134
+ .min(1000)
135
+ .default(300000)
136
+ .description('A workspace lock older than this is stale and can be taken over.'),
137
+ heartbeatMs: Schema.natural().min(1000).default(10000).description('Workspace-lock heartbeat interval while Tower mode is on.'),
138
+ denyTools: Schema.array(Schema.string())
139
+ .default(DEFAULT_DENY_TOOLS)
140
+ .description('Tools refused while Tower mode is active.'),
141
+ storePath: Schema.string().description('Directory for modes.json; defaults to $DSH_HOME/tower-mode.'),
142
+ maxEntries: Schema.natural().min(1).max(2000).default(200).description('Maximum persisted mode entries.'),
143
+ lockTimeoutMs: Schema.natural().min(10).max(60000).default(5000).description('How long saveModes waits for the cross-process modes.lock before giving up.'),
144
+ })
145
+ .default({})
146
+ .description('Tower-mode behavior and persistence.'),
147
+ review: Schema.object({
148
+ requireReview: Schema.boolean()
149
+ .default(false)
150
+ .description('Require an independent clean review before merge. Default false: review is optional; set true to enforce the review gate globally.'),
151
+ maxRoundsPerReviewer: Schema.natural().min(1).default(5).description('Review-round cap per reviewer per mission branch.'),
152
+ })
153
+ .default({})
154
+ .description('Review policy.'),
155
+ merge: Schema.object({
156
+ timeoutMs: Schema.natural().min(1000).default(900000).description('Default tower_merge timeout, including pre-merge hooks.'),
157
+ maxBufferBytes: Schema.natural().min(65536).default(67108864).description('Captured merge output buffer.'),
158
+ })
159
+ .default({})
160
+ .description('Merge execution policy.'),
161
+ git: Schema.object({
162
+ timeoutMs: Schema.natural().min(1000).default(15000).description('Default timeout for Git commands other than merge; merge uses merge.timeoutMs.'),
163
+ })
164
+ .default({})
165
+ .description('Git subprocess policy.'),
166
+ worktrees: Schema.object({
167
+ root: Schema.string().description('Default parent directory for mission worktrees; empty derives <main-checkout-parent>/.dsh-tower-worktrees/<hash>.'),
168
+ })
169
+ .default({})
170
+ .description('Worktree placement.'),
171
+ spawn: Schema.object({
172
+ workerPreset: Schema.string().default('tower-worker').description('Agent preset for worker missions.'),
173
+ reviewerPreset: Schema.string().default('tower-reviewer').description('Agent preset for reviewers.'),
174
+ maxDepth: Schema.natural().min(1).default(1).description('Subagent depth requested when spawning tower agents.'),
175
+ provider: Schema.string().description('Subagent provider id; empty picks spawn, then the first registered provider.'),
176
+ workerModel: ModelSchema().description('Model route for workers; omitted inherits the parent session.'),
177
+ reviewerModel: ModelSchema().description('Model route for reviewers; omitted inherits the parent session (recommended).'),
178
+ maxLiveWorkers: Schema.natural()
179
+ .min(1)
180
+ .max(64)
181
+ .default(4)
182
+ .description('Maximum live worker entries allowed per repository at once.'),
183
+ staleMs: Schema.natural()
184
+ .min(0)
185
+ .default(15000)
186
+ .description('Roster entries with no catalog activity after this age are marked stale and block merge (0 disables).'),
187
+ maxWaitMs: Schema.natural()
188
+ .min(1000)
189
+ .max(600000)
190
+ .default(90000)
191
+ .description('Upper bound for tower_spawn waitMs (default 90s, Kimi-compatible).'),
192
+ requireLease: Schema.string().description('Optional lease name tower_spawn must hold before starting a worker.'),
193
+ })
194
+ .default({})
195
+ .description('Worker and reviewer presets plus model routing.'),
196
+ send: Schema.object({
197
+ maxPerMinute: Schema.natural()
198
+ .min(0)
199
+ .default(120)
200
+ .description('Per-repository tower_send rate limit per minute (0 disables).'),
201
+ interruptDefault: Schema.boolean()
202
+ .default(false)
203
+ .description('Default value of tower_send.interrupt when the caller omits it.'),
204
+ })
205
+ .default({})
206
+ .description('Message delivery policy.'),
207
+ tests: Schema.object({
208
+ enabled: Schema.boolean().default(true).description('Run declared test discovery when a mission configures it.'),
209
+ admission: Schema.union(['spawn', 'complete', 'off'])
210
+ .default('off')
211
+ .description('Run discovery before spawning a worker, when completing, or only on explicit tower_test (default off: repository code runs only on an explicit tower_test).'),
212
+ timeoutMs: Schema.natural().min(1000).max(1800000).default(60000).description('Per discovery command timeout.'),
213
+ maxBytes: Schema.natural().min(1024).max(16 * 1024 * 1024).default(16 * 1024 * 1024).description('Maximum captured stdout bytes per command.'),
214
+ autoChecks: Schema.boolean().default(true).description('Attach a tests check to the completion report when discovery runs.'),
215
+ allowPackageDiscovery: Schema.boolean()
216
+ .default(true)
217
+ .description('Allow package.json tower.testDiscovery commands from the mission worktree (executes repository code; see security docs).'),
218
+ })
219
+ .default({})
220
+ .description('Declared test discovery and admission.'),
221
+ gates: Schema.object({
222
+ requireChecks: Schema.boolean()
223
+ .default(false)
224
+ .description('Require at least one completion check (tower_complete.checks) before a mission can merge.'),
225
+ requireTests: Schema.boolean()
226
+ .default(false)
227
+ .description('Require a passing test-discovery receipt for the reviewed worktree tip before merge.'),
228
+ allowReviewExemption: Schema.boolean()
229
+ .default(false)
230
+ .description('Allow reviewPolicy:none missions with a non-empty reviewExemptionReason to skip review blockers.'),
231
+ maxReportBytes: Schema.natural()
232
+ .min(1024)
233
+ .max(16 * 1024 * 1024)
234
+ .default(DEFAULT_MAX_REPORT_BYTES)
235
+ .description('Hard cap, in UTF-8 bytes, for tower_complete.report; a larger report is rejected with an actionable nextAction, never truncated.'),
236
+ })
237
+ .default({})
238
+ .description('Merge-gate strictness.'),
239
+ docs: Schema.object({
240
+ board: Schema.boolean()
241
+ .default(true)
242
+ .description('Write a human-readable MISSIONS.md after mission changes.'),
243
+ boardLocation: Schema.union(['common', 'repo'])
244
+ .default('common')
245
+ .description('common = <git-common-dir>/dsh-tower/MISSIONS.md; repo = <repoRoot>/MISSIONS.md.'),
246
+ })
247
+ .default({})
248
+ .description('Human-readable artifacts.'),
249
+ archive: Schema.object({
250
+ enabled: Schema.boolean()
251
+ .default(true)
252
+ .description('Move terminal (merged/abandoned) missions whose worktree is gone into <git-common-dir>/dsh-tower/archive/ at the next mutation, so state.json stops carrying their full history.'),
253
+ afterDays: Schema.natural()
254
+ .min(0)
255
+ .default(30)
256
+ .description('Terminal missions untouched for at least this many days are archived on the next mutation (0 archives them immediately).'),
257
+ maxMissions: Schema.natural()
258
+ .min(0)
259
+ .default(100)
260
+ .description('Maximum terminal missions kept hot; once exceeded, the oldest eligible terminal missions are archived until the cap holds (0 archives every eligible terminal mission).'),
261
+ })
262
+ .default({})
263
+ .description('Terminal-mission archival policy. Archived missions are moved, never deleted.'),
264
+ tools: Schema.object({
265
+ exposeOperational: Schema.boolean()
266
+ .default(true)
267
+ .description('Master switch for batch/resource/import/legacy/recover/test/doctor tools.'),
268
+ exposeBatch: Schema.boolean().default(true).description('Register tower_batch.'),
269
+ exposeImport: Schema.boolean().default(true).description('Register tower_import_legacy/tower_legacy_map.'),
270
+ exposeLeases: Schema.boolean().default(true).description('Register tower_resource advisory leases.'),
271
+ exposeTests: Schema.boolean().default(true).description('Register tower_test; gates.requireTests requires it.'),
272
+ exposeRecovery: Schema.boolean().default(true).description('Register tower_recover (stale state-lock escape).'),
273
+ exposeDoctor: Schema.boolean().default(true).description('Register tower_doctor (read-only health contract).'),
274
+ })
275
+ .default({})
276
+ .description('Tool-surface policy.'),
277
+ leases: Schema.object({
278
+ ttlMs: Schema.natural().min(1000).max(86400000).default(900000).description('Default advisory lease lifetime; expiry never auto-releases.'),
279
+ })
280
+ .default({})
281
+ .description('Advisory resource leases.'),
282
+ dev: Schema.object({
283
+ hotReload: Schema.boolean().default(true).description('Register tower_reload and /tower reload when the DSH HMR service is available.'),
284
+ reloadDelayMs: Schema.natural().min(1).max(5000).default(50).description('Delay before a scheduled reload, so the tool result is delivered first.'),
285
+ reloadRequiredIds: Schema.array(Schema.string())
286
+ .default(['tower-bundle', 'tower', 'tower-skills', 'preset-tower-worker', 'preset-tower-reviewer'])
287
+ .description('Loader entry ids passed to reconcileProfile as required.'),
288
+ })
289
+ .default({})
290
+ .description('Development and hot-reload behavior.'),
291
+ }).description('DSH Tower configuration')
292
+
293
+ export function clone(value) {
294
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value))
295
+ }
296
+
297
+ export function isPlainObject(value) {
298
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
299
+ }
300
+
301
+ export function deepMerge(base, override) {
302
+ if (override === undefined) return clone(base) ?? {}
303
+ if (!isPlainObject(base) || !isPlainObject(override)) return clone(override)
304
+ const out = clone(base)
305
+ for (const [key, value] of Object.entries(override)) {
306
+ out[key] = value === undefined ? out[key] : deepMerge(base[key], value)
307
+ }
308
+ return out
309
+ }
310
+
311
+ /** Move the legacy top-level aliases into their canonical group keys. */
312
+ export function normalizeLegacy(raw = {}) {
313
+ const out = clone(raw) ?? {}
314
+ if (out.section !== undefined) {
315
+ const mode = { ...(out.mode ?? {}) }
316
+ if (mode.promptSection === undefined) mode.promptSection = out.section
317
+ out.mode = mode
318
+ delete out.section
319
+ }
320
+ if (out.allowAgentControl !== undefined) {
321
+ const mode = { ...(out.mode ?? {}) }
322
+ if (mode.allowAgentControl === undefined) mode.allowAgentControl = out.allowAgentControl
323
+ out.mode = mode
324
+ delete out.allowAgentControl
325
+ }
326
+ return out
327
+ }
328
+
329
+ function assertModelKeys(value, label) {
330
+ const bad = Object.keys(value).filter((key) => !MODEL_KEYS.includes(key))
331
+ if (bad.length > 0) {
332
+ throw new TowerToolError('config-unknown-key', `${label} has unknown key(s) ${bad.join(', ')}`)
333
+ }
334
+ }
335
+
336
+ export function assertKnownKeys(raw, label) {
337
+ const unknown = Object.keys(raw).filter((key) => !LEGACY_KEYS.includes(key) && !GROUP_KEYS.includes(key))
338
+ if (unknown.length > 0) {
339
+ throw new TowerToolError(
340
+ 'config-unknown-key',
341
+ `${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, git?, worktrees?, spawn?, send?, tests?, gates?, docs?, archive?, leases?, dev?, tools? }`,
342
+ )
343
+ }
344
+ for (const [group, allowed] of Object.entries(SECTION_KEYS)) {
345
+ const value = raw[group]
346
+ if (value === undefined) continue
347
+ if (!value || typeof value !== 'object' || Array.isArray(value)) continue
348
+ const bad = Object.keys(value).filter((key) => !allowed.includes(key))
349
+ if (bad.length > 0) {
350
+ throw new TowerToolError(
351
+ 'config-unknown-key',
352
+ `${label} has unknown key(s) ${bad.map((key) => `${group}.${key}`).join(', ')}`,
353
+ )
354
+ }
355
+ if ((group === 'spawn') && value.workerModel && typeof value.workerModel === 'object') {
356
+ assertModelKeys(value.workerModel, `${label} spawn.workerModel`)
357
+ }
358
+ if ((group === 'spawn') && value.reviewerModel && typeof value.reviewerModel === 'object') {
359
+ assertModelKeys(value.reviewerModel, `${label} spawn.reviewerModel`)
360
+ }
361
+ }
362
+ }
363
+
364
+ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
365
+
366
+ /** Reject prototype-pollution style keys anywhere in a repository config. */
367
+ export function assertSafeKeys(value, label) {
368
+ if (Array.isArray(value)) {
369
+ for (const item of value) assertSafeKeys(item, label)
370
+ return
371
+ }
372
+ if (!isPlainObject(value)) return
373
+ for (const [key, child] of Object.entries(value)) {
374
+ if (FORBIDDEN_KEYS.has(key)) {
375
+ throw new TowerToolError('config-invalid', `${label} contains the forbidden key "${key}"`)
376
+ }
377
+ assertSafeKeys(child, label)
378
+ }
379
+ }
380
+
381
+ export function resolveTowerConfig(rawConfig) {
382
+ const raw = rawConfig ?? {}
383
+ if (raw !== null && typeof raw !== 'object') throw new TowerToolError('config-invalid', 'Tower config must be an object')
384
+ assertKnownKeys(raw, 'Tower config')
385
+ let parsed
386
+ try {
387
+ parsed = Config(raw)
388
+ } catch (error) {
389
+ throw new TowerToolError('config-invalid', `Tower config is invalid: ${String(error?.message ?? error)}`)
390
+ }
391
+ const mode = clone(parsed.mode ?? {})
392
+ if (parsed.gates?.requireTests === true) {
393
+ const testsAvailable = parsed.tests?.enabled !== false && parsed.tools?.exposeOperational !== false && parsed.tools?.exposeTests !== false
394
+ if (!testsAvailable) {
395
+ throw new TowerToolError('config-invalid', 'Tower config is invalid: gates.requireTests is true but test discovery is disabled (tests.enabled/tools.exposeTests)')
396
+ }
397
+ }
398
+ // Canonical keys win; the legacy top-level aliases only fill gaps.
399
+ const rawMode = raw.mode ?? {}
400
+ if (rawMode.promptSection === undefined && raw.section !== undefined) mode.promptSection = raw.section
401
+ if (rawMode.allowAgentControl === undefined && raw.allowAgentControl !== undefined) {
402
+ mode.allowAgentControl = raw.allowAgentControl
403
+ }
404
+ // Apply the canonical default only after alias resolution: a validated
405
+ // Cordis config may omit this key, and an explicit legacy top-level
406
+ // `allowAgentControl:true` must not be shadowed by a schema default.
407
+ if (mode.allowAgentControl === undefined) mode.allowAgentControl = true
408
+ if (!Array.isArray(mode.denyTools) || mode.denyTools.length === 0) mode.denyTools = [...DEFAULT_DENY_TOOLS]
409
+ return {
410
+ section: mode.promptSection ?? '',
411
+ init: clone(parsed.init ?? {}),
412
+ mode,
413
+ review: clone(parsed.review ?? {}),
414
+ merge: clone(parsed.merge ?? {}),
415
+ git: clone(parsed.git ?? {}),
416
+ worktrees: clone(parsed.worktrees ?? {}),
417
+ spawn: clone(parsed.spawn ?? {}),
418
+ send: clone(parsed.send ?? {}),
419
+ tests: clone(parsed.tests ?? {}),
420
+ gates: clone(parsed.gates ?? {}),
421
+ docs: clone(parsed.docs ?? {}),
422
+ archive: clone(parsed.archive ?? {}),
423
+ tools: clone(parsed.tools ?? {}),
424
+ leases: clone(parsed.leases ?? {}),
425
+ dev: clone(parsed.dev ?? {}),
426
+ }
427
+ }