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.
- package/CHANGELOG.md +152 -0
- package/COMPATIBILITY.md +39 -0
- package/CONTRIBUTING.md +157 -2
- package/README.en.md +46 -3
- package/README.md +44 -3
- package/SECURITY.md +27 -0
- package/adapters/default.js +12 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/archive.js +190 -0
- package/app/batch.js +79 -0
- package/app/board.js +27 -0
- package/app/checkCompletion.js +89 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +126 -0
- package/app/init.js +291 -0
- package/app/merge.js +573 -0
- package/app/messages.js +66 -0
- package/app/mission.js +37 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +50 -0
- package/app/provision.js +193 -0
- package/app/read.js +113 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +147 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +205 -0
- package/app/tower.js +552 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +3 -1
- package/docs/agents/domain.md +38 -0
- package/docs/agents/issue-tracker.md +46 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/architecture/overview.md +114 -13
- package/docs/board.md +72 -0
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +25 -1
- package/docs/guide/release.md +125 -0
- package/docs/operations/security.md +47 -2
- package/docs/reference/blockers.md +1 -1
- package/docs/reference/config.generated.md +70 -0
- package/docs/reference/config.md +8 -1
- package/docs/reference/doctor.md +58 -8
- package/docs/reference/errors.generated.md +41 -0
- package/docs/reference/errors.md +7 -3
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +7 -6
- package/docs/zh/README.md +3 -2
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +18 -1
- package/docs/zh/operations/security.md +30 -1
- package/docs/zh/reference/blockers.md +1 -1
- package/docs/zh/reference/config.md +8 -1
- package/docs/zh/reference/doctor.md +35 -2
- package/docs/zh/reference/errors.md +6 -2
- package/docs/zh/reference/tools.md +3 -3
- package/domain/collab/batch.js +112 -0
- package/domain/collab/messages.js +127 -0
- package/domain/collab/roster.js +158 -0
- package/domain/collab/spawn.js +188 -0
- package/domain/config/schema.js +427 -0
- package/domain/contracts/outputs.js +305 -0
- package/domain/contracts/tool-inputs/collaboration.js +58 -0
- package/domain/contracts/tool-inputs/findings.js +29 -0
- package/domain/contracts/tool-inputs/index.js +94 -0
- package/domain/contracts/tool-inputs/integration.js +37 -0
- package/domain/contracts/tool-inputs/missions.js +95 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +98 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +190 -0
- package/domain/integration/init.js +261 -0
- package/domain/integration/liveness.js +31 -0
- package/domain/integration/merge.js +377 -0
- package/domain/integration/provision.js +124 -0
- package/domain/integration/rebase.js +236 -0
- package/domain/integration/review.js +52 -0
- package/domain/integration/teardown.js +118 -0
- package/domain/mission.js +68 -0
- package/domain/missions/completion.js +167 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +186 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +121 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +141 -0
- package/domain/report.js +67 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +177 -0
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +239 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +183 -0
- package/domain/summary.js +116 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +63 -20
- package/lib/archive.js +279 -0
- package/lib/briefing.js +176 -0
- package/lib/caseSensitivity.js +66 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +33 -375
- package/lib/doctor.js +275 -4
- package/lib/git.js +60 -16
- package/lib/hmr.js +4 -3
- package/lib/leases.js +17 -7
- package/lib/legacy.js +39 -14
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +11 -43
- package/lib/persistence.js +356 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/stateFile.js +99 -0
- package/lib/store.js +67 -135
- package/lib/tests.js +17 -12
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +2 -2
- package/lib/worktreeOwnership.js +22 -10
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +32 -4
- package/ports/clock.js +6 -0
- package/ports/ids.js +7 -0
- package/ports/index.js +2 -0
- package/ports/store.js +11 -0
- package/ports/vcs.js +11 -0
- package/scripts/architecture.mjs +94 -0
- package/scripts/check-docs.mjs +9 -3
- package/scripts/check-pack.mjs +49 -0
- package/scripts/domain-metrics.mjs +48 -0
- package/scripts/generate-config-reference.mjs +46 -0
- package/scripts/generate-error-reference.mjs +29 -0
- package/scripts/generate-tools-reference.mjs +56 -0
- package/scripts/issue-audit.mjs +259 -0
- package/scripts/lib/cli.mjs +76 -0
- package/scripts/lib/tmp.mjs +20 -0
- package/scripts/mount-smoke/fixtures.mjs +162 -0
- package/scripts/mount-smoke/host.json +4 -0
- package/scripts/mount-smoke/probe.mjs +125 -0
- package/scripts/mount-smoke/surface.mjs +141 -0
- package/scripts/mount-smoke.mjs +270 -0
- package/scripts/pack-manifest.txt +212 -0
- package/scripts/release-check.mjs +303 -0
- package/tools/adopt.js +72 -0
- package/tools/batch.js +78 -0
- package/tools/complete.js +53 -0
- package/tools/diff.js +39 -0
- package/tools/doctor.js +50 -0
- package/tools/finding.js +40 -0
- package/tools/findings.js +39 -0
- package/tools/import-legacy.js +42 -0
- package/tools/inbox.js +39 -0
- package/tools/index.js +39 -0
- package/tools/init.js +63 -0
- package/tools/interrupt.js +42 -0
- package/tools/legacy-map.js +44 -0
- package/tools/merge.js +39 -0
- package/tools/mission.js +41 -0
- package/tools/plan.js +39 -0
- package/tools/preflight.js +45 -0
- package/tools/rebase.js +39 -0
- package/tools/recover.js +45 -0
- package/tools/reload.js +52 -0
- package/tools/resource.js +54 -0
- package/tools/review.js +45 -0
- package/tools/roster.js +60 -0
- package/tools/send.js +147 -0
- package/tools/spawn.js +196 -0
- package/tools/status.js +102 -0
- package/tools/teardown.js +42 -0
- package/tools/test.js +61 -0
- package/types/cordis.d.ts +6 -0
- package/types/index.d.ts +373 -0
- package/lib/errors.js +0 -123
- package/lib/tools.js +0 -2460
- package/lib/tower.js +0 -2912
- /package/{lib → domain}/batch.js +0 -0
package/lib/config.js
CHANGED
|
@@ -1,391 +1,50 @@
|
|
|
1
|
-
// Tower configuration:
|
|
2
|
-
// process-local effective snapshot consumed by the domain modules.
|
|
1
|
+
// Tower configuration: process-local profile snapshot and repository layer.
|
|
3
2
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// 3. repository config: <git-common-dir>/dsh-tower/config.json
|
|
8
|
-
// 4. per-tool arguments (e.g. worktreeRoot, timeoutMs)
|
|
3
|
+
// The pure Schemastery schema, default resolution, and raw-config validation
|
|
4
|
+
// live in domain/config/schema.js (#56 P2a). This module keeps only the layers
|
|
5
|
+
// that need process or filesystem state:
|
|
9
6
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
7
|
+
// profile -> the raw config `apply()` received, resolved with code defaults
|
|
8
|
+
// on every read (runtimeConfig)
|
|
9
|
+
// repo -> <git-common-dir>/dsh-tower/config.json merged over the profile
|
|
10
|
+
//
|
|
11
|
+
// `Config` and `resolveTowerConfig` are re-exported for compatibility; new
|
|
12
|
+
// code should import them from domain/config/schema.js.
|
|
13
|
+
import { fsp } from '../adapters/store-fs.js'
|
|
13
14
|
import path from 'node:path'
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
'subagent',
|
|
29
|
-
'subagent_fork',
|
|
30
|
-
]
|
|
31
|
-
|
|
32
|
-
const LEGACY_KEYS = ['section', 'allowAgentControl']
|
|
33
|
-
const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'git', 'worktrees', 'spawn', 'send', 'tests', 'gates', 'docs', 'leases', 'dev']
|
|
34
|
-
|
|
35
|
-
const SECTION_KEYS = {
|
|
36
|
-
init: ['autoGitInit', 'snapshotDirty'],
|
|
37
|
-
mode: [
|
|
38
|
-
'promptSection',
|
|
39
|
-
'sectionAppend',
|
|
40
|
-
'allowAgentControl',
|
|
41
|
-
'autoPauseGoal',
|
|
42
|
-
'requireResumeAuthorization',
|
|
43
|
-
'workspaceLock',
|
|
44
|
-
'workspaceLockScope',
|
|
45
|
-
'planModePolicy',
|
|
46
|
-
'workspaceStaleMs',
|
|
47
|
-
'heartbeatMs',
|
|
48
|
-
'denyTools',
|
|
49
|
-
'storePath',
|
|
50
|
-
'maxEntries',
|
|
51
|
-
],
|
|
52
|
-
review: ['requireReview', 'maxRoundsPerReviewer'],
|
|
53
|
-
merge: ['timeoutMs', 'maxBufferBytes'],
|
|
54
|
-
git: ['timeoutMs'],
|
|
55
|
-
worktrees: ['root'],
|
|
56
|
-
spawn: [
|
|
57
|
-
'workerPreset',
|
|
58
|
-
'reviewerPreset',
|
|
59
|
-
'maxDepth',
|
|
60
|
-
'provider',
|
|
61
|
-
'workerModel',
|
|
62
|
-
'reviewerModel',
|
|
63
|
-
'maxLiveWorkers',
|
|
64
|
-
'staleMs',
|
|
65
|
-
'maxWaitMs',
|
|
66
|
-
'requireLease',
|
|
67
|
-
],
|
|
68
|
-
send: ['maxPerMinute', 'interruptDefault'],
|
|
69
|
-
tests: ['enabled', 'admission', 'timeoutMs', 'maxBytes', 'autoChecks', 'allowPackageDiscovery'],
|
|
70
|
-
gates: ['requireChecks', 'requireTests', 'allowReviewExemption'],
|
|
71
|
-
docs: ['board', 'boardLocation'],
|
|
72
|
-
leases: ['ttlMs'],
|
|
73
|
-
dev: ['hotReload', 'reloadDelayMs', 'reloadRequiredIds'],
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const MODEL_KEYS = ['provider', 'model', 'reasoningEffort', 'maxTokens']
|
|
77
|
-
|
|
78
|
-
const ModelSchema = () =>
|
|
79
|
-
Schema.object({
|
|
80
|
-
provider: Schema.string().description('Provider route override for this role.'),
|
|
81
|
-
model: Schema.string().description('Model id override for this role.'),
|
|
82
|
-
reasoningEffort: Schema.string().description('Optional reasoning effort override.'),
|
|
83
|
-
maxTokens: Schema.natural().description('Optional output token cap.'),
|
|
84
|
-
})
|
|
15
|
+
import {
|
|
16
|
+
Config,
|
|
17
|
+
DEFAULT_DENY_TOOLS,
|
|
18
|
+
assertKnownKeys,
|
|
19
|
+
assertSafeKeys,
|
|
20
|
+
clone,
|
|
21
|
+
deepMerge,
|
|
22
|
+
isPlainObject,
|
|
23
|
+
normalizeLegacy,
|
|
24
|
+
resolveTowerConfig,
|
|
25
|
+
} from '../domain/config/schema.js'
|
|
26
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
27
|
+
|
|
28
|
+
export { Config, DEFAULT_DENY_TOOLS, resolveTowerConfig }
|
|
85
29
|
|
|
86
|
-
|
|
87
|
-
section: Schema.string().description(
|
|
88
|
-
'Legacy alias for mode.promptSection: replaces the whole Tower-mode system prompt text.',
|
|
89
|
-
),
|
|
90
|
-
allowAgentControl: Schema.boolean().description(
|
|
91
|
-
'Legacy alias for mode.allowAgentControl: register tower_control so the model may toggle the mode.',
|
|
92
|
-
),
|
|
93
|
-
init: Schema.object({
|
|
94
|
-
autoGitInit: Schema.boolean()
|
|
95
|
-
.default(false)
|
|
96
|
-
.description('tower_init may run git init and create an empty initial commit when the directory is not a repository.'),
|
|
97
|
-
snapshotDirty: Schema.boolean()
|
|
98
|
-
.default(false)
|
|
99
|
-
.description('tower_init may snapshot uncommitted tracked changes into a dangling commit used as the mission baseline.'),
|
|
100
|
-
})
|
|
101
|
-
.default({})
|
|
102
|
-
.description('Repository initialization policy.'),
|
|
103
|
-
mode: Schema.object({
|
|
104
|
-
promptSection: Schema.string().description('Replacement Tower-mode system prompt text. Empty keeps the built-in reminder.'),
|
|
105
|
-
sectionAppend: Schema.string().description('Extra text appended to the Tower-mode system prompt section (team conventions).'),
|
|
106
|
-
allowAgentControl: Schema.boolean().description('Register tower_control so the model may enter/leave the mode with a reason.'),
|
|
107
|
-
autoPauseGoal: Schema.boolean().default(true).description('Pause an active goal when entering Tower mode.'),
|
|
108
|
-
requireResumeAuthorization: Schema.boolean()
|
|
109
|
-
.default(false)
|
|
110
|
-
.description('Require an authorization reference when resuming a mission paused with pauseCause=user.'),
|
|
111
|
-
workspaceLock: Schema.boolean()
|
|
112
|
-
.default(false)
|
|
113
|
-
.description('Allow only one Tower session per repository (advisory <git-common-dir>/dsh-tower/tower.lock).'),
|
|
114
|
-
workspaceLockScope: Schema.union(['all', 'mutations'])
|
|
115
|
-
.default('all')
|
|
116
|
-
.description('all = every repo-scoped tool acquires the workspace lock; mutations = only tools that write state/worktrees do.'),
|
|
117
|
-
planModePolicy: Schema.union(['clear', 'refuse'])
|
|
118
|
-
.default('clear')
|
|
119
|
-
.description('On Tower entry: clear an active plan mode, or refuse to enter until the plan is exited.'),
|
|
120
|
-
workspaceStaleMs: Schema.natural()
|
|
121
|
-
.min(1000)
|
|
122
|
-
.default(300000)
|
|
123
|
-
.description('A workspace lock older than this is stale and can be taken over.'),
|
|
124
|
-
heartbeatMs: Schema.natural().min(1000).default(10000).description('Workspace-lock heartbeat interval while Tower mode is on.'),
|
|
125
|
-
denyTools: Schema.array(Schema.string())
|
|
126
|
-
.default(DEFAULT_DENY_TOOLS)
|
|
127
|
-
.description('Tools refused while Tower mode is active.'),
|
|
128
|
-
storePath: Schema.string().description('Directory for modes.json; defaults to $DSH_HOME/tower-mode.'),
|
|
129
|
-
maxEntries: Schema.natural().min(1).max(2000).default(200).description('Maximum persisted mode entries.'),
|
|
130
|
-
})
|
|
131
|
-
.default({})
|
|
132
|
-
.description('Tower-mode behavior and persistence.'),
|
|
133
|
-
review: Schema.object({
|
|
134
|
-
requireReview: Schema.boolean()
|
|
135
|
-
.default(false)
|
|
136
|
-
.description('Require an independent clean review before merge. Default false: review is optional; set true to enforce the review gate globally.'),
|
|
137
|
-
maxRoundsPerReviewer: Schema.natural().min(1).default(5).description('Review-round cap per reviewer per mission branch.'),
|
|
138
|
-
})
|
|
139
|
-
.default({})
|
|
140
|
-
.description('Review policy.'),
|
|
141
|
-
merge: Schema.object({
|
|
142
|
-
timeoutMs: Schema.natural().min(1000).default(900000).description('Default tower_merge timeout, including pre-merge hooks.'),
|
|
143
|
-
maxBufferBytes: Schema.natural().min(65536).default(67108864).description('Captured merge output buffer.'),
|
|
144
|
-
})
|
|
145
|
-
.default({})
|
|
146
|
-
.description('Merge execution policy.'),
|
|
147
|
-
git: Schema.object({
|
|
148
|
-
timeoutMs: Schema.natural().min(1000).default(15000).description('Default timeout for Git commands other than merge; merge uses merge.timeoutMs.'),
|
|
149
|
-
})
|
|
150
|
-
.default({})
|
|
151
|
-
.description('Git subprocess policy.'),
|
|
152
|
-
worktrees: Schema.object({
|
|
153
|
-
root: Schema.string().description('Default parent directory for mission worktrees; empty derives <main-checkout-parent>/.dsh-tower-worktrees/<hash>.'),
|
|
154
|
-
})
|
|
155
|
-
.default({})
|
|
156
|
-
.description('Worktree placement.'),
|
|
157
|
-
spawn: Schema.object({
|
|
158
|
-
workerPreset: Schema.string().default('tower-worker').description('Agent preset for worker missions.'),
|
|
159
|
-
reviewerPreset: Schema.string().default('tower-reviewer').description('Agent preset for reviewers.'),
|
|
160
|
-
maxDepth: Schema.natural().min(1).default(1).description('Subagent depth requested when spawning tower agents.'),
|
|
161
|
-
provider: Schema.string().description('Subagent provider id; empty picks spawn, then the first registered provider.'),
|
|
162
|
-
workerModel: ModelSchema().description('Model route for workers; omitted inherits the parent session.'),
|
|
163
|
-
reviewerModel: ModelSchema().description('Model route for reviewers; omitted inherits the parent session (recommended).'),
|
|
164
|
-
maxLiveWorkers: Schema.natural()
|
|
165
|
-
.min(1)
|
|
166
|
-
.max(64)
|
|
167
|
-
.default(4)
|
|
168
|
-
.description('Maximum live worker entries allowed per repository at once.'),
|
|
169
|
-
staleMs: Schema.natural()
|
|
170
|
-
.min(0)
|
|
171
|
-
.default(15000)
|
|
172
|
-
.description('Roster entries with no catalog activity after this age are marked stale and block merge (0 disables).'),
|
|
173
|
-
maxWaitMs: Schema.natural()
|
|
174
|
-
.min(1000)
|
|
175
|
-
.max(600000)
|
|
176
|
-
.default(90000)
|
|
177
|
-
.description('Upper bound for tower_spawn waitMs (default 90s, Kimi-compatible).'),
|
|
178
|
-
requireLease: Schema.string().description('Optional lease name tower_spawn must hold before starting a worker.'),
|
|
179
|
-
})
|
|
180
|
-
.default({})
|
|
181
|
-
.description('Worker and reviewer presets plus model routing.'),
|
|
182
|
-
send: Schema.object({
|
|
183
|
-
maxPerMinute: Schema.natural()
|
|
184
|
-
.min(0)
|
|
185
|
-
.default(120)
|
|
186
|
-
.description('Per-repository tower_send rate limit per minute (0 disables).'),
|
|
187
|
-
interruptDefault: Schema.boolean()
|
|
188
|
-
.default(false)
|
|
189
|
-
.description('Default value of tower_send.interrupt when the caller omits it.'),
|
|
190
|
-
})
|
|
191
|
-
.default({})
|
|
192
|
-
.description('Message delivery policy.'),
|
|
193
|
-
tests: Schema.object({
|
|
194
|
-
enabled: Schema.boolean().default(true).description('Run declared test discovery when a mission configures it.'),
|
|
195
|
-
admission: Schema.union(['spawn', 'complete', 'off'])
|
|
196
|
-
.default('off')
|
|
197
|
-
.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).'),
|
|
198
|
-
timeoutMs: Schema.natural().min(1000).max(1800000).default(60000).description('Per discovery command timeout.'),
|
|
199
|
-
maxBytes: Schema.natural().min(1024).max(16 * 1024 * 1024).default(16 * 1024 * 1024).description('Maximum captured stdout bytes per command.'),
|
|
200
|
-
autoChecks: Schema.boolean().default(true).description('Attach a tests check to the completion report when discovery runs.'),
|
|
201
|
-
allowPackageDiscovery: Schema.boolean()
|
|
202
|
-
.default(true)
|
|
203
|
-
.description('Allow package.json tower.testDiscovery commands from the mission worktree (executes repository code; see security docs).'),
|
|
204
|
-
})
|
|
205
|
-
.default({})
|
|
206
|
-
.description('Declared test discovery and admission.'),
|
|
207
|
-
gates: Schema.object({
|
|
208
|
-
requireChecks: Schema.boolean()
|
|
209
|
-
.default(false)
|
|
210
|
-
.description('Require at least one completion check (tower_complete.checks) before a mission can merge.'),
|
|
211
|
-
requireTests: Schema.boolean()
|
|
212
|
-
.default(false)
|
|
213
|
-
.description('Require a passing test-discovery receipt for the reviewed worktree tip before merge.'),
|
|
214
|
-
allowReviewExemption: Schema.boolean()
|
|
215
|
-
.default(false)
|
|
216
|
-
.description('Allow reviewPolicy:none missions with a non-empty reviewExemptionReason to skip review blockers.'),
|
|
217
|
-
})
|
|
218
|
-
.default({})
|
|
219
|
-
.description('Merge-gate strictness.'),
|
|
220
|
-
docs: Schema.object({
|
|
221
|
-
board: Schema.boolean()
|
|
222
|
-
.default(true)
|
|
223
|
-
.description('Write a human-readable MISSIONS.md after mission changes.'),
|
|
224
|
-
boardLocation: Schema.union(['common', 'repo'])
|
|
225
|
-
.default('common')
|
|
226
|
-
.description('common = <git-common-dir>/dsh-tower/MISSIONS.md; repo = <repoRoot>/MISSIONS.md.'),
|
|
227
|
-
})
|
|
228
|
-
.default({})
|
|
229
|
-
.description('Human-readable artifacts.'),
|
|
230
|
-
leases: Schema.object({
|
|
231
|
-
ttlMs: Schema.natural().min(1000).max(86400000).default(900000).description('Default advisory lease lifetime; expiry never auto-releases.'),
|
|
232
|
-
})
|
|
233
|
-
.default({})
|
|
234
|
-
.description('Advisory resource leases.'),
|
|
235
|
-
dev: Schema.object({
|
|
236
|
-
hotReload: Schema.boolean().default(true).description('Register tower_reload and /tower reload when the DSH HMR service is available.'),
|
|
237
|
-
reloadDelayMs: Schema.natural().min(1).max(5000).default(50).description('Delay before a scheduled reload, so the tool result is delivered first.'),
|
|
238
|
-
reloadRequiredIds: Schema.array(Schema.string())
|
|
239
|
-
.default(['tower', 'tower-skills', 'preset-tower-worker', 'preset-tower-reviewer'])
|
|
240
|
-
.description('Loader entry ids passed to reconcileProfile as required.'),
|
|
241
|
-
})
|
|
242
|
-
.default({})
|
|
243
|
-
.description('Development and hot-reload behavior.'),
|
|
244
|
-
}).description('DSH Tower configuration')
|
|
30
|
+
const REPO_CONFIG_MAX_BYTES = 1024 * 1024
|
|
245
31
|
|
|
246
|
-
let effective = null
|
|
247
32
|
// The last raw profile config `apply()` received; repository config is merged
|
|
248
33
|
// over it so a repo can tune spawn/merge/review without editing the profile.
|
|
249
34
|
let profileRaw = {}
|
|
250
35
|
|
|
251
|
-
function clone(value) {
|
|
252
|
-
return value === undefined ? undefined : JSON.parse(JSON.stringify(value))
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function isPlainObject(value) {
|
|
256
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function deepMerge(base, override) {
|
|
260
|
-
if (override === undefined) return clone(base) ?? {}
|
|
261
|
-
if (!isPlainObject(base) || !isPlainObject(override)) return clone(override)
|
|
262
|
-
const out = clone(base)
|
|
263
|
-
for (const [key, value] of Object.entries(override)) {
|
|
264
|
-
out[key] = value === undefined ? out[key] : deepMerge(base[key], value)
|
|
265
|
-
}
|
|
266
|
-
return out
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/** Move the legacy top-level aliases into their canonical group keys. */
|
|
270
|
-
function normalizeLegacy(raw = {}) {
|
|
271
|
-
const out = clone(raw) ?? {}
|
|
272
|
-
if (out.section !== undefined) {
|
|
273
|
-
const mode = { ...(out.mode ?? {}) }
|
|
274
|
-
if (mode.promptSection === undefined) mode.promptSection = out.section
|
|
275
|
-
out.mode = mode
|
|
276
|
-
delete out.section
|
|
277
|
-
}
|
|
278
|
-
if (out.allowAgentControl !== undefined) {
|
|
279
|
-
const mode = { ...(out.mode ?? {}) }
|
|
280
|
-
if (mode.allowAgentControl === undefined) mode.allowAgentControl = out.allowAgentControl
|
|
281
|
-
out.mode = mode
|
|
282
|
-
delete out.allowAgentControl
|
|
283
|
-
}
|
|
284
|
-
return out
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function assertModelKeys(value, label) {
|
|
288
|
-
const bad = Object.keys(value).filter((key) => !MODEL_KEYS.includes(key))
|
|
289
|
-
if (bad.length > 0) {
|
|
290
|
-
throw new TowerToolError('config-unknown-key', `${label} has unknown key(s) ${bad.join(', ')}`)
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function assertKnownKeys(raw, label) {
|
|
295
|
-
const unknown = Object.keys(raw).filter((key) => !LEGACY_KEYS.includes(key) && !GROUP_KEYS.includes(key))
|
|
296
|
-
if (unknown.length > 0) {
|
|
297
|
-
throw new TowerToolError(
|
|
298
|
-
'config-unknown-key',
|
|
299
|
-
`${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, git?, worktrees?, spawn?, send?, tests?, gates?, docs?, leases?, dev? }`,
|
|
300
|
-
)
|
|
301
|
-
}
|
|
302
|
-
for (const [group, allowed] of Object.entries(SECTION_KEYS)) {
|
|
303
|
-
const value = raw[group]
|
|
304
|
-
if (value === undefined) continue
|
|
305
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) continue
|
|
306
|
-
const bad = Object.keys(value).filter((key) => !allowed.includes(key))
|
|
307
|
-
if (bad.length > 0) {
|
|
308
|
-
throw new TowerToolError(
|
|
309
|
-
'config-unknown-key',
|
|
310
|
-
`${label} has unknown key(s) ${bad.map((key) => `${group}.${key}`).join(', ')}`,
|
|
311
|
-
)
|
|
312
|
-
}
|
|
313
|
-
if ((group === 'spawn') && value.workerModel && typeof value.workerModel === 'object') {
|
|
314
|
-
assertModelKeys(value.workerModel, `${label} spawn.workerModel`)
|
|
315
|
-
}
|
|
316
|
-
if ((group === 'spawn') && value.reviewerModel && typeof value.reviewerModel === 'object') {
|
|
317
|
-
assertModelKeys(value.reviewerModel, `${label} spawn.reviewerModel`)
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
323
|
-
const REPO_CONFIG_MAX_BYTES = 1024 * 1024
|
|
324
|
-
|
|
325
|
-
/** Reject prototype-pollution style keys anywhere in a repository config. */
|
|
326
|
-
function assertSafeKeys(value, label) {
|
|
327
|
-
if (Array.isArray(value)) {
|
|
328
|
-
for (const item of value) assertSafeKeys(item, label)
|
|
329
|
-
return
|
|
330
|
-
}
|
|
331
|
-
if (!isPlainObject(value)) return
|
|
332
|
-
for (const [key, child] of Object.entries(value)) {
|
|
333
|
-
if (FORBIDDEN_KEYS.has(key)) {
|
|
334
|
-
throw new TowerToolError('config-invalid', `${label} contains the forbidden key "${key}"`)
|
|
335
|
-
}
|
|
336
|
-
assertSafeKeys(child, label)
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
export function resolveTowerConfig(rawConfig) {
|
|
341
|
-
const raw = rawConfig ?? {}
|
|
342
|
-
if (raw !== null && typeof raw !== 'object') throw new Error('Tower config must be an object')
|
|
343
|
-
assertKnownKeys(raw, 'Tower config')
|
|
344
|
-
let parsed
|
|
345
|
-
try {
|
|
346
|
-
parsed = Config(raw)
|
|
347
|
-
} catch (error) {
|
|
348
|
-
throw new Error(`Tower config is invalid: ${String(error?.message ?? error)}`)
|
|
349
|
-
}
|
|
350
|
-
const mode = clone(parsed.mode ?? {})
|
|
351
|
-
// Canonical keys win; the legacy top-level aliases only fill gaps.
|
|
352
|
-
const rawMode = raw.mode ?? {}
|
|
353
|
-
if (rawMode.promptSection === undefined && raw.section !== undefined) mode.promptSection = raw.section
|
|
354
|
-
if (rawMode.allowAgentControl === undefined && raw.allowAgentControl !== undefined) {
|
|
355
|
-
mode.allowAgentControl = raw.allowAgentControl
|
|
356
|
-
}
|
|
357
|
-
// Apply the canonical default only after alias resolution: a validated
|
|
358
|
-
// Cordis config may omit this key, and an explicit legacy top-level
|
|
359
|
-
// `allowAgentControl:true` must not be shadowed by a schema default.
|
|
360
|
-
if (mode.allowAgentControl === undefined) mode.allowAgentControl = true
|
|
361
|
-
if (!Array.isArray(mode.denyTools) || mode.denyTools.length === 0) mode.denyTools = [...DEFAULT_DENY_TOOLS]
|
|
362
|
-
return {
|
|
363
|
-
section: mode.promptSection ?? '',
|
|
364
|
-
init: clone(parsed.init ?? {}),
|
|
365
|
-
mode,
|
|
366
|
-
review: clone(parsed.review ?? {}),
|
|
367
|
-
merge: clone(parsed.merge ?? {}),
|
|
368
|
-
git: clone(parsed.git ?? {}),
|
|
369
|
-
worktrees: clone(parsed.worktrees ?? {}),
|
|
370
|
-
spawn: clone(parsed.spawn ?? {}),
|
|
371
|
-
send: clone(parsed.send ?? {}),
|
|
372
|
-
tests: clone(parsed.tests ?? {}),
|
|
373
|
-
gates: clone(parsed.gates ?? {}),
|
|
374
|
-
docs: clone(parsed.docs ?? {}),
|
|
375
|
-
leases: clone(parsed.leases ?? {}),
|
|
376
|
-
dev: clone(parsed.dev ?? {}),
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
36
|
export function configureRuntime(config) {
|
|
381
37
|
profileRaw = clone(config ?? {}) ?? {}
|
|
382
|
-
|
|
383
|
-
return effective
|
|
38
|
+
return resolveTowerConfig(config)
|
|
384
39
|
}
|
|
385
40
|
|
|
386
41
|
export function runtimeConfig() {
|
|
387
|
-
|
|
388
|
-
|
|
42
|
+
return resolveTowerConfig(profileRaw)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Raw profile config before defaults are materialized (for doctor warnings). */
|
|
46
|
+
export function profileConfig() {
|
|
47
|
+
return clone(profileRaw) ?? {}
|
|
389
48
|
}
|
|
390
49
|
|
|
391
50
|
export function repoConfigPath(commonDir) {
|
|
@@ -479,4 +138,3 @@ export async function resolveRepoConfig(repo) {
|
|
|
479
138
|
profileConfigured: Object.keys(profileRaw).length > 0,
|
|
480
139
|
}
|
|
481
140
|
}
|
|
482
|
-
|