dsh-tower 2.0.3 → 2.0.4
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 +40 -0
- package/README.en.md +19 -13
- package/README.md +19 -13
- package/SECURITY.md +4 -1
- package/docs/README.md +30 -0
- package/docs/architecture/overview.md +3 -2
- package/docs/examples/first-mission.md +2 -1
- package/docs/faq.md +11 -7
- package/docs/guide/install-upgrade-migration.md +14 -5
- package/docs/guide/quickstart.md +12 -7
- package/docs/guide/scenarios-and-philosophy.md +86 -69
- package/docs/operations/security.md +18 -12
- package/docs/reference/blockers.md +11 -10
- package/docs/reference/config.md +7 -6
- package/docs/reference/errors.md +7 -1
- package/docs/reference/events.md +7 -4
- package/docs/reference/testing.md +2 -1
- package/docs/reference/tools.md +7 -6
- package/docs/zh/README.md +1 -1
- package/docs/zh/faq.md +9 -6
- package/docs/zh/guide/install-upgrade-migration.md +11 -4
- package/docs/zh/guide/quickstart.md +13 -8
- package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
- package/docs/zh/operations/security.md +13 -6
- package/docs/zh/reference/blockers.md +10 -10
- package/docs/zh/reference/config.md +7 -6
- package/docs/zh/reference/errors.md +2 -1
- package/docs/zh/reference/events.md +6 -3
- package/docs/zh/reference/testing.md +3 -2
- package/docs/zh/reference/tools.md +7 -6
- package/index.js +26 -17
- package/lib/config.js +100 -6
- package/lib/doctor.js +11 -5
- package/lib/errors.js +14 -2
- package/lib/git.js +36 -2
- package/lib/modeStore.js +54 -20
- package/lib/store.js +65 -10
- package/lib/tests.js +35 -1
- package/lib/tools.js +257 -47
- package/lib/tower.js +268 -45
- package/lib/workspaceLock.js +44 -20
- package/package.json +3 -2
- package/scripts/check-docs.mjs +35 -0
- package/skills/tower-recovery.md +7 -5
- package/ui-bundle/README.md +111 -0
- package/ui-bundle/client.js +1980 -0
- package/ui-bundle/cordis.patch.yml +3 -0
- package/ui-bundle/icon.svg +6 -0
- package/ui-bundle/index.js +2 -0
- package/ui-bundle/locale/en.json +6 -0
- package/ui-bundle/locale/zh.json +6 -0
- package/ui-bundle/package.json +35 -0
|
@@ -49,8 +49,9 @@ stdout/stderr 文件、SHA-256 哈希、退出码、计数、required 名称、w
|
|
|
49
49
|
|
|
50
50
|
- `tower_test({repoRoot,missionId})` 按需运行发现并记录回执。
|
|
51
51
|
- `tests.autoChecks` 开启时,`tower_complete` 会添加一条 `tests` 检查。
|
|
52
|
-
- `gates.requireChecks` 要求任意完成检查;`gates.requireTests`
|
|
53
|
-
|
|
52
|
+
- `gates.requireChecks` 要求任意完成检查;`gates.requireTests` 要求针对当前 mission 分支 tip 的
|
|
53
|
+
通过回执(`tests-missing`)。测试门禁与审查独立:即使 `review.requireReview:false`,测试门禁仍
|
|
54
|
+
检查分支 tip。
|
|
54
55
|
|
|
55
56
|
## 信任边界
|
|
56
57
|
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
[English](../../reference/tools.md) | **简体中文**
|
|
4
4
|
|
|
5
5
|
所有工具都接受 `repoRoot`(目标 Git 仓库内的绝对路径)并返回 JSON。
|
|
6
|
-
本构建最多
|
|
7
|
-
`mode.allowAgentControl`
|
|
8
|
-
`tower_reload`,以及 2.1 的验证/运维工具 `tower_doctor`、`tower_test`、`tower_batch`、
|
|
6
|
+
本构建最多 29 个工具:下列 19 个核心仓库工具(含 `tower_interrupt`)、`tower_mode`、默认注册的
|
|
7
|
+
`tower_control`(设 `mode.allowAgentControl:false` 可禁用)、`dev.hotReload` 开启时注册的
|
|
8
|
+
`tower_reload`,以及 2.1 的验证/运维工具 `tower_doctor`、`tower_recover`、`tower_test`、`tower_batch`、
|
|
9
9
|
`tower_resource`、`tower_import_legacy`、`tower_legacy_map`。
|
|
10
10
|
|
|
11
11
|
`tower_mission` 使用 `id`(别名 `missionId`);其他所有 mission 级工具使用 `missionId`
|
|
@@ -32,7 +32,7 @@ roster 角色由 `exec.agent` 与 roster 共同决定。mission 级工具(`tow
|
|
|
32
32
|
| 工具 | 参数 | 用途 / 说明 |
|
|
33
33
|
|---|---|---|
|
|
34
34
|
| `tower_init` | `base?`、`autoGitInit?`、`snapshotDirty?`、`reanchor?` | 创建 `<git-common-dir>/dsh-tower/state.json`。`autoGitInit` 执行 `git init` 并创建空的初始提交;`snapshotDirty` 记录一个游离快照提交作为 mission 基线;`reanchor` 仅在没有非终态 mission 时修改基线。返回 `dirty`、`snapshotCommit`、`reanchored`。 |
|
|
35
|
-
| `tower_plan` | `missions[]`(`title`、`scope[]`、`tasks[]?`、`deps[]?`、`kind?`、`context?`、`references[]?` ≤20、`reviewPolicy?`、`reviewExemptionReason?`) | 创建 mission `M1..`;重叠的 build scope 会自动加依赖;拒绝未知依赖与依赖循环。`reviewPolicy:"none"`
|
|
35
|
+
| `tower_plan` | `missions[]`(`title`、`scope[]`、`tasks[]?`、`deps[]?`、`kind?`、`context?`、`references[]?` ≤20、`reviewPolicy?`、`reviewExemptionReason?`) | 创建 mission `M1..`;重叠的 build scope 会自动加依赖;拒绝未知依赖与依赖循环。`reviewPolicy:"none"` 需要非空理由;审查门禁开启时,preflight 还要求 `gates.allowReviewExemption`。 |
|
|
36
36
|
| `tower_mission` | `id`(别名 `missionId`)、`status?`、`task_done?`、`task_drop?`+`task_drop_reason?`、`blocker?`、`clear_blockers?`、`note?`、`scope?`、`pause_cause?`、`authorization?` | 更新单个 mission。worker 可安全使用的字段是 task/blocker/note;不能在这里设置 `merged`。暂停的 mission 记录 `pause_cause`(user/failure/resource/dependency);恢复用户暂停的 mission 会遵循 `mode.requireResumeAuthorization`。 |
|
|
37
37
|
| `tower_status` | `missionId?`、`offset?`、`limit?`、`includeGate?`、`format?` | 默认返回面板。`missionId` 返回完整详情(任务/备注/审查/finding/完成报告)。`offset`/`limit` 对 mission 分页(默认 20,最大 100),`includeGate` 附带门禁摘要,`format:"markdown"` 返回看板。 |
|
|
38
38
|
| `tower_spawn` | `missionId`(别名 `id`)、`role`(`worker`/`reviewer`)、`name?`、`instructions?`、`worktreeRoot?`、`wait?`、`waitMs?`、`takeover?`、`includeDirtyBase?` | 准备或复用 worktree 并启动子代理。worker 使用 `tower-worker`;reviewer 要求 mission 为 `completed`,并使用只读的 `tower-reviewer`。`takeover:true` 替换已不再运行的 worker 条目(ownerGeneration 递增)。每个 mission 只能有一个活跃 worker;`spawn.maxLiveWorkers` 限制整个仓库。 |
|
|
@@ -41,7 +41,7 @@ roster 角色由 `exec.agent` 与 roster 共同决定。mission 级工具(`tow
|
|
|
41
41
|
| `tower_diff` | `missionId`(别名 `id`)、`maxBytes?` | 只读 merge-base diff:文件、提交、有上限的 patch。供 reviewer 安全使用。 |
|
|
42
42
|
| `tower_send` | `to`、`subject`、`body`、`scope?`、`interrupt?`、`consentRef?` | 向 roster 名称、`tower` 或 `all` 发送持久消息;对运行中的 agent 会即时投递。`interrupt:true` 先中断运行中的回合(Kimi 风格)再投递;`consentRef` 记录用于审计。受 `send.maxPerMinute` 限速。 |
|
|
43
43
|
| `tower_inbox` | `limit?`、`include_read?` | 读取并确认发给调用者的消息;未读消息会阻止 `tower_complete`。 |
|
|
44
|
-
| `tower_complete` | `missionId`(别名 `id`)、`report`、`checks?` | worker 报告完成(mission 变为 `completed
|
|
44
|
+
| `tower_complete` | `missionId`(别名 `id`)、`report`、`checks?` | worker 报告完成(mission 变为 `completed`,可进入 preflight 与可选审查)。要求当前 owner、无未完成任务/blocker、≥1 个提交、无未读消息。`checks` 记录 `{name, command?, exitCode?, evidence?}` 证据;`gates.requireChecks` 可以强制要求。配置了测试发现时还会自动运行并添加 `tests` 检查;失败会抛出 `[tower:tests-failed]`。 |
|
|
45
45
|
| `tower_interrupt` | `to?`(roster 名称)、`agentId?`、`reason?` | 通过 `subagents.interrupt` 中断一个运行中的 roster agent,并记录 `state:"interrupted"`。幂等(未运行 → `interrupted:false`)。 |
|
|
46
46
|
| `tower_finding` | `type`、`title`、`summary`、`details`、`suggested_fix`、`severity?`、`missionId?`、`location?` | 记录 scope 之外的问题;reviewer 用它代替直接改文件。新 finding 初始为 `open`。 |
|
|
47
47
|
| `tower_findings` | `id?`、`status?`、`note?`、过滤(`type?`、`severity?`、`missionId?`、`open?`、`limit?`) | 生命周期工具:列出/读取/流转。状态为 `open → triaged → fixed`/`wontfix`,允许重新打开。只有控制塔或 worker 能修改;reviewer 只能列出/读取。 |
|
|
@@ -51,9 +51,10 @@ roster 角色由 `exec.agent` 与 roster 共同决定。mission 级工具(`tow
|
|
|
51
51
|
| `tower_rebase` | `missionId`(别名 `id`) | 在 worktree 内把分支 rebase 到基线。worker 运行中或 worktree 脏时拒绝。分支已包含在基线中时返回 `alreadyMerged` 且保持分支不动。 |
|
|
52
52
|
| `tower_teardown` | `force?`、`exclude?`、`dry_run?`(别名 `dryRun`) | 移除符合条件的 merged/abandoned worktree 与分支;运行中的 agent 始终保留。 |
|
|
53
53
|
| `tower_mode` | — | 报告模式、最近修改、被暂停的 goal、被拒绝的工具、store 路径、插件版本、HMR 可用性、workspace 锁持有者。 |
|
|
54
|
-
| `tower_control` | `action`(`enter`/`exit`)、`reason` |
|
|
54
|
+
| `tower_control` | `action`(`enter`/`exit`)、`reason` | 模型主动切换模式;默认注册,设 `mode.allowAgentControl:false` 可禁用;`reason` 必填。 |
|
|
55
55
|
| `tower_reload` | `repoRoot?` | 仅开发用:请求 DSH HMR 对账 profile 并从磁盘重载 Host 插件。`dev.hotReload` 开启时注册;在工具结果返回后调度(默认 50 ms);没有 HMR 时以 `reload-unavailable` 失败。传入 `repoRoot` 时调用者必须是控制塔。 |
|
|
56
56
|
| `tower_doctor` | —(需要 `repoRoot`) | 只读健康契约:git、state schema、config、preset/subagents/HMR、workspace 锁、roster、worktree realpath/归属、测试配置、leases。返回 `{ok, checks[{id,status,detail}], environmentContract}`。从不写状态。 |
|
|
57
|
+
| `tower_recover` | `action`(`status`/`clear-stale-lock`)、`confirm?` | 恢复诊断与显式 stale state-lock 修复。`clear-stale-lock` 需要 `confirm:"CLEAR_STALE_LOCK"`,只把 PID 已死的锁移到备份文件;不会重置状态或 worktree。 |
|
|
57
58
|
| `tower_test` | `missionId`(别名 `id`) | 运行 worktree 的 `package.json` 中 `tower.testDiscovery` 配置的发现命令并记录回执;失败时 `ok:false`。自动准入检查请用 `tower_complete`。 |
|
|
58
59
|
| `tower_batch` | `action`(`create`/`status`/`close`/`abandon`)、`missionIds?`、`exclusions?`、`requiredChecks?`、`receiptPaths?`、`batchId?`、`checks?`、`treeTip?`、`receiptHashes?`、`reason?` | 有限证据批次。create 冻结成员/检查/回执;close 校验每条必需检查与回执哈希;`merged` 成员也可参与 close;abandon 可退掉过期的 open batch 且不删除 mission。成员关系不可变。 |
|
|
59
60
|
| `tower_resource` | `action`(`acquire`/`release`/`status`)、`name?`、`holder?`、`token?`、`ttlMs?`、`force?` | 每个仓库的咨询式租约,位于 `<common>/dsh-tower/leases.json`。没有 OS CPU/内存硬上限;过期的租约需要显式 `force` 与正确 token。 |
|
package/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// dsh-tower — Host entry.
|
|
2
2
|
//
|
|
3
|
-
// Tower mode is a session-level stance.
|
|
4
|
-
// `/tower` command
|
|
5
|
-
// [message]`, and `/tower <message
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// Tower mode is a session-level stance. It is intentionally operable by both
|
|
4
|
+
// sides: the `/tower` command (bare `/tower`, `/tower on [message]`, `/tower
|
|
5
|
+
// off [message]`, and `/tower <message>`; trailing text is forwarded to the
|
|
6
|
+
// model as the next user message) and, when config.allowAgentControl is true
|
|
7
|
+
// (the default), the model through `tower_control`. Every change is recorded
|
|
8
|
+
// with its actor and reason; set allowAgentControl:false for human-only mode
|
|
9
|
+
// control.
|
|
9
10
|
//
|
|
10
11
|
// `/tower reload` asks DSH HMR to reconcile this profile and reload the Tower
|
|
11
12
|
// plugin from disk when the HMR service is present (dev/linked installs).
|
|
@@ -31,6 +32,11 @@ export const Config = ConfigSchema
|
|
|
31
32
|
|
|
32
33
|
let MODE = new Map()
|
|
33
34
|
let denyTools = new Set(DEFAULT_DENY_TOOLS)
|
|
35
|
+
|
|
36
|
+
function modeOn(key) {
|
|
37
|
+
const entry = key ? MODE.get(key) : undefined
|
|
38
|
+
return Boolean(entry && entry.on === true)
|
|
39
|
+
}
|
|
34
40
|
const READ_ONLY_TOOL_NAMES = new Set([
|
|
35
41
|
'tower_status',
|
|
36
42
|
'tower_roster',
|
|
@@ -44,11 +50,11 @@ const BASE_REMINDER = [
|
|
|
44
50
|
'Tower mode is active for this session. You are the control tower: plan missions, spawn workers and reviewers, and integrate only through the Tower tools.',
|
|
45
51
|
'Mission tracking lives in tower_plan / tower_mission / tower_status, not todo_write or the Team task board.',
|
|
46
52
|
'Do not create or resume goals, do not spawn teammates, and never merge without a green tower_preflight.',
|
|
47
|
-
'
|
|
53
|
+
'Tower mode changes are explicit and recorded; the user can always use /tower on|off.',
|
|
48
54
|
].join(' ')
|
|
49
55
|
|
|
50
56
|
const AGENT_CONTROL_NOTE =
|
|
51
|
-
'
|
|
57
|
+
'You may also enter or leave Tower mode with tower_control when the user intent or task state clearly calls for it; always state a concrete reason.'
|
|
52
58
|
|
|
53
59
|
function readPluginVersion() {
|
|
54
60
|
if (pluginVersion !== null) return pluginVersion
|
|
@@ -187,12 +193,15 @@ function enterMode(ctx, key, agent, by, reason) {
|
|
|
187
193
|
async function exitMode(key, by, reason) {
|
|
188
194
|
const previous = MODE.get(key)
|
|
189
195
|
if (!previous) return { wasOn: false, pausedGoal: null, goalNote: null }
|
|
190
|
-
|
|
196
|
+
const wasOn = previous.on === true
|
|
197
|
+
// Keep a timestamped off tombstone so a concurrent save from another
|
|
198
|
+
// session cannot resurrect this mode entry.
|
|
199
|
+
MODE.set(key, { on: false, at: Date.now() })
|
|
191
200
|
persistModes()
|
|
192
201
|
if (previous.lock) {
|
|
193
202
|
await releaseWorkspaceLock(previous.lock.commonDir, { token: previous.lock.token }).catch(() => {})
|
|
194
203
|
}
|
|
195
|
-
return { wasOn
|
|
204
|
+
return { wasOn, pausedGoal: previous.pausedGoal ?? null, goalNote: null, by, reason: reason ?? null }
|
|
196
205
|
}
|
|
197
206
|
|
|
198
207
|
function forwardInstruction(agent, message, attachments) {
|
|
@@ -256,7 +265,7 @@ export function apply(ctx, rawConfig) {
|
|
|
256
265
|
const key = agentKey(exec?.agent)
|
|
257
266
|
if (!key) return
|
|
258
267
|
const entry = MODE.get(key)
|
|
259
|
-
if (!entry) return
|
|
268
|
+
if (!entry || entry.on !== true) return
|
|
260
269
|
const repo = await resolveRepo(repoRoot)
|
|
261
270
|
const existing = entry.lock
|
|
262
271
|
if (existing && existing.commonDir !== repo.commonDir) {
|
|
@@ -304,7 +313,7 @@ export function apply(ctx, rawConfig) {
|
|
|
304
313
|
},
|
|
305
314
|
basePreference(exec) {
|
|
306
315
|
const key = agentKey(exec?.agent)
|
|
307
|
-
return key ? MODE.get(key)?.baseBranch ?? null : null
|
|
316
|
+
return key && modeOn(key) ? MODE.get(key)?.baseBranch ?? null : null
|
|
308
317
|
},
|
|
309
318
|
}
|
|
310
319
|
|
|
@@ -324,7 +333,7 @@ export function apply(ctx, rawConfig) {
|
|
|
324
333
|
ctx.effect(() =>
|
|
325
334
|
ctx.tools.guard((execution) => {
|
|
326
335
|
const key = agentKey(execution.agent)
|
|
327
|
-
if (!key || !
|
|
336
|
+
if (!key || !modeOn(key)) return undefined
|
|
328
337
|
if (denyTools.has(execution.name)) {
|
|
329
338
|
return `tower mode: ${execution.name} is disabled while Tower mode is active; use the Tower tools or /tower off`
|
|
330
339
|
}
|
|
@@ -345,7 +354,7 @@ export function apply(ctx, rawConfig) {
|
|
|
345
354
|
typeof ctx.on === 'function'
|
|
346
355
|
? ctx.on('agent/pre-step', (payload, next) => {
|
|
347
356
|
const key = agentKey(payload?.agent)
|
|
348
|
-
if (key &&
|
|
357
|
+
if (key && modeOn(key)) {
|
|
349
358
|
// Once Tower is active, Tower wins regardless of entry policy: clear
|
|
350
359
|
// any plan state so the two policies cannot coexist.
|
|
351
360
|
const state = planModeState(ctx, payload.agent)
|
|
@@ -359,7 +368,7 @@ export function apply(ctx, rawConfig) {
|
|
|
359
368
|
ctx.systemPrompt.section({
|
|
360
369
|
name: 'tower:mode',
|
|
361
370
|
order: 15000,
|
|
362
|
-
text: (context) => (context &&
|
|
371
|
+
text: (context) => (context && modeOn(agentKey(context.agent)) ? reminder : ''),
|
|
363
372
|
})
|
|
364
373
|
|
|
365
374
|
// Workspace-lock heartbeat; only sessions holding a lock do I/O.
|
|
@@ -474,7 +483,7 @@ export function apply(ctx, rawConfig) {
|
|
|
474
483
|
return {
|
|
475
484
|
ok: true,
|
|
476
485
|
action: args.action,
|
|
477
|
-
mode:
|
|
486
|
+
mode: modeOn(key) ? 'on' : 'off',
|
|
478
487
|
changed: args.action === 'enter' ? !outcome.wasOn : outcome.wasOn,
|
|
479
488
|
changedBy: 'agent',
|
|
480
489
|
reason,
|
|
@@ -497,7 +506,7 @@ export function apply(ctx, rawConfig) {
|
|
|
497
506
|
const baseMatch = String(rawInput ?? '').trim().match(/^base\s+(\S+)\s*$/i)
|
|
498
507
|
if (baseMatch) {
|
|
499
508
|
const entry = MODE.get(key)
|
|
500
|
-
if (!entry) return { kind: 'error', text: 'Run /tower on first, then /tower base <branch>.' }
|
|
509
|
+
if (!entry || entry.on !== true) return { kind: 'error', text: 'Run /tower on first, then /tower base <branch>.' }
|
|
501
510
|
entry.baseBranch = baseMatch[1]
|
|
502
511
|
persistModes()
|
|
503
512
|
return { kind: 'success', text: `Tower base preference set to "${entry.baseBranch}"; tower_init without an explicit base will use it.` }
|
package/lib/config.js
CHANGED
|
@@ -30,7 +30,50 @@ export const DEFAULT_DENY_TOOLS = [
|
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
const LEGACY_KEYS = ['section', 'allowAgentControl']
|
|
33
|
-
const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'worktrees', 'spawn', 'send', 'tests', 'gates', 'docs', 'leases', 'dev']
|
|
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']
|
|
34
77
|
|
|
35
78
|
const ModelSchema = () =>
|
|
36
79
|
Schema.object({
|
|
@@ -101,6 +144,11 @@ export const Config = Schema.object({
|
|
|
101
144
|
})
|
|
102
145
|
.default({})
|
|
103
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.'),
|
|
104
152
|
worktrees: Schema.object({
|
|
105
153
|
root: Schema.string().description('Default parent directory for mission worktrees; empty derives <main-checkout-parent>/.dsh-tower-worktrees/<hash>.'),
|
|
106
154
|
})
|
|
@@ -145,8 +193,8 @@ export const Config = Schema.object({
|
|
|
145
193
|
tests: Schema.object({
|
|
146
194
|
enabled: Schema.boolean().default(true).description('Run declared test discovery when a mission configures it.'),
|
|
147
195
|
admission: Schema.union(['spawn', 'complete', 'off'])
|
|
148
|
-
.default('
|
|
149
|
-
.description('Run discovery before spawning a worker, when completing, or only on explicit tower_test.'),
|
|
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).'),
|
|
150
198
|
timeoutMs: Schema.natural().min(1000).max(1800000).default(60000).description('Per discovery command timeout.'),
|
|
151
199
|
maxBytes: Schema.natural().min(1024).max(16 * 1024 * 1024).default(16 * 1024 * 1024).description('Maximum captured stdout bytes per command.'),
|
|
152
200
|
autoChecks: Schema.boolean().default(true).description('Attach a tests check to the completion report when discovery runs.'),
|
|
@@ -236,14 +284,39 @@ function normalizeLegacy(raw = {}) {
|
|
|
236
284
|
return out
|
|
237
285
|
}
|
|
238
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
|
+
|
|
239
294
|
function assertKnownKeys(raw, label) {
|
|
240
295
|
const unknown = Object.keys(raw).filter((key) => !LEGACY_KEYS.includes(key) && !GROUP_KEYS.includes(key))
|
|
241
296
|
if (unknown.length > 0) {
|
|
242
297
|
throw new TowerToolError(
|
|
243
298
|
'config-unknown-key',
|
|
244
|
-
`${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, worktrees?, spawn?, send?, tests?, gates?, docs?, leases?, dev? }`,
|
|
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? }`,
|
|
245
300
|
)
|
|
246
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
|
+
}
|
|
247
320
|
}
|
|
248
321
|
|
|
249
322
|
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
@@ -284,7 +357,7 @@ export function resolveTowerConfig(rawConfig) {
|
|
|
284
357
|
// Apply the canonical default only after alias resolution: a validated
|
|
285
358
|
// Cordis config may omit this key, and an explicit legacy top-level
|
|
286
359
|
// `allowAgentControl:true` must not be shadowed by a schema default.
|
|
287
|
-
if (mode.allowAgentControl === undefined) mode.allowAgentControl =
|
|
360
|
+
if (mode.allowAgentControl === undefined) mode.allowAgentControl = true
|
|
288
361
|
if (!Array.isArray(mode.denyTools) || mode.denyTools.length === 0) mode.denyTools = [...DEFAULT_DENY_TOOLS]
|
|
289
362
|
return {
|
|
290
363
|
section: mode.promptSection ?? '',
|
|
@@ -292,6 +365,7 @@ export function resolveTowerConfig(rawConfig) {
|
|
|
292
365
|
mode,
|
|
293
366
|
review: clone(parsed.review ?? {}),
|
|
294
367
|
merge: clone(parsed.merge ?? {}),
|
|
368
|
+
git: clone(parsed.git ?? {}),
|
|
295
369
|
worktrees: clone(parsed.worktrees ?? {}),
|
|
296
370
|
spawn: clone(parsed.spawn ?? {}),
|
|
297
371
|
send: clone(parsed.send ?? {}),
|
|
@@ -373,7 +447,27 @@ export async function readRepoConfig(commonDir) {
|
|
|
373
447
|
export async function resolveRepoConfig(repo) {
|
|
374
448
|
const base = runtimeConfig()
|
|
375
449
|
const layer = await readRepoConfig(repo.commonDir)
|
|
376
|
-
|
|
450
|
+
// The mode guard is a session/profile concern. A repository checkout may
|
|
451
|
+
// only tighten it, never weaken denyTools, workspaceLock, allowAgentControl,
|
|
452
|
+
// plan policy, or resume authorization.
|
|
453
|
+
const repoLayer = { ...layer.config }
|
|
454
|
+
delete repoLayer.mode
|
|
455
|
+
delete repoLayer.section
|
|
456
|
+
delete repoLayer.allowAgentControl
|
|
457
|
+
const merged = deepMerge(base, repoLayer)
|
|
458
|
+
const repoMode = layer.config.mode
|
|
459
|
+
if (repoMode && typeof repoMode === 'object' && !Array.isArray(repoMode)) {
|
|
460
|
+
merged.mode = { ...(merged.mode ?? {}) }
|
|
461
|
+
if (repoMode.requireResumeAuthorization === true) merged.mode.requireResumeAuthorization = true
|
|
462
|
+
if (repoMode.workspaceLock === true) merged.mode.workspaceLock = true
|
|
463
|
+
if (repoMode.autoPauseGoal === true) merged.mode.autoPauseGoal = true
|
|
464
|
+
if (repoMode.workspaceLockScope === 'all') merged.mode.workspaceLockScope = 'all'
|
|
465
|
+
if (repoMode.planModePolicy === 'refuse') merged.mode.planModePolicy = 'refuse'
|
|
466
|
+
if (repoMode.allowAgentControl === false) merged.mode.allowAgentControl = false
|
|
467
|
+
if (Array.isArray(repoMode.denyTools)) {
|
|
468
|
+
merged.mode.denyTools = [...new Set([...(base.mode?.denyTools ?? DEFAULT_DENY_TOOLS), ...repoMode.denyTools])]
|
|
469
|
+
}
|
|
470
|
+
}
|
|
377
471
|
if (!Array.isArray(merged.mode?.denyTools) || merged.mode.denyTools.length === 0) {
|
|
378
472
|
merged.mode = { ...(merged.mode ?? {}), denyTools: [...DEFAULT_DENY_TOOLS] }
|
|
379
473
|
}
|
package/lib/doctor.js
CHANGED
|
@@ -4,6 +4,7 @@ import { promisify } from 'node:util'
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import fsp from 'node:fs/promises'
|
|
6
6
|
import { git } from './git.js'
|
|
7
|
+
import { readStateLock } from './store.js'
|
|
7
8
|
import { inspectWorkspaceLock } from './workspaceLock.js'
|
|
8
9
|
|
|
9
10
|
const execFileAsync = promisify(execFile)
|
|
@@ -78,11 +79,13 @@ export async function runDoctor({ tower, ctx, agent }) {
|
|
|
78
79
|
checks.push(hmr ? check('hmr', 'pass', 'service mounted') : check('hmr', 'warn', 'HMR is not mounted; /tower reload is unavailable'))
|
|
79
80
|
|
|
80
81
|
if (state) {
|
|
81
|
-
const
|
|
82
|
+
const lock = await readStateLock(tower.paths)
|
|
82
83
|
checks.push(
|
|
83
|
-
|
|
84
|
-
? check('state-lock', '
|
|
85
|
-
:
|
|
84
|
+
!lock.record
|
|
85
|
+
? check('state-lock', 'pass', 'no state lock file')
|
|
86
|
+
: lock.alive === false
|
|
87
|
+
? check('state-lock', 'warn', `stale state lock (pid ${lock.record.pid ?? 'unknown'} is gone); the next mutation reclaims it atomically`)
|
|
88
|
+
: check('state-lock', 'warn', `state lock held by pid ${lock.record.pid ?? 'unknown'}; retry after it finishes`),
|
|
86
89
|
)
|
|
87
90
|
const lockDir = path.join(tower.commonDir, 'dsh-tower', 'tower.lock')
|
|
88
91
|
try {
|
|
@@ -121,7 +124,7 @@ export async function runDoctor({ tower, ctx, agent }) {
|
|
|
121
124
|
const removedNote = removedWorktrees > 0 ? ` (${removedWorktrees} removed by teardown)` : ''
|
|
122
125
|
checks.push(
|
|
123
126
|
missingWorktrees > 0
|
|
124
|
-
? check('worktrees', 'fail', `${missingWorktrees} mission worktree(s) are missing or invalid${removedNote}`, '
|
|
127
|
+
? check('worktrees', 'fail', `${missingWorktrees} mission worktree(s) are missing or invalid${removedNote}`, 'inspect tower_status, restore the worktree/branch, then re-run tower_doctor')
|
|
125
128
|
: dirtyWorktrees > 0
|
|
126
129
|
? check('worktrees', 'warn', `${dirtyWorktrees} worktree(s) have uncommitted changes${removedNote}`)
|
|
127
130
|
: check('worktrees', 'pass', `all registered worktrees are valid${removedNote}`),
|
|
@@ -165,6 +168,9 @@ export async function runDoctor({ tower, ctx, agent }) {
|
|
|
165
168
|
repoRoot: tower.root,
|
|
166
169
|
commonDir: tower.commonDir,
|
|
167
170
|
stateDir: tower.paths.dir,
|
|
171
|
+
gitTimeoutMs: Number(tower.config.git?.timeoutMs) || 15000,
|
|
172
|
+
mergeTimeoutMs: Number(tower.config.merge?.timeoutMs) || 900000,
|
|
173
|
+
testsAdmission: tower.config.tests?.admission ?? 'off',
|
|
168
174
|
},
|
|
169
175
|
mode: agent ? 'ready' : 'unknown',
|
|
170
176
|
}
|
package/lib/errors.js
CHANGED
|
@@ -20,6 +20,7 @@ export const ERROR_CODES = {
|
|
|
20
20
|
'config-unknown-key': 'Tower configuration has an unknown top-level key.',
|
|
21
21
|
'git-failed': 'A git command failed.',
|
|
22
22
|
'git-timeout': 'A git command timed out or exceeded its output buffer.',
|
|
23
|
+
'worktree-dirty': 'The mission worktree has uncommitted changes and cannot complete.',
|
|
23
24
|
'spawn-unavailable': 'The subagents service is unavailable.',
|
|
24
25
|
'provider-missing': 'No subagent provider is registered.',
|
|
25
26
|
'preset-missing': 'The configured agent preset is not registered.',
|
|
@@ -45,6 +46,9 @@ export class TowerToolError extends Error {
|
|
|
45
46
|
super(`[tower:${code}] ${message}`, options.cause ? { cause: options.cause } : undefined)
|
|
46
47
|
this.name = 'TowerToolError'
|
|
47
48
|
this.code = code
|
|
49
|
+
this.retryable = options.retryable === true
|
|
50
|
+
this.nextAction = options.nextAction ?? null
|
|
51
|
+
this.details = options.details ?? null
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -95,7 +99,12 @@ function originalMessage(error) {
|
|
|
95
99
|
export function classifyError(error) {
|
|
96
100
|
if (error instanceof TowerToolError) return error
|
|
97
101
|
if (error && typeof error.code === 'string' && ERROR_CODES[error.code]) {
|
|
98
|
-
return new TowerToolError(error.code, originalMessage(error), {
|
|
102
|
+
return new TowerToolError(error.code, originalMessage(error), {
|
|
103
|
+
cause: error,
|
|
104
|
+
retryable: error.retryable === true,
|
|
105
|
+
nextAction: error.nextAction ?? null,
|
|
106
|
+
details: error.details ?? null,
|
|
107
|
+
})
|
|
99
108
|
}
|
|
100
109
|
if (error instanceof GitError || error?.name === 'GitError') {
|
|
101
110
|
const message = originalMessage(error)
|
|
@@ -104,8 +113,11 @@ export function classifyError(error) {
|
|
|
104
113
|
return new TowerToolError(code, message, { cause: error })
|
|
105
114
|
}
|
|
106
115
|
const message = originalMessage(error)
|
|
116
|
+
const RETRYABLE = new Set(['state-locked', 'rate-limited', 'git-timeout', 'workspace-busy', 'merge-intent-pending'])
|
|
107
117
|
for (const [code, pattern] of RULES) {
|
|
108
|
-
if (pattern.test(message))
|
|
118
|
+
if (pattern.test(message)) {
|
|
119
|
+
return new TowerToolError(code, message, { cause: error, retryable: RETRYABLE.has(code) })
|
|
120
|
+
}
|
|
109
121
|
}
|
|
110
122
|
return new TowerToolError('tool-internal', message, { cause: error })
|
|
111
123
|
}
|
package/lib/git.js
CHANGED
|
@@ -4,6 +4,40 @@ import path from 'node:path'
|
|
|
4
4
|
|
|
5
5
|
const GIT_TIMEOUT_MS = 15000
|
|
6
6
|
const MAX_BUFFER = 16 * 1024 * 1024
|
|
7
|
+
let configuredTimeoutMs = GIT_TIMEOUT_MS
|
|
8
|
+
|
|
9
|
+
/** Override the default Git timeout from resolved Tower config. */
|
|
10
|
+
export function setGitTimeout(ms) {
|
|
11
|
+
const value = Number(ms)
|
|
12
|
+
if (Number.isFinite(value) && value >= 1000) configuredTimeoutMs = value
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const BLOCKED_GIT_ENV = new Set([
|
|
16
|
+
'GIT_DIR',
|
|
17
|
+
'GIT_WORK_TREE',
|
|
18
|
+
'GIT_INDEX_FILE',
|
|
19
|
+
'GIT_OBJECT_DIRECTORY',
|
|
20
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
21
|
+
'GIT_COMMON_DIR',
|
|
22
|
+
'GIT_NAMESPACE',
|
|
23
|
+
'GIT_PREFIX',
|
|
24
|
+
'GIT_CEILING_DIRECTORIES',
|
|
25
|
+
'GIT_CONFIG_GLOBAL',
|
|
26
|
+
'GIT_CONFIG_SYSTEM',
|
|
27
|
+
'GIT_CONFIG_NOSYSTEM',
|
|
28
|
+
'GIT_DISCOVERY_ACROSS_FILESYSTEM',
|
|
29
|
+
'GIT_QUARANTINE_PATH',
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
/** Git must not inherit a repository redirect from the DSH process environment. */
|
|
33
|
+
export function gitEnv(source = process.env) {
|
|
34
|
+
const env = {}
|
|
35
|
+
for (const [key, value] of Object.entries(source)) {
|
|
36
|
+
if (BLOCKED_GIT_ENV.has(key)) continue
|
|
37
|
+
env[key] = value
|
|
38
|
+
}
|
|
39
|
+
return env
|
|
40
|
+
}
|
|
7
41
|
|
|
8
42
|
export class GitError extends Error {
|
|
9
43
|
constructor(args, detail, code, meta = {}) {
|
|
@@ -23,10 +57,10 @@ export function git(cwd, args, options = {}) {
|
|
|
23
57
|
args,
|
|
24
58
|
{
|
|
25
59
|
cwd,
|
|
26
|
-
timeout: options.timeoutMs ??
|
|
60
|
+
timeout: options.timeoutMs ?? configuredTimeoutMs,
|
|
27
61
|
maxBuffer: options.maxBuffer ?? MAX_BUFFER,
|
|
28
62
|
windowsHide: true,
|
|
29
|
-
env:
|
|
63
|
+
env: gitEnv(),
|
|
30
64
|
},
|
|
31
65
|
(error, stdout, stderr) => {
|
|
32
66
|
if (error) {
|
package/lib/modeStore.js
CHANGED
|
@@ -62,33 +62,67 @@ export function loadModes(baseDir) {
|
|
|
62
62
|
return modes
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/** Persist the current modes atomically
|
|
65
|
+
/** Persist the current modes atomically with a short cross-process lock. */
|
|
66
66
|
export function saveModes(modes, baseDir, maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
67
|
+
const dir = storeDir(baseDir)
|
|
68
|
+
const file = storeFile(baseDir)
|
|
69
|
+
const lock = path.join(dir, 'modes.lock')
|
|
67
70
|
try {
|
|
68
|
-
const dir = storeDir(baseDir)
|
|
69
71
|
fs.mkdirSync(dir, { recursive: true })
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
.sort((a, b) => (Number(b[1]?.at) || 0) - (Number(a[1]?.at) || 0))
|
|
73
|
-
.slice(0, limit)
|
|
74
|
-
const payload = { version: 1, modes: Object.fromEntries(entries) }
|
|
75
|
-
const file = storeFile(baseDir)
|
|
76
|
-
const tmp = `${file}.tmp-${process.pid}`
|
|
77
|
-
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
|
|
78
|
-
let lastError = null
|
|
79
|
-
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
72
|
+
let acquired = false
|
|
73
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
80
74
|
try {
|
|
81
|
-
fs.
|
|
82
|
-
|
|
75
|
+
const fd = fs.openSync(lock, 'wx')
|
|
76
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }))
|
|
77
|
+
fs.closeSync(fd)
|
|
78
|
+
acquired = true
|
|
79
|
+
break
|
|
83
80
|
} catch (error) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
sleepSync(10 * (attempt + 1))
|
|
81
|
+
if (error?.code !== 'EEXIST') throw error
|
|
82
|
+
sleepSync(5)
|
|
87
83
|
}
|
|
88
84
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
85
|
+
if (!acquired) return false
|
|
86
|
+
try {
|
|
87
|
+
const limit = Number.isInteger(maxEntries) && maxEntries > 0 ? maxEntries : DEFAULT_MAX_ENTRIES
|
|
88
|
+
let disk = {}
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
91
|
+
if (parsed && typeof parsed === 'object' && parsed.modes && typeof parsed.modes === 'object') disk = parsed.modes
|
|
92
|
+
} catch {
|
|
93
|
+
disk = {}
|
|
94
|
+
}
|
|
95
|
+
// Merge by timestamp so two sessions saving concurrently do not erase
|
|
96
|
+
// each other's mode changes. A disk-only entry from another process is
|
|
97
|
+
// preserved unless this process has a newer record for the same key.
|
|
98
|
+
const merged = { ...disk }
|
|
99
|
+
for (const [key, value] of modes.entries()) {
|
|
100
|
+
const previous = merged[key]
|
|
101
|
+
if (!previous || (Number(value?.at) || 0) >= (Number(previous?.at) || 0)) merged[key] = value
|
|
102
|
+
}
|
|
103
|
+
const entries = Object.entries(merged)
|
|
104
|
+
.sort((a, b) => (Number(b[1]?.at) || 0) - (Number(a[1]?.at) || 0))
|
|
105
|
+
.slice(0, limit)
|
|
106
|
+
const payload = { version: 1, modes: Object.fromEntries(entries) }
|
|
107
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
|
|
108
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
|
|
109
|
+
let lastError = null
|
|
110
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
111
|
+
try {
|
|
112
|
+
fs.renameSync(tmp, file)
|
|
113
|
+
return true
|
|
114
|
+
} catch (error) {
|
|
115
|
+
lastError = error
|
|
116
|
+
if (!['EPERM', 'EBUSY', 'EACCES'].includes(error?.code)) break
|
|
117
|
+
sleepSync(10 * (attempt + 1))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
try { fs.unlinkSync(tmp) } catch {}
|
|
121
|
+
if (lastError) return false
|
|
122
|
+
return true
|
|
123
|
+
} finally {
|
|
124
|
+
try { fs.unlinkSync(lock) } catch {}
|
|
125
|
+
}
|
|
92
126
|
} catch {
|
|
93
127
|
return false
|
|
94
128
|
}
|