dsh-tower 1.3.0 → 2.0.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.
- package/CHANGELOG.md +109 -0
- package/CONTRIBUTING.md +1 -1
- package/README.en.md +44 -8
- package/README.md +42 -8
- package/cordis.patch.yml +11 -1
- package/docs/README.md +10 -5
- package/docs/architecture/overview.md +13 -9
- package/docs/faq.md +45 -2
- package/docs/guide/hot-reload.md +189 -0
- package/docs/guide/install-upgrade-migration.md +11 -0
- package/docs/guide/quickstart.md +33 -1
- package/docs/guide/scenarios-and-philosophy.md +309 -0
- package/docs/operations/runbook.md +23 -0
- package/docs/operations/security.md +23 -0
- package/docs/reference/blockers.md +10 -2
- package/docs/reference/config.md +65 -2
- package/docs/reference/doctor.md +33 -0
- package/docs/reference/errors.md +50 -0
- package/docs/reference/events.md +45 -0
- package/docs/reference/operations.md +55 -0
- package/docs/reference/testing.md +61 -0
- package/docs/reference/tools.md +65 -23
- package/docs/zh/README.md +60 -0
- package/docs/zh/faq.md +81 -0
- package/docs/zh/guide/hot-reload.md +178 -0
- package/docs/zh/guide/install-upgrade-migration.md +57 -0
- package/docs/zh/guide/quickstart.md +96 -0
- package/docs/zh/guide/scenarios-and-philosophy.md +211 -0
- package/docs/zh/operations/security.md +42 -0
- package/docs/zh/reference/blockers.md +62 -0
- package/docs/zh/reference/config.md +113 -0
- package/docs/zh/reference/doctor.md +31 -0
- package/docs/zh/reference/errors.md +52 -0
- package/docs/zh/reference/events.md +46 -0
- package/docs/zh/reference/operations.md +50 -0
- package/docs/zh/reference/testing.md +60 -0
- package/docs/zh/reference/tools.md +75 -0
- package/index.js +261 -13
- package/lib/batch.js +578 -0
- package/lib/config.js +275 -9
- package/lib/doctor.js +171 -0
- package/lib/errors.js +111 -0
- package/lib/hmr.js +196 -0
- package/lib/leases.js +317 -0
- package/lib/legacy.js +413 -0
- package/lib/modeStore.js +28 -1
- package/lib/store.js +24 -1
- package/lib/tests.js +628 -0
- package/lib/tools.js +1566 -184
- package/lib/tower.js +986 -103
- package/lib/workspaceLock.js +117 -0
- package/lib/worktreeOwnership.js +166 -0
- package/package.json +4 -1
- package/reviewer.js +1 -0
- package/scripts/check-docs.mjs +135 -0
- package/scripts/enable-dev-hmr.mjs +347 -0
- package/skills/tower-review.md +4 -2
- package/skills/tower-worker-protocol.md +3 -1
- package/worker.js +4 -2
package/lib/config.js
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
// Tower configuration: one declared schema, resolved defaults, and the
|
|
2
2
|
// process-local effective snapshot consumed by the domain modules.
|
|
3
3
|
//
|
|
4
|
+
// Layers, lowest precedence first:
|
|
5
|
+
// 1. code defaults (the schemastery `.default(...)` values)
|
|
6
|
+
// 2. profile config (the Cordis patch passes it to `apply()`)
|
|
7
|
+
// 3. repository config: <git-common-dir>/dsh-tower/config.json
|
|
8
|
+
// 4. per-tool arguments (e.g. worktreeRoot, timeoutMs)
|
|
9
|
+
//
|
|
4
10
|
// Defaults are exactly the 1.2.0 behavior; the shipped cordis patch keeps
|
|
5
11
|
// `allowAgentControl: true` via the legacy top-level key.
|
|
12
|
+
import fsp from 'node:fs/promises'
|
|
13
|
+
import path from 'node:path'
|
|
6
14
|
import Schema from '@deepseek-ai/schemastery'
|
|
15
|
+
import { TowerToolError } from './errors.js'
|
|
7
16
|
|
|
8
17
|
export const DEFAULT_DENY_TOOLS = [
|
|
9
18
|
'create_goal',
|
|
@@ -11,11 +20,17 @@ export const DEFAULT_DENY_TOOLS = [
|
|
|
11
20
|
'team_task_create',
|
|
12
21
|
'team_task_update',
|
|
13
22
|
'team_task_reopen',
|
|
23
|
+
'team_task_list',
|
|
14
24
|
'spawn_teammate',
|
|
25
|
+
'wait_agent',
|
|
26
|
+
'workflow',
|
|
27
|
+
'ralph',
|
|
28
|
+
'subagent',
|
|
29
|
+
'subagent_fork',
|
|
15
30
|
]
|
|
16
31
|
|
|
17
32
|
const LEGACY_KEYS = ['section', 'allowAgentControl']
|
|
18
|
-
const GROUP_KEYS = ['mode', 'review', 'merge', 'worktrees', 'spawn']
|
|
33
|
+
const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'worktrees', 'spawn', 'send', 'tests', 'gates', 'docs', 'leases', 'dev']
|
|
19
34
|
|
|
20
35
|
const ModelSchema = () =>
|
|
21
36
|
Schema.object({
|
|
@@ -32,11 +47,38 @@ export const Config = Schema.object({
|
|
|
32
47
|
allowAgentControl: Schema.boolean().description(
|
|
33
48
|
'Legacy alias for mode.allowAgentControl: register tower_control so the model may toggle the mode.',
|
|
34
49
|
),
|
|
50
|
+
init: Schema.object({
|
|
51
|
+
autoGitInit: Schema.boolean()
|
|
52
|
+
.default(false)
|
|
53
|
+
.description('tower_init may run git init and create an empty initial commit when the directory is not a repository.'),
|
|
54
|
+
snapshotDirty: Schema.boolean()
|
|
55
|
+
.default(false)
|
|
56
|
+
.description('tower_init may snapshot uncommitted tracked changes into a dangling commit used as the mission baseline.'),
|
|
57
|
+
})
|
|
58
|
+
.default({})
|
|
59
|
+
.description('Repository initialization policy.'),
|
|
35
60
|
mode: Schema.object({
|
|
36
61
|
promptSection: Schema.string().description('Replacement Tower-mode system prompt text. Empty keeps the built-in reminder.'),
|
|
37
62
|
sectionAppend: Schema.string().description('Extra text appended to the Tower-mode system prompt section (team conventions).'),
|
|
38
|
-
allowAgentControl: Schema.boolean().
|
|
63
|
+
allowAgentControl: Schema.boolean().description('Register tower_control so the model may enter/leave the mode with a reason.'),
|
|
39
64
|
autoPauseGoal: Schema.boolean().default(true).description('Pause an active goal when entering Tower mode.'),
|
|
65
|
+
requireResumeAuthorization: Schema.boolean()
|
|
66
|
+
.default(false)
|
|
67
|
+
.description('Require an authorization reference when resuming a mission paused with pauseCause=user.'),
|
|
68
|
+
workspaceLock: Schema.boolean()
|
|
69
|
+
.default(false)
|
|
70
|
+
.description('Allow only one Tower session per repository (advisory <git-common-dir>/dsh-tower/tower.lock).'),
|
|
71
|
+
workspaceLockScope: Schema.union(['all', 'mutations'])
|
|
72
|
+
.default('all')
|
|
73
|
+
.description('all = every repo-scoped tool acquires the workspace lock; mutations = only tools that write state/worktrees do.'),
|
|
74
|
+
planModePolicy: Schema.union(['clear', 'refuse'])
|
|
75
|
+
.default('clear')
|
|
76
|
+
.description('On Tower entry: clear an active plan mode, or refuse to enter until the plan is exited.'),
|
|
77
|
+
workspaceStaleMs: Schema.natural()
|
|
78
|
+
.min(1000)
|
|
79
|
+
.default(300000)
|
|
80
|
+
.description('A workspace lock older than this is stale and can be taken over.'),
|
|
81
|
+
heartbeatMs: Schema.natural().min(1000).default(10000).description('Workspace-lock heartbeat interval while Tower mode is on.'),
|
|
40
82
|
denyTools: Schema.array(Schema.string())
|
|
41
83
|
.default(DEFAULT_DENY_TOOLS)
|
|
42
84
|
.description('Tools refused while Tower mode is active.'),
|
|
@@ -68,26 +110,161 @@ export const Config = Schema.object({
|
|
|
68
110
|
provider: Schema.string().description('Subagent provider id; empty picks spawn, then the first registered provider.'),
|
|
69
111
|
workerModel: ModelSchema().description('Model route for workers; omitted inherits the parent session.'),
|
|
70
112
|
reviewerModel: ModelSchema().description('Model route for reviewers; omitted inherits the parent session (recommended).'),
|
|
113
|
+
maxLiveWorkers: Schema.natural()
|
|
114
|
+
.min(1)
|
|
115
|
+
.max(64)
|
|
116
|
+
.default(4)
|
|
117
|
+
.description('Maximum live worker entries allowed per repository at once.'),
|
|
118
|
+
staleMs: Schema.natural()
|
|
119
|
+
.min(0)
|
|
120
|
+
.default(15000)
|
|
121
|
+
.description('Roster entries with no catalog activity after this age are marked stale and block merge (0 disables).'),
|
|
122
|
+
maxWaitMs: Schema.natural()
|
|
123
|
+
.min(1000)
|
|
124
|
+
.max(600000)
|
|
125
|
+
.default(90000)
|
|
126
|
+
.description('Upper bound for tower_spawn waitMs (default 90s, Kimi-compatible).'),
|
|
127
|
+
requireLease: Schema.string().description('Optional lease name tower_spawn must hold before starting a worker.'),
|
|
71
128
|
})
|
|
72
129
|
.default({})
|
|
73
130
|
.description('Worker and reviewer presets plus model routing.'),
|
|
131
|
+
send: Schema.object({
|
|
132
|
+
maxPerMinute: Schema.natural()
|
|
133
|
+
.min(0)
|
|
134
|
+
.default(120)
|
|
135
|
+
.description('Per-repository tower_send rate limit per minute (0 disables).'),
|
|
136
|
+
interruptDefault: Schema.boolean()
|
|
137
|
+
.default(false)
|
|
138
|
+
.description('Default value of tower_send.interrupt when the caller omits it.'),
|
|
139
|
+
})
|
|
140
|
+
.default({})
|
|
141
|
+
.description('Message delivery policy.'),
|
|
142
|
+
tests: Schema.object({
|
|
143
|
+
enabled: Schema.boolean().default(true).description('Run declared test discovery when a mission configures it.'),
|
|
144
|
+
admission: Schema.union(['spawn', 'complete', 'off'])
|
|
145
|
+
.default('complete')
|
|
146
|
+
.description('Run discovery before spawning a worker, when completing, or only on explicit tower_test.'),
|
|
147
|
+
timeoutMs: Schema.natural().min(1000).max(1800000).default(60000).description('Per discovery command timeout.'),
|
|
148
|
+
maxBytes: Schema.natural().min(1024).max(16 * 1024 * 1024).default(16 * 1024 * 1024).description('Maximum captured stdout bytes per command.'),
|
|
149
|
+
autoChecks: Schema.boolean().default(true).description('Attach a tests check to the completion report when discovery runs.'),
|
|
150
|
+
allowPackageDiscovery: Schema.boolean()
|
|
151
|
+
.default(true)
|
|
152
|
+
.description('Allow package.json tower.testDiscovery commands from the mission worktree (executes repository code; see security docs).'),
|
|
153
|
+
})
|
|
154
|
+
.default({})
|
|
155
|
+
.description('Declared test discovery and admission.'),
|
|
156
|
+
gates: Schema.object({
|
|
157
|
+
requireChecks: Schema.boolean()
|
|
158
|
+
.default(false)
|
|
159
|
+
.description('Require at least one completion check (tower_complete.checks) before a mission can merge.'),
|
|
160
|
+
requireTests: Schema.boolean()
|
|
161
|
+
.default(false)
|
|
162
|
+
.description('Require a passing test-discovery receipt for the reviewed worktree tip before merge.'),
|
|
163
|
+
allowReviewExemption: Schema.boolean()
|
|
164
|
+
.default(false)
|
|
165
|
+
.description('Allow reviewPolicy:none missions with a non-empty reviewExemptionReason to skip review blockers.'),
|
|
166
|
+
})
|
|
167
|
+
.default({})
|
|
168
|
+
.description('Merge-gate strictness.'),
|
|
169
|
+
docs: Schema.object({
|
|
170
|
+
board: Schema.boolean()
|
|
171
|
+
.default(true)
|
|
172
|
+
.description('Write a human-readable MISSIONS.md after mission changes.'),
|
|
173
|
+
boardLocation: Schema.union(['common', 'repo'])
|
|
174
|
+
.default('common')
|
|
175
|
+
.description('common = <git-common-dir>/dsh-tower/MISSIONS.md; repo = <repoRoot>/MISSIONS.md.'),
|
|
176
|
+
})
|
|
177
|
+
.default({})
|
|
178
|
+
.description('Human-readable artifacts.'),
|
|
179
|
+
leases: Schema.object({
|
|
180
|
+
ttlMs: Schema.natural().min(1000).max(86400000).default(900000).description('Default advisory lease lifetime; expiry never auto-releases.'),
|
|
181
|
+
})
|
|
182
|
+
.default({})
|
|
183
|
+
.description('Advisory resource leases.'),
|
|
184
|
+
dev: Schema.object({
|
|
185
|
+
hotReload: Schema.boolean().default(true).description('Register tower_reload and /tower reload when the DSH HMR service is available.'),
|
|
186
|
+
reloadDelayMs: Schema.natural().min(1).max(5000).default(50).description('Delay before a scheduled reload, so the tool result is delivered first.'),
|
|
187
|
+
reloadRequiredIds: Schema.array(Schema.string())
|
|
188
|
+
.default(['tower', 'tower-skills', 'preset-tower-worker', 'preset-tower-reviewer'])
|
|
189
|
+
.description('Loader entry ids passed to reconcileProfile as required.'),
|
|
190
|
+
})
|
|
191
|
+
.default({})
|
|
192
|
+
.description('Development and hot-reload behavior.'),
|
|
74
193
|
}).description('DSH Tower configuration')
|
|
75
194
|
|
|
76
195
|
let effective = null
|
|
196
|
+
// The last raw profile config `apply()` received; repository config is merged
|
|
197
|
+
// over it so a repo can tune spawn/merge/review without editing the profile.
|
|
198
|
+
let profileRaw = {}
|
|
77
199
|
|
|
78
200
|
function clone(value) {
|
|
79
201
|
return value === undefined ? undefined : JSON.parse(JSON.stringify(value))
|
|
80
202
|
}
|
|
81
203
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
204
|
+
function isPlainObject(value) {
|
|
205
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function deepMerge(base, override) {
|
|
209
|
+
if (override === undefined) return clone(base) ?? {}
|
|
210
|
+
if (!isPlainObject(base) || !isPlainObject(override)) return clone(override)
|
|
211
|
+
const out = clone(base)
|
|
212
|
+
for (const [key, value] of Object.entries(override)) {
|
|
213
|
+
out[key] = value === undefined ? out[key] : deepMerge(base[key], value)
|
|
214
|
+
}
|
|
215
|
+
return out
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Move the legacy top-level aliases into their canonical group keys. */
|
|
219
|
+
function normalizeLegacy(raw = {}) {
|
|
220
|
+
const out = clone(raw) ?? {}
|
|
221
|
+
if (out.section !== undefined) {
|
|
222
|
+
const mode = { ...(out.mode ?? {}) }
|
|
223
|
+
if (mode.promptSection === undefined) mode.promptSection = out.section
|
|
224
|
+
out.mode = mode
|
|
225
|
+
delete out.section
|
|
226
|
+
}
|
|
227
|
+
if (out.allowAgentControl !== undefined) {
|
|
228
|
+
const mode = { ...(out.mode ?? {}) }
|
|
229
|
+
if (mode.allowAgentControl === undefined) mode.allowAgentControl = out.allowAgentControl
|
|
230
|
+
out.mode = mode
|
|
231
|
+
delete out.allowAgentControl
|
|
232
|
+
}
|
|
233
|
+
return out
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function assertKnownKeys(raw, label) {
|
|
85
237
|
const unknown = Object.keys(raw).filter((key) => !LEGACY_KEYS.includes(key) && !GROUP_KEYS.includes(key))
|
|
86
238
|
if (unknown.length > 0) {
|
|
87
|
-
throw new
|
|
88
|
-
|
|
239
|
+
throw new TowerToolError(
|
|
240
|
+
'config-unknown-key',
|
|
241
|
+
`${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, worktrees?, spawn?, send?, tests?, gates?, docs?, leases?, dev? }`,
|
|
89
242
|
)
|
|
90
243
|
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
247
|
+
const REPO_CONFIG_MAX_BYTES = 1024 * 1024
|
|
248
|
+
|
|
249
|
+
/** Reject prototype-pollution style keys anywhere in a repository config. */
|
|
250
|
+
function assertSafeKeys(value, label) {
|
|
251
|
+
if (Array.isArray(value)) {
|
|
252
|
+
for (const item of value) assertSafeKeys(item, label)
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
if (!isPlainObject(value)) return
|
|
256
|
+
for (const [key, child] of Object.entries(value)) {
|
|
257
|
+
if (FORBIDDEN_KEYS.has(key)) {
|
|
258
|
+
throw new TowerToolError('config-invalid', `${label} contains the forbidden key "${key}"`)
|
|
259
|
+
}
|
|
260
|
+
assertSafeKeys(child, label)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function resolveTowerConfig(rawConfig) {
|
|
265
|
+
const raw = rawConfig ?? {}
|
|
266
|
+
if (raw !== null && typeof raw !== 'object') throw new Error('Tower config must be an object')
|
|
267
|
+
assertKnownKeys(raw, 'Tower config')
|
|
91
268
|
let parsed
|
|
92
269
|
try {
|
|
93
270
|
parsed = Config(raw)
|
|
@@ -95,20 +272,36 @@ export function resolveTowerConfig(rawConfig) {
|
|
|
95
272
|
throw new Error(`Tower config is invalid: ${String(error?.message ?? error)}`)
|
|
96
273
|
}
|
|
97
274
|
const mode = clone(parsed.mode ?? {})
|
|
98
|
-
|
|
99
|
-
|
|
275
|
+
// Canonical keys win; the legacy top-level aliases only fill gaps.
|
|
276
|
+
const rawMode = raw.mode ?? {}
|
|
277
|
+
if (rawMode.promptSection === undefined && raw.section !== undefined) mode.promptSection = raw.section
|
|
278
|
+
if (rawMode.allowAgentControl === undefined && raw.allowAgentControl !== undefined) {
|
|
279
|
+
mode.allowAgentControl = raw.allowAgentControl
|
|
280
|
+
}
|
|
281
|
+
// Apply the canonical default only after alias resolution: a validated
|
|
282
|
+
// Cordis config may omit this key, and an explicit legacy top-level
|
|
283
|
+
// `allowAgentControl:true` must not be shadowed by a schema default.
|
|
284
|
+
if (mode.allowAgentControl === undefined) mode.allowAgentControl = false
|
|
100
285
|
if (!Array.isArray(mode.denyTools) || mode.denyTools.length === 0) mode.denyTools = [...DEFAULT_DENY_TOOLS]
|
|
101
286
|
return {
|
|
102
287
|
section: mode.promptSection ?? '',
|
|
288
|
+
init: clone(parsed.init ?? {}),
|
|
103
289
|
mode,
|
|
104
290
|
review: clone(parsed.review ?? {}),
|
|
105
291
|
merge: clone(parsed.merge ?? {}),
|
|
106
292
|
worktrees: clone(parsed.worktrees ?? {}),
|
|
107
293
|
spawn: clone(parsed.spawn ?? {}),
|
|
294
|
+
send: clone(parsed.send ?? {}),
|
|
295
|
+
tests: clone(parsed.tests ?? {}),
|
|
296
|
+
gates: clone(parsed.gates ?? {}),
|
|
297
|
+
docs: clone(parsed.docs ?? {}),
|
|
298
|
+
leases: clone(parsed.leases ?? {}),
|
|
299
|
+
dev: clone(parsed.dev ?? {}),
|
|
108
300
|
}
|
|
109
301
|
}
|
|
110
302
|
|
|
111
303
|
export function configureRuntime(config) {
|
|
304
|
+
profileRaw = clone(config ?? {}) ?? {}
|
|
112
305
|
effective = resolveTowerConfig(config)
|
|
113
306
|
return effective
|
|
114
307
|
}
|
|
@@ -117,3 +310,76 @@ export function runtimeConfig() {
|
|
|
117
310
|
if (!effective) effective = resolveTowerConfig({})
|
|
118
311
|
return effective
|
|
119
312
|
}
|
|
313
|
+
|
|
314
|
+
export function repoConfigPath(commonDir) {
|
|
315
|
+
return path.join(commonDir, 'dsh-tower', 'config.json')
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Read and validate <git-common-dir>/dsh-tower/config.json.
|
|
320
|
+
* Returns the raw (legacy-normalized) object plus its path; missing is not an error.
|
|
321
|
+
*/
|
|
322
|
+
export async function readRepoConfig(commonDir) {
|
|
323
|
+
const file = repoConfigPath(commonDir)
|
|
324
|
+
let stat
|
|
325
|
+
try {
|
|
326
|
+
stat = await fsp.stat(file)
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (error?.code === 'ENOENT') return { config: {}, path: file, present: false }
|
|
329
|
+
throw new TowerToolError('config-invalid', `repo config at ${file} could not be read: ${String(error?.message ?? error)}`)
|
|
330
|
+
}
|
|
331
|
+
if (stat.size > REPO_CONFIG_MAX_BYTES) {
|
|
332
|
+
throw new TowerToolError(
|
|
333
|
+
'config-invalid',
|
|
334
|
+
`repo config at ${file} is too large (${stat.size} bytes; limit ${REPO_CONFIG_MAX_BYTES})`,
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
let text
|
|
338
|
+
try {
|
|
339
|
+
text = await fsp.readFile(file, 'utf8')
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (error?.code === 'ENOENT') return { config: {}, path: file, present: false }
|
|
342
|
+
throw new TowerToolError('config-invalid', `repo config at ${file} could not be read: ${String(error?.message ?? error)}`)
|
|
343
|
+
}
|
|
344
|
+
let parsed
|
|
345
|
+
try {
|
|
346
|
+
parsed = JSON.parse(text)
|
|
347
|
+
} catch (error) {
|
|
348
|
+
throw new TowerToolError('config-invalid', `repo config at ${file} is not valid JSON: ${String(error?.message ?? error)}`)
|
|
349
|
+
}
|
|
350
|
+
if (!isPlainObject(parsed)) {
|
|
351
|
+
throw new TowerToolError('config-invalid', `repo config at ${file} must be a JSON object`)
|
|
352
|
+
}
|
|
353
|
+
assertSafeKeys(parsed, `repo config at ${file}`)
|
|
354
|
+
assertKnownKeys(parsed, `repo config at ${file}`)
|
|
355
|
+
try {
|
|
356
|
+
// Validate value types without letting the schema's defaults win over the
|
|
357
|
+
// profile layer: merge into an empty raw object and discard the result.
|
|
358
|
+
Config(parsed)
|
|
359
|
+
} catch (error) {
|
|
360
|
+
throw new TowerToolError('config-invalid', `repo config at ${file} is invalid: ${String(error?.message ?? error)}`)
|
|
361
|
+
}
|
|
362
|
+
return { config: normalizeLegacy(parsed), path: file, present: true }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Effective config for one repository: profile config (already resolved with
|
|
367
|
+
* code defaults) overridden by the repository config file. Tool arguments are
|
|
368
|
+
* applied by the tool layer on top of this snapshot.
|
|
369
|
+
*/
|
|
370
|
+
export async function resolveRepoConfig(repo) {
|
|
371
|
+
const base = runtimeConfig()
|
|
372
|
+
const layer = await readRepoConfig(repo.commonDir)
|
|
373
|
+
const merged = deepMerge(base, layer.config)
|
|
374
|
+
if (!Array.isArray(merged.mode?.denyTools) || merged.mode.denyTools.length === 0) {
|
|
375
|
+
merged.mode = { ...(merged.mode ?? {}), denyTools: [...DEFAULT_DENY_TOOLS] }
|
|
376
|
+
}
|
|
377
|
+
merged.section = merged.mode?.promptSection ?? merged.section ?? ''
|
|
378
|
+
return {
|
|
379
|
+
...merged,
|
|
380
|
+
configPath: layer.path,
|
|
381
|
+
repoConfigPresent: layer.present,
|
|
382
|
+
profileConfigured: Object.keys(profileRaw).length > 0,
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Read-only environment contract and health checks for a Tower repository.
|
|
2
|
+
import { execFile } from 'node:child_process'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import fsp from 'node:fs/promises'
|
|
6
|
+
import { git } from './git.js'
|
|
7
|
+
import { inspectWorkspaceLock } from './workspaceLock.js'
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile)
|
|
10
|
+
|
|
11
|
+
async function exists(target) {
|
|
12
|
+
try {
|
|
13
|
+
await fsp.stat(target)
|
|
14
|
+
return true
|
|
15
|
+
} catch {
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function gitVersion() {
|
|
21
|
+
try {
|
|
22
|
+
const { stdout } = await execFileAsync('git', ['--version'], { windowsHide: true })
|
|
23
|
+
return String(stdout).trim()
|
|
24
|
+
} catch {
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function check(id, status, detail, action) {
|
|
30
|
+
return { id, status, detail, ...(action ? { action } : {}) }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Run every read-only diagnostic; never writes state or takes the state lock. */
|
|
34
|
+
export async function runDoctor({ tower, ctx, agent }) {
|
|
35
|
+
const checks = []
|
|
36
|
+
const version = await gitVersion()
|
|
37
|
+
checks.push(version ? check('git', 'pass', version) : check('git', 'fail', 'git is not available', 'install Git and retry'))
|
|
38
|
+
|
|
39
|
+
checks.push(check('repository', 'pass', `${tower.root} (common ${tower.commonDir})`))
|
|
40
|
+
let state = null
|
|
41
|
+
try {
|
|
42
|
+
state = await tower.load()
|
|
43
|
+
checks.push(check('state', 'pass', `${tower.paths.state} revision ${state.revision}`))
|
|
44
|
+
} catch (error) {
|
|
45
|
+
checks.push(check('state', 'fail', String(error?.message ?? error), 'run tower_init or restore the state file'))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const config = tower.config ?? {}
|
|
49
|
+
checks.push(
|
|
50
|
+
check(
|
|
51
|
+
'config',
|
|
52
|
+
config.configPath && config.repoConfigPresent ? 'pass' : 'warn',
|
|
53
|
+
config.repoConfigPresent ? `repo config ${config.configPath}` : `repo config not present (${config.configPath ?? 'unknown'})`,
|
|
54
|
+
'optional: add <git-common-dir>/dsh-tower/config.json',
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
const presets = ctx.get?.('agentPresets')
|
|
58
|
+
if (presets && typeof presets.list === 'function') {
|
|
59
|
+
try {
|
|
60
|
+
const available = (await presets.list()).map((entry) => String(entry.id))
|
|
61
|
+
const missing = [config.spawn?.workerPreset ?? 'tower-worker', config.spawn?.reviewerPreset ?? 'tower-reviewer']
|
|
62
|
+
.filter((id) => !available.includes(id))
|
|
63
|
+
checks.push(
|
|
64
|
+
missing.length === 0
|
|
65
|
+
? check('presets', 'pass', `worker=${config.spawn?.workerPreset ?? 'tower-worker'} reviewer=${config.spawn?.reviewerPreset ?? 'tower-reviewer'}`)
|
|
66
|
+
: check('presets', 'fail', `missing preset(s): ${missing.join(', ')}`, 'enable the Tower preset bundle and retry'),
|
|
67
|
+
)
|
|
68
|
+
} catch (error) {
|
|
69
|
+
checks.push(check('presets', 'warn', `preset registry unavailable: ${String(error?.message ?? error)}`))
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
checks.push(check('presets', 'warn', 'agentPresets service is unavailable'))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const subagents = ctx.get?.('subagents')
|
|
76
|
+
checks.push(subagents ? check('subagents', 'pass', 'service mounted') : check('subagents', 'fail', 'subagents service is unavailable', 'enable the subagent bundle'))
|
|
77
|
+
const hmr = ctx.get?.('hmr')
|
|
78
|
+
checks.push(hmr ? check('hmr', 'pass', 'service mounted') : check('hmr', 'warn', 'HMR is not mounted; /tower reload is unavailable'))
|
|
79
|
+
|
|
80
|
+
if (state) {
|
|
81
|
+
const stateLock = path.join(tower.paths.dir, 'state.lock')
|
|
82
|
+
checks.push(
|
|
83
|
+
(await exists(stateLock))
|
|
84
|
+
? check('state-lock', 'warn', `${stateLock} exists (a writer may be active or a crash left it behind)`)
|
|
85
|
+
: check('state-lock', 'pass', 'no state lock file'),
|
|
86
|
+
)
|
|
87
|
+
const lockDir = path.join(tower.commonDir, 'dsh-tower', 'tower.lock')
|
|
88
|
+
try {
|
|
89
|
+
const lock = await inspectWorkspaceLock(tower.commonDir, {
|
|
90
|
+
staleMs: Number(config.mode?.workspaceStaleMs) || 300000,
|
|
91
|
+
})
|
|
92
|
+
checks.push(lock.held ? check('workspace-lock', lock.stale ? 'warn' : 'pass', `held by ${lock.holder?.sessionId ?? 'unknown'}`) : check('workspace-lock', 'pass', 'free'))
|
|
93
|
+
} catch {
|
|
94
|
+
checks.push(check('workspace-lock', 'warn', `lock file ${lockDir} could not be inspected`))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const live = (state.roster ?? []).filter((entry) => !['retired', 'failed', 'interrupted', 'missing', 'stale', 'inactive'].includes(entry.state))
|
|
98
|
+
checks.push(live.length > 0 ? check('roster', 'warn', `${live.length} live roster entr${live.length === 1 ? 'y' : 'ies'}`) : check('roster', 'pass', 'no live workers'))
|
|
99
|
+
|
|
100
|
+
let missingWorktrees = 0
|
|
101
|
+
let dirtyWorktrees = 0
|
|
102
|
+
let removedWorktrees = 0
|
|
103
|
+
for (const mission of state.missions ?? []) {
|
|
104
|
+
if (!mission.worktree) continue
|
|
105
|
+
if (mission.worktreeRemovedAt) {
|
|
106
|
+
removedWorktrees += 1
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
await tower.assertWorktree(mission.worktree)
|
|
111
|
+
if (await exists(mission.worktree.path)) {
|
|
112
|
+
const dirty = (await git(mission.worktree.path, ['status', '--porcelain'])).trim()
|
|
113
|
+
if (dirty) dirtyWorktrees += 1
|
|
114
|
+
} else {
|
|
115
|
+
missingWorktrees += 1
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
missingWorktrees += 1
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const removedNote = removedWorktrees > 0 ? ` (${removedWorktrees} removed by teardown)` : ''
|
|
122
|
+
checks.push(
|
|
123
|
+
missingWorktrees > 0
|
|
124
|
+
? check('worktrees', 'fail', `${missingWorktrees} mission worktree(s) are missing or invalid${removedNote}`, 'run tower_preflight or tower_recover')
|
|
125
|
+
: dirtyWorktrees > 0
|
|
126
|
+
? check('worktrees', 'warn', `${dirtyWorktrees} worktree(s) have uncommitted changes${removedNote}`)
|
|
127
|
+
: check('worktrees', 'pass', `all registered worktrees are valid${removedNote}`),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const testConfigured = state.missions?.some((mission) => mission.testRuns !== undefined)
|
|
131
|
+
checks.push(
|
|
132
|
+
check(
|
|
133
|
+
'tests',
|
|
134
|
+
config.tests?.enabled === false ? 'warn' : 'pass',
|
|
135
|
+
config.tests?.enabled === false ? 'test discovery disabled' : `admission=${config.tests?.admission ?? 'complete'} timeoutMs=${config.tests?.timeoutMs ?? 60000}${testConfigured ? '; receipts present' : ''}`,
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
checks.push(check('legacy', 'pass', `${state.legacySnapshots?.length ?? 0} legacy snapshot(s)`))
|
|
139
|
+
checks.push(check('batches', 'pass', `${state.batches?.length ?? 0} batch(es)`))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const { inspectLeases } = await import('./leases.js')
|
|
144
|
+
const leases = await inspectLeases(tower.commonDir)
|
|
145
|
+
checks.push(check('leases', leases.leases?.length ? 'warn' : 'pass', `${leases.leases?.length ?? 0} active lease(s)`))
|
|
146
|
+
} catch (error) {
|
|
147
|
+
const corrupt = error?.code === 'state-corrupt'
|
|
148
|
+
checks.push(
|
|
149
|
+
check(
|
|
150
|
+
'leases',
|
|
151
|
+
corrupt ? 'fail' : 'warn',
|
|
152
|
+
corrupt ? `lease ledger is corrupt: ${String(error?.message ?? error)}` : 'lease module unavailable',
|
|
153
|
+
corrupt ? 'inspect <git-common-dir>/dsh-tower/leases.json and repair or remove it' : undefined,
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const failed = checks.filter((item) => item.status === 'fail').length
|
|
159
|
+
return {
|
|
160
|
+
ok: failed === 0,
|
|
161
|
+
checks,
|
|
162
|
+
environmentContract: {
|
|
163
|
+
platform: process.platform,
|
|
164
|
+
gitVersion: version,
|
|
165
|
+
repoRoot: tower.root,
|
|
166
|
+
commonDir: tower.commonDir,
|
|
167
|
+
stateDir: tower.paths.dir,
|
|
168
|
+
},
|
|
169
|
+
mode: agent ? 'ready' : 'unknown',
|
|
170
|
+
}
|
|
171
|
+
}
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Stable tool error codes.
|
|
2
|
+
//
|
|
3
|
+
// Everything a Tower tool throws reaches the model as
|
|
4
|
+
// [tower:<code>] <original message>
|
|
5
|
+
// so callers (and tests) can branch on the code instead of matching prose.
|
|
6
|
+
// The catalog is documented in docs/reference/errors.md; scripts/check-docs.mjs
|
|
7
|
+
// keeps the two in sync.
|
|
8
|
+
import { GitError } from './git.js'
|
|
9
|
+
|
|
10
|
+
export const ERROR_CODES = {
|
|
11
|
+
'invalid-argument': 'A tool argument is missing, malformed, or not allowed.',
|
|
12
|
+
'repo-invalid': 'repoRoot is missing, relative, or not inside a Git repository.',
|
|
13
|
+
'not-initialized': 'Tower is not initialized for this repository; run tower_init.',
|
|
14
|
+
'mission-not-found': 'No mission with that id exists.',
|
|
15
|
+
'finding-not-found': 'No finding with that id exists.',
|
|
16
|
+
'invalid-transition': 'The requested state transition is not allowed.',
|
|
17
|
+
'state-locked': 'Another Tower writer holds the state lock.',
|
|
18
|
+
'state-corrupt': 'The Tower state file is unreadable or invalid.',
|
|
19
|
+
'config-invalid': 'Tower configuration is invalid.',
|
|
20
|
+
'config-unknown-key': 'Tower configuration has an unknown top-level key.',
|
|
21
|
+
'git-failed': 'A git command failed.',
|
|
22
|
+
'git-timeout': 'A git command timed out or exceeded its output buffer.',
|
|
23
|
+
'spawn-unavailable': 'The subagents service is unavailable.',
|
|
24
|
+
'provider-missing': 'No subagent provider is registered.',
|
|
25
|
+
'preset-missing': 'The configured agent preset is not registered.',
|
|
26
|
+
'reviewer-prerequisite': 'The reviewer spawn prerequisite is not met.',
|
|
27
|
+
'not-authorized': 'The calling agent role may not perform this action.',
|
|
28
|
+
'workspace-busy': 'Another Tower session owns this repository workspace lock.',
|
|
29
|
+
'worker-limit': 'The repository reached spawn.maxLiveWorkers live workers.',
|
|
30
|
+
'rate-limited': 'The tower_send per-minute rate limit was reached.',
|
|
31
|
+
'takeover-refused': 'The mission already has a live worker that was not stopped first.',
|
|
32
|
+
'reload-unavailable': 'The DSH HMR service is not available in this profile.',
|
|
33
|
+
'reload-failed': 'The HMR reload failed; check the DSH log and restart if needed.',
|
|
34
|
+
'tests-failed': 'Declared test discovery failed or did not meet its admission requirements.',
|
|
35
|
+
'tests-timeout': 'Test discovery exceeded its time or output budget.',
|
|
36
|
+
'lease-busy': 'Another holder owns the requested advisory lease.',
|
|
37
|
+
'lease-token': 'The lease release token does not match the current holder.',
|
|
38
|
+
'batch-invalid': 'The batch request or its close evidence is invalid.',
|
|
39
|
+
'legacy-invalid': 'The legacy snapshot or mapping is invalid.',
|
|
40
|
+
'tool-internal': 'An unexpected failure; read the message and the activity log.',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class TowerToolError extends Error {
|
|
44
|
+
constructor(code, message, options = {}) {
|
|
45
|
+
super(`[tower:${code}] ${message}`, options.cause ? { cause: options.cause } : undefined)
|
|
46
|
+
this.name = 'TowerToolError'
|
|
47
|
+
this.code = code
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Ordered rules; the first match wins. Keep the specific patterns above the
|
|
52
|
+
// generic argument fallback so `no open task matching` never becomes internal.
|
|
53
|
+
const RULES = [
|
|
54
|
+
['not-initialized', /tower is not initialized/i],
|
|
55
|
+
['mission-not-found', /^unknown mission "/i],
|
|
56
|
+
['finding-not-found', /^unknown finding "/i],
|
|
57
|
+
['state-locked', /locked by another writer/i],
|
|
58
|
+
[
|
|
59
|
+
'state-corrupt',
|
|
60
|
+
/corrupt tower state|^tower state |^unsupported tower state|^invalid (?:mission|roster|message|finding|review) entry|^mission entry without id|^duplicate (?:mission|roster|message|finding|review)|^mission .* (?:has unknown status|has no tasks array|has no deps array)|^message .* has no readBy array|^finding .* has unknown status|^review .* has unknown (?:status|merge verdict)/i,
|
|
61
|
+
],
|
|
62
|
+
['config-unknown-key', /unknown key|unknown top-level key/i],
|
|
63
|
+
['config-invalid', /config (?:is invalid|must be an object)|repo config/i],
|
|
64
|
+
['lease-busy', /lease .* (?:held|owned)|another holder owns/i],
|
|
65
|
+
['lease-token', /lease .*token|token does not match/i],
|
|
66
|
+
['tests-timeout', /test discovery .*(?:timed out|exceeded|too large)/i],
|
|
67
|
+
['tests-failed', /test discovery (?:failed|did not)|tests? (?:failed|missing|did not pass)/i],
|
|
68
|
+
['batch-invalid', /batch (?:is invalid|close|member|receipt)/i],
|
|
69
|
+
['legacy-invalid', /legacy (?:snapshot|map|source)|unsupported legacy/i],
|
|
70
|
+
['spawn-unavailable', /subagents service is unavailable/i],
|
|
71
|
+
['provider-missing', /no subagent provider/i],
|
|
72
|
+
['preset-missing', /agent preset .*is not registered/i],
|
|
73
|
+
['reviewer-prerequisite', /reviewer needs an existing|reviewer requires a completed/i],
|
|
74
|
+
['repo-invalid', /repoRoot is required|repoRoot must be an absolute path/i],
|
|
75
|
+
['workspace-busy', /workspace .*lock|another tower session owns/i],
|
|
76
|
+
['worker-limit', /live worker limit|too many live workers|worker limit reached/i],
|
|
77
|
+
['rate-limited', /rate limit|too many tower messages/i],
|
|
78
|
+
['takeover-refused', /takeover refused|cannot take over/i],
|
|
79
|
+
['not-authorized', /only the tower|only an assigned reviewer|only the tower or a worker|does not own mission|not authorized|may not perform this action/i],
|
|
80
|
+
[
|
|
81
|
+
'invalid-transition',
|
|
82
|
+
/cannot move from|cannot be completed from that state|is already completed|merged is recorded only|can only be widened|only a completed mission|reviewers may list and read|still has \d+ open task|has unresolved blockers|has no worktree|has no commits since|cannot complete with \d+ unread inbox|not assigned to mission|is a [a-z]+, not a worker|cannot reanchor base|worktree is missing|worktree .* is not a Git worktree|belongs to a different repository|is on .* expected|has \d+ live worker|already has a live worker|cannot reopen a terminal mission|stopped worker cannot report completion|can only be spawned|violates? (?:the )?mission (?:state|transition)/i,
|
|
83
|
+
],
|
|
84
|
+
[
|
|
85
|
+
'invalid-argument',
|
|
86
|
+
/requires |is required|must be (?:a|an|one|an integer|non-empty|printable)|must not be|must be one of|no open task matching|unknown mission status|unknown finding severity|unknown finding type|not an array|is not an array|scope must|title must|checks must|role must|timeoutMs must|repoRoot is required|only a list filter|snapshotDirty was requested|refusing to send a message to yourself|mission has \d+ live worker|roster name already used|is already in the roster|depends on unknown mission/i,
|
|
87
|
+
],
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
function originalMessage(error) {
|
|
91
|
+
return String(error?.message ?? error)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Wrap any thrown value into a TowerToolError with a stable code. */
|
|
95
|
+
export function classifyError(error) {
|
|
96
|
+
if (error instanceof TowerToolError) return error
|
|
97
|
+
if (error && typeof error.code === 'string' && ERROR_CODES[error.code]) {
|
|
98
|
+
return new TowerToolError(error.code, originalMessage(error), { cause: error })
|
|
99
|
+
}
|
|
100
|
+
if (error instanceof GitError || error?.name === 'GitError') {
|
|
101
|
+
const message = originalMessage(error)
|
|
102
|
+
if (/not a git repository/i.test(message)) return new TowerToolError('repo-invalid', message, { cause: error })
|
|
103
|
+
const code = error?.timedOut === true || error?.bufferOverflow === true ? 'git-timeout' : 'git-failed'
|
|
104
|
+
return new TowerToolError(code, message, { cause: error })
|
|
105
|
+
}
|
|
106
|
+
const message = originalMessage(error)
|
|
107
|
+
for (const [code, pattern] of RULES) {
|
|
108
|
+
if (pattern.test(message)) return new TowerToolError(code, message, { cause: error })
|
|
109
|
+
}
|
|
110
|
+
return new TowerToolError('tool-internal', message, { cause: error })
|
|
111
|
+
}
|