dsh-tower 1.1.2 → 1.3.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.
@@ -0,0 +1,65 @@
1
+ # Quick start
2
+
3
+ Prerequisites: DSH `0.2.1-rc.2`+, Node.js 22+, Git. Install `dsh-tower` and restart DSH (see
4
+ [install](install-upgrade-migration.md)).
5
+
6
+ ## 1. Enter Tower mode
7
+
8
+ ```
9
+ /tower
10
+ ```
11
+
12
+ The session reports `Tower mode ON`. You are now the control tower; goal/todo/team delegation tools are
13
+ reserved for normal mode.
14
+
15
+ ## 2. Initialize and plan
16
+
17
+ ```
18
+ tower_init repoRoot=D:\work\repo
19
+ tower_plan repoRoot=D:\work\repo missions=[{"title":"Feature A","scope":["src/a/**"],"tasks":["implement the feature","commit the change"],"kind":"build"}]
20
+ ```
21
+
22
+ `tower_plan` returns mission ids (`M1`). Each mission gets a branch and a worktree when spawned.
23
+
24
+ ## 3. Run the worker
25
+
26
+ ```
27
+ tower_spawn repoRoot=D:\work\repo missionId=M1 role=worker
28
+ ```
29
+
30
+ The call returns immediately with the child agent id. The worker works inside the mission worktree,
31
+ commits, marks tasks done, and calls `tower_complete`; its final report is delivered back to this
32
+ session. Use `tower_roster` to observe activity; do not poll in a loop.
33
+
34
+ ## 4. Review
35
+
36
+ ```
37
+ tower_spawn repoRoot=D:\work\repo missionId=M1 role=reviewer
38
+ ```
39
+
40
+ Reviewers use the read-only `tower-reviewer` preset. They inspect `tower_diff` and submit
41
+ `tower_review`. A `clean` + `merge` verdict is required to merge.
42
+
43
+ ## 5. Gate, merge, clean up
44
+
45
+ ```
46
+ tower_preflight repoRoot=D:\work\repo missionId=M1
47
+ tower_merge repoRoot=D:\work\repo missionId=M1
48
+ tower_teardown repoRoot=D:\work\repo
49
+ ```
50
+
51
+ `tower_preflight` before review is expected to report `no-review`; after a clean review it should be
52
+ `ok:true`. `tower_merge` refuses while blockers remain. `tower_teardown` removes merged/abandoned
53
+ worktrees and branches.
54
+
55
+ ```
56
+ /tower off
57
+ ```
58
+
59
+ ## If something fails
60
+
61
+ Read the blocker code and use [the blocker reference](reference/blockers.md). The most common cases:
62
+ `stale-base` (run `tower_rebase`), `no-review` (spawn a reviewer), `merge-hook-failed` (retry, or raise
63
+ `timeoutMs`), `tip-moved` (the branch changed after review; review again).
64
+
65
+ A complete copy-paste example: [examples/first-mission.md](../examples/first-mission.md).
@@ -0,0 +1,57 @@
1
+ # Operations runbook
2
+
3
+ ## Daily checks
4
+
5
+ - `tower_status` — counts, open tasks, roster, unread messages, recent activity.
6
+ - `tower_roster` — live activity; retired agents are recorded but never resumed.
7
+ - `tower_preflight missionId` before every merge.
8
+
9
+ ## Recover from a failed merge
10
+
11
+ 1. Read the blocker: `merge-hook-failed` (retryable) vs `merge-conflict` (mission blocked) vs
12
+ `merge-shape` (inspect manually).
13
+ 2. For a slow `pre-merge-commit` hook, retry with a larger timeout:
14
+ `tower_merge({ missionId, timeoutMs: 1800000 })`.
15
+ 3. If a merge commit landed but the call failed, re-run `tower_merge`; it records the commit
16
+ (`recoveredFromFailure`) or reconciles an already-landed merge (`reconciled`) without a second merge.
17
+ 4. Never reset or rewrite the base branch by hand while Tower state exists; use Tower tools so state and
18
+ Git stay aligned.
19
+
20
+ ## Recover from a stale branch
21
+
22
+ `tower_rebase` first. If the branch is already contained in the base it returns `alreadyMerged` and
23
+ leaves the branch untouched — then call `tower_merge` to record it. A conflict marks the mission
24
+ `blocked`; resolve in the worktree, set the mission back to `active` (or `completed`), retry.
25
+
26
+ ## State lock
27
+
28
+ `tower state is locked by another writer` means an operation holds `state.lock`. Wait and retry. Only if
29
+ the owning process is gone, remove `<git-common-dir>/dsh-tower/state.lock` deliberately; never delete it
30
+ while an agent may still be running.
31
+
32
+ ## Worktrees and teardown
33
+
34
+ `tower_teardown` removes worktrees only for `merged`/`abandoned` missions; a running agent is always
35
+ kept, and a dirty worktree requires `force: true`. Use `dry_run: true` to preview decisions. A missing
36
+ worktree can be re-added from its branch with `git worktree add`, but do not do this while the mission
37
+ is active without updating the roster.
38
+
39
+ ## Stale or corrupt state
40
+
41
+ If `state.json` is unreadable, stop all agents first. The plugin reports corruption instead of guessing.
42
+ Restore from a backup if you have one; otherwise remove `<git-common-dir>/dsh-tower/` and re-run
43
+ `tower_init` — this loses mission history but not Git branches/worktrees.
44
+
45
+ ## Full recovery of a repo's Tower data
46
+
47
+ Only when no mission is in flight and you accept losing history:
48
+
49
+ 1. Stop all tower agents.
50
+ 2. Remove merged/abandoned worktrees (`tower_teardown` first when the state is readable).
51
+ 3. Remove `<git-common-dir>/dsh-tower/`.
52
+ 4. `tower_init` again; re-plan any work still needed.
53
+
54
+ ## Mode state
55
+
56
+ Tower mode is stored in `$DSH_HOME/tower-mode/modes.json` (atomic writes, capped entries). If a mode is
57
+ stuck, `/tower off`; if the session is gone, remove its entry with the file unlocked and restart DSH.
@@ -0,0 +1,39 @@
1
+ # Security model
2
+
3
+ Tower's guarantees are **coordination guarantees**, not a sandbox. Understand what is enforced where.
4
+
5
+ ## Enforced by the plugin
6
+
7
+ - **Review binding.** `tower_review` records the branch tip it inspected; the gate rejects a moved tip
8
+ (`tip-moved`) unless a clean conflict-free rebase waived it. A reviewer that modifies the branch after
9
+ spawning is rejected (`reviewer must not modify the mission branch`).
10
+ - **Read-only reviewer preset.** The `tower-reviewer` preset removes `write`, `edit`, `pwsh`,
11
+ `tower_mission` and `tower_complete` from the tool surface; reviewers can only read, diff, message,
12
+ file findings, and submit a review.
13
+ - **Scope gate.** A mission may only change files matched by its declared scope globs; out-of-scope
14
+ changes block the merge.
15
+ - **Worktree isolation.** Workers operate in a dedicated worktree and branch; the main checkout is only
16
+ touched by merge/teardown.
17
+ - **Human-owned mode.** `/tower` is user-owned. With `mode.allowAgentControl` the model may toggle it,
18
+ but must state a reason; the shipped patch enables that by default — set it to `false` to make the
19
+ mode human-only.
20
+ - **No automatic goal resume.** Entering pauses the active goal; leaving does not resume it.
21
+
22
+ ## Not enforced by the plugin (deployment concerns)
23
+
24
+ - **Sandboxing.** Host plugins run outside the filesystem sandbox; the reviewer's shell ability is
25
+ removed at the tool-surface level, but the underlying process still has whatever OS permissions DSH
26
+ has. Deployments that need hard isolation must restrict the process/user themselves.
27
+ - **Reviewer trust.** A review is an attestation by the reviewer agent/model; the plugin binds it to a
28
+ commit but cannot prove the reviewer actually reasoned correctly. Use independent models/roles for
29
+ high-value branches.
30
+ - **Repository hooks.** `git merge` runs the target repository's hooks (`pre-merge-commit` etc.). Tower
31
+ cannot make those safe; review hooks before running Tower on an untrusted repository.
32
+ - **Prompt injection.** The reviewer reads untrusted branch content (diff/comments). The read-only preset
33
+ limits impact, but no model-side protection is absolute.
34
+ - **Base branch protection.** Tower writes to the local base branch; server-side branch protection/push
35
+ rules are not part of the plugin.
36
+
37
+ ## Reporting
38
+
39
+ Do not paste secrets, tokens, or session logs in public issues. See [SECURITY.md](../../SECURITY.md).
@@ -0,0 +1,50 @@
1
+ # Blockers and failure codes
2
+
3
+ `tower_preflight` returns `{ok:false, blockers:[{code, detail}]}`. `tower_merge` and `tower_rebase`
4
+ return the same shape for operation failures. Codes are stable; the detail is human-facing.
5
+
6
+ ## Gate blockers (19)
7
+
8
+ | Code | Meaning / next action |
9
+ |---|---|
10
+ | `mission-missing` | Unknown mission id. Check `tower_status`. |
11
+ | `mission-not-completed` | Mission is not `completed`; only a completed mission (awaiting review) merges. |
12
+ | `tasks-open` | Open tasks remain; the worker must finish or `task_drop` with a reason. |
13
+ | `blockers-present` | The mission has blockers; clear them after resolving. |
14
+ | `branch-missing` | No worktree/branch yet: spawn a worker first. |
15
+ | `stale-base` | The branch does not contain the base tip: run `tower_rebase`. |
16
+ | `read-only-survey` | Survey mission changed files; survey missions must be read-only. |
17
+ | `out-of-scope` | Changed files outside the mission scope; widen scope deliberately or revert. |
18
+ | `base-dirty` | The main checkout has uncommitted changes the merge would overwrite. |
19
+ | `deps-unmerged` | A dependency mission is not merged/abandoned. |
20
+ | `no-review` | No review on the branch: spawn a reviewer. |
21
+ | `review-mission-mismatch` | The latest review belongs to another mission. |
22
+ | `not-clean` | Latest review is `p1-Nitems`/`p2-Nitems`; another round is required. |
23
+ | `review-hold` | Latest review recommends hold; a later clean review must supersede it. |
24
+ | `review-not-approved` | Latest clean review says `fix-then-merge` or `hold`; only `merge` approves. |
25
+ | `tip-moved` | The branch changed after review; review again (or use a clean rebase waiver). |
26
+ | `worker-busy` | A roster agent for this mission is running; wait or retire it. |
27
+ | `base-wrong-branch` | The main checkout is not on the recorded base branch. |
28
+ | `branch-owned-by-closed-missions` | The mission is already merged/abandoned; re-plan under a new title. |
29
+
30
+ ## Merge failures (3)
31
+
32
+ | Code | Meaning / next action |
33
+ |---|---|
34
+ | `merge-conflict` | Real index conflict; the mission is `blocked`. Resolve, restore the mission to `active`/`completed`, retry. |
35
+ | `merge-hook-failed` | A hook/process failed before a commit existed; repository restored, mission stays `completed`. Retry; `timeout: true` means the git process timed out. Raise `timeoutMs` for slow hooks. |
36
+ | `merge-shape` | Git produced unexpected merge parents; inspect the repository, do not force. |
37
+
38
+ A merge that already landed is **not** a blocker: `tower_merge` records it (`reconciled`,
39
+ `alreadyLanded`) or recovers it (`recoveredFromFailure`, `warnings`).
40
+
41
+ ## Rebase failures (3; `worker-busy` shared)
42
+
43
+ | Code | Meaning / next action |
44
+ |---|---|
45
+ | `worktree-dirty` | Commit or discard worktree changes, then retry. |
46
+ | `rebase-conflict` | A real conflict; the mission is `blocked`. Resolve manually, return it to `active`, retry. |
47
+ | `worker-busy` | An agent is running for this mission; wait or retire it first. |
48
+
49
+ `tower_rebase` returns `alreadyMerged: true` when the branch is already contained in the base; call
50
+ `tower_merge` to record the landed merge.
@@ -0,0 +1,51 @@
1
+ # Config reference
2
+
3
+ Every key is optional; defaults reproduce the shipped behavior. Unknown top-level keys are rejected.
4
+ `section` and `allowAgentControl` are legacy aliases for `mode.promptSection` and
5
+ `mode.allowAgentControl`.
6
+
7
+ | Key | Default | Meaning |
8
+ |---|---|---|
9
+ | `section` | `''` | Legacy alias: replace the whole Tower-mode prompt text. Empty keeps the built-in reminder. |
10
+ | `allowAgentControl` | `false` (bundled patch: `true`) | Legacy alias: register `tower_control`. |
11
+ | `mode.promptSection` | `''` | Replacement prompt text (takes precedence over `section` when both set). |
12
+ | `mode.sectionAppend` | `''` | Extra text appended after the reminder (team conventions). |
13
+ | `mode.allowAgentControl` | `false` | Register `tower_control`; the model may enter/leave with a reason. |
14
+ | `mode.autoPauseGoal` | `true` | Pause an active goal on entry; the goal is never auto-resumed on exit. |
15
+ | `mode.denyTools` | six goal/todo/team tools | Tools refused while the mode is active. |
16
+ | `mode.storePath` | `$DSH_HOME/tower-mode` | Directory for `modes.json`. |
17
+ | `mode.maxEntries` | `200` | Maximum persisted mode entries (1–2000). |
18
+ | `review.maxRoundsPerReviewer` | `5` | Review-round cap per reviewer per branch. |
19
+ | `merge.timeoutMs` | `900000` (15 min) | Default `tower_merge` timeout, including hooks. A per-call `timeoutMs` wins. |
20
+ | `merge.maxBufferBytes` | `67108864` (64 MB) | Merge output buffer. |
21
+ | `worktrees.root` | `''` | Default worktree parent; empty derives `<checkout parent>/.dsh-tower-worktrees/<hash>`. |
22
+ | `spawn.workerPreset` | `tower-worker` | Preset used for `role=worker`. |
23
+ | `spawn.reviewerPreset` | `tower-reviewer` | Preset used for `role=reviewer`. |
24
+ | `spawn.maxDepth` | `1` | Subagent depth requested at spawn. |
25
+ | `spawn.provider` | `''` | Subagent provider id; empty prefers `spawn`, then the first provider. |
26
+ | `spawn.workerModel` | `{}` | Optional `{provider, model, reasoningEffort, maxTokens}` for workers. |
27
+ | `spawn.reviewerModel` | `{}` | Optional model route for reviewers; independent review is stronger with a different model. |
28
+
29
+ ## Example
30
+
31
+ ```yaml
32
+ - id: tower
33
+ name: 'dsh-tower'
34
+ config:
35
+ mode:
36
+ allowAgentControl: true
37
+ sectionAppend: 'Team conventions: tests are mandatory.'
38
+ review:
39
+ maxRoundsPerReviewer: 3
40
+ merge:
41
+ timeoutMs: 1800000
42
+ spawn:
43
+ reviewerModel:
44
+ provider: deepseek-account
45
+ model: deepseek-flash
46
+ ```
47
+
48
+ ## Looking up the effective schema
49
+
50
+ `cordis_inspect_query` with `Config.listConfigs({ name: "dsh-tower" })` returns the projected JSON
51
+ Schema once the plugin declares `Config` (this release does).
@@ -0,0 +1,33 @@
1
+ # Tools reference
2
+
3
+ All tools take `repoRoot` (an absolute path inside the target Git repository) and return JSON.
4
+ The installable build exposes 19 tools: 18 below plus `tower_control` when
5
+ `mode.allowAgentControl` is enabled (the bundled patch enables it).
6
+
7
+ | Tool | Parameters | Purpose / notes |
8
+ |---|---|---|
9
+ | `tower_init` | `base?` | Create `.git/dsh-tower/state.json`, record the base branch (idempotent; `base` is ignored on an existing state). |
10
+ | `tower_plan` | `missions[]` (`title`, `scope[]`, `tasks[]?`, `deps[]?`, `kind?`, `context?`) | Create missions `M1..`; overlapping build scopes auto-add a dependency; rejects unknown deps and cycles. |
11
+ | `tower_mission` | `id`, `status?`, `task_done?`, `task_drop?`+`task_drop_reason?`, `blocker?`, `clear_blockers?`, `note?`, `scope?` | Update one mission. `merged` cannot be set here; widening scope makes the mission active again. |
12
+ | `tower_status` | — | Mission counts, roster, open tasks, reviews, findings, recent activity. |
13
+ | `tower_spawn` | `missionId`, `role` (`worker`/`reviewer`), `name?`, `instructions?`, `worktreeRoot?`, `wait?`, `waitMs?` | Provision/reuse the worktree and start a subagent. Worker uses `tower-worker`; reviewer requires a `completed` mission and uses the read-only `tower-reviewer`. Returns agent id immediately; the child final report is delivered to this session. |
14
+ | `tower_roster` | — | Roster with live activity (`running`/`inactive`/`missing`/`unknown`) from the subagent catalog. Read-only. |
15
+ | `tower_adopt` | — | Reconcile roster entries with the durable subagent catalog after restart; provably gone agents are retired. |
16
+ | `tower_diff` | `missionId`, `maxBytes?` | Read-only merge-base diff: files, commits, capped patch. Safe for reviewers. |
17
+ | `tower_send` | `to`, `subject`, `body`, `scope?` | Durable message to a roster name, `tower`, or `all`; delivers live to a running agent. |
18
+ | `tower_inbox` | `page?`, `pageSize?` | Read/ack messages addressed to the caller; unread messages block `tower_complete`. |
19
+ | `tower_complete` | `missionId`, `agentId`, `report`, `commit?` | Worker reports completion (mission becomes `completed`, awaiting review). |
20
+ | `tower_finding` | `type`, `title`, `summary`, `details`, `suggested_fix`, `severity?`, `missionId?`, `location?` | Record an out-of-scope issue; reviewers use this instead of editing. |
21
+ | `tower_review` | `missionId`, `status` (`clean`/`p1-Nitems`/`p2-Nitems`), `merge` (`merge`/`fix-then-merge`/`hold`), `findings`, `decision`, `checks?`, `breakGlassReason?` | Reviewer verdict bound to the current tip. Reviewer must be the assigned reviewer (or tower break-glass). |
22
+ | `tower_preflight` | `missionId` | Read-only gate: returns `{ok, blockers[], branchTip, baseTip, changedFiles, review}`. |
23
+ | `tower_merge` | `missionId`, `timeoutMs?` | Merge after a green gate. If the merge commit already landed, records it (`reconciled`); if the command failed but the commit exists, records it (`recoveredFromFailure`); hook failures return `merge-hook-failed` with `cleanup` and optional `timeout`. |
24
+ | `tower_rebase` | `missionId` | Rebase the branch onto the base in its worktree. Refuses while the worker runs or the worktree is dirty. If the branch is already contained in the base it returns `alreadyMerged` and leaves it untouched. |
25
+ | `tower_teardown` | `force?`, `exclude?`, `dry_run?` | Remove eligible merged/abandoned worktrees and branches; running agents are always kept. |
26
+ | `tower_mode` | — | Report mode, who changed it, whether the model may toggle it, paused goal, denied tools, store path. |
27
+ | `tower_control` | `action` (`enter`/`exit`), `reason` | Model-initiated mode change; only when `mode.allowAgentControl` is true; reason is mandatory. |
28
+
29
+ ## Failure results
30
+
31
+ Gate tools return `{ok:false, blockers:[{code, detail}]}`. The code list is in
32
+ [blockers.md](blockers.md). `tower_merge` failure blockers may also include `timeout: true`, `cleanup`,
33
+ and `warnings`.
package/index.js CHANGED
@@ -9,20 +9,14 @@
9
9
  import { randomUUID } from 'node:crypto'
10
10
  import { registerTowerTools } from './lib/tools.js'
11
11
  import { loadModes, saveModes, storePath } from './lib/modeStore.js'
12
+ import { Config as ConfigSchema, DEFAULT_DENY_TOOLS, configureRuntime, runtimeConfig } from './lib/config.js'
12
13
 
13
14
  export const name = 'dsh-tower'
14
15
  export const inject = ['tools', 'systemPrompt']
16
+ export const Config = ConfigSchema
15
17
 
16
- const MODE = loadModes()
17
-
18
- const DENY_TOOLS = new Set([
19
- 'create_goal',
20
- 'todo_write',
21
- 'team_task_create',
22
- 'team_task_update',
23
- 'team_task_reopen',
24
- 'spawn_teammate',
25
- ])
18
+ let MODE = new Map()
19
+ let denyTools = new Set(DEFAULT_DENY_TOOLS)
26
20
 
27
21
  const BASE_REMINDER = [
28
22
  '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.',
@@ -40,18 +34,20 @@ function agentKey(agent) {
40
34
  return key === undefined || key === null ? null : String(key)
41
35
  }
42
36
 
43
- function resolveConfig(config) {
44
- const input = config ?? {}
45
- const unknown = Object.keys(input).filter((key) => !['section', 'allowAgentControl'].includes(key))
46
- if (unknown.length > 0) {
47
- throw new Error(
48
- `Tower config has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl? }`,
49
- )
50
- }
51
- const section = typeof input.section === 'string' && input.section.trim().length > 0
52
- ? input.section
37
+ function configSection(config, allowAgentControl) {
38
+ const configured = config.mode.promptSection && config.mode.promptSection.trim().length > 0
39
+ ? config.mode.promptSection
53
40
  : BASE_REMINDER
54
- return { section, allowAgentControl: input.allowAgentControl === true }
41
+ const appended = config.mode.sectionAppend && config.mode.sectionAppend.trim().length > 0
42
+ ? ` ${config.mode.sectionAppend.trim()}`
43
+ : ''
44
+ const control = allowAgentControl ? ` ${AGENT_CONTROL_NOTE}` : ''
45
+ return `${configured}${appended}${control}`
46
+ }
47
+
48
+ function persistModes() {
49
+ const config = runtimeConfig()
50
+ saveModes(MODE, config.mode.storePath || undefined, config.mode.maxEntries)
55
51
  }
56
52
 
57
53
  /** Parse `/tower` arguments: bare and free text both mean "ensure on". */
@@ -94,9 +90,10 @@ function pauseActiveGoal(ctx, agent) {
94
90
  function enterMode(ctx, key, agent, by, reason) {
95
91
  const previous = MODE.get(key)
96
92
  const wasOn = Boolean(previous && previous.on)
93
+ const autoPauseGoal = runtimeConfig().mode.autoPauseGoal !== false
97
94
  let pausedGoal = previous?.pausedGoal ?? null
98
95
  let goalNote = null
99
- if (!wasOn) {
96
+ if (!wasOn && autoPauseGoal) {
100
97
  const paused = pauseActiveGoal(ctx, agent)
101
98
  if (paused.attempted && paused.ok) pausedGoal = paused.goal
102
99
  else if (paused.attempted && !paused.ok) goalNote = `pause-failed: ${paused.reason}`
@@ -111,7 +108,7 @@ function enterMode(ctx, key, agent, by, reason) {
111
108
  lastChange: { by, action: 'on', reason: reason ?? null, at: now },
112
109
  at: now,
113
110
  })
114
- saveModes(MODE)
111
+ persistModes()
115
112
  return { wasOn, pausedGoal, goalNote }
116
113
  }
117
114
 
@@ -119,7 +116,7 @@ function exitMode(key, by, reason) {
119
116
  const previous = MODE.get(key)
120
117
  if (!previous) return { wasOn: false, pausedGoal: null, goalNote: null }
121
118
  MODE.delete(key)
122
- saveModes(MODE)
119
+ persistModes()
123
120
  return { wasOn: true, pausedGoal: previous.pausedGoal ?? null, goalNote: null, by, reason: reason ?? null }
124
121
  }
125
122
 
@@ -162,14 +159,18 @@ function describeChange(active, outcome, forwarded, message, allowAgentControl)
162
159
  }
163
160
 
164
161
  export function apply(ctx, rawConfig) {
165
- const config = resolveConfig(rawConfig)
162
+ const config = configureRuntime(rawConfig)
163
+ const allowAgentControl = config.mode.allowAgentControl === true
164
+ const reminder = configSection(config, allowAgentControl)
165
+ MODE = loadModes(config.mode.storePath || undefined)
166
+ denyTools = new Set(config.mode.denyTools)
166
167
  registerTowerTools(ctx)
167
168
 
168
169
  ctx.effect(() =>
169
170
  ctx.tools.guard((execution) => {
170
171
  const key = agentKey(execution.agent)
171
172
  if (!key || !MODE.has(key)) return undefined
172
- if (DENY_TOOLS.has(execution.name)) {
173
+ if (denyTools.has(execution.name)) {
173
174
  return `tower mode: ${execution.name} is disabled while Tower mode is active; use the Tower tools or /tower off`
174
175
  }
175
176
  if (execution.name === 'update_goal') {
@@ -185,12 +186,7 @@ export function apply(ctx, rawConfig) {
185
186
  ctx.systemPrompt.section({
186
187
  name: 'tower:mode',
187
188
  order: 15000,
188
- text: (context) =>
189
- context && MODE.has(agentKey(context.agent))
190
- ? config.allowAgentControl
191
- ? `${config.section} ${AGENT_CONTROL_NOTE}`
192
- : config.section
193
- : '',
189
+ text: (context) => (context && MODE.has(agentKey(context.agent)) ? reminder : ''),
194
190
  })
195
191
 
196
192
  ctx.effect(() =>
@@ -209,17 +205,17 @@ export function apply(ctx, rawConfig) {
209
205
  return {
210
206
  mode: entry && entry.on === true ? 'on' : key ? 'off' : 'unknown',
211
207
  agentId: key,
212
- allowAgentControl: config.allowAgentControl,
208
+ allowAgentControl,
213
209
  lastChange: entry?.lastChange ?? null,
214
210
  pausedGoal: entry?.pausedGoal ?? null,
215
- deniedWhileActive: [...DENY_TOOLS],
216
- persistedAt: storePath(),
211
+ deniedWhileActive: [...denyTools],
212
+ persistedAt: storePath(config.mode.storePath || undefined),
217
213
  }
218
214
  },
219
215
  }),
220
216
  )
221
217
 
222
- if (config.allowAgentControl) {
218
+ if (allowAgentControl) {
223
219
  ctx.effect(() =>
224
220
  ctx.tools.register({
225
221
  name: 'tower_control',
@@ -288,7 +284,7 @@ export function apply(ctx, rawConfig) {
288
284
  }
289
285
  return {
290
286
  kind: 'success',
291
- text: describeChange(active, outcome, forwarded, message, config.allowAgentControl),
287
+ text: describeChange(active, outcome, forwarded, message, allowAgentControl),
292
288
  }
293
289
  },
294
290
  })
package/lib/config.js ADDED
@@ -0,0 +1,119 @@
1
+ // Tower configuration: one declared schema, resolved defaults, and the
2
+ // process-local effective snapshot consumed by the domain modules.
3
+ //
4
+ // Defaults are exactly the 1.2.0 behavior; the shipped cordis patch keeps
5
+ // `allowAgentControl: true` via the legacy top-level key.
6
+ import Schema from '@deepseek-ai/schemastery'
7
+
8
+ export const DEFAULT_DENY_TOOLS = [
9
+ 'create_goal',
10
+ 'todo_write',
11
+ 'team_task_create',
12
+ 'team_task_update',
13
+ 'team_task_reopen',
14
+ 'spawn_teammate',
15
+ ]
16
+
17
+ const LEGACY_KEYS = ['section', 'allowAgentControl']
18
+ const GROUP_KEYS = ['mode', 'review', 'merge', 'worktrees', 'spawn']
19
+
20
+ const ModelSchema = () =>
21
+ Schema.object({
22
+ provider: Schema.string().description('Provider route override for this role.'),
23
+ model: Schema.string().description('Model id override for this role.'),
24
+ reasoningEffort: Schema.string().description('Optional reasoning effort override.'),
25
+ maxTokens: Schema.natural().description('Optional output token cap.'),
26
+ })
27
+
28
+ export const Config = Schema.object({
29
+ section: Schema.string().description(
30
+ 'Legacy alias for mode.promptSection: replaces the whole Tower-mode system prompt text.',
31
+ ),
32
+ allowAgentControl: Schema.boolean().description(
33
+ 'Legacy alias for mode.allowAgentControl: register tower_control so the model may toggle the mode.',
34
+ ),
35
+ mode: Schema.object({
36
+ promptSection: Schema.string().description('Replacement Tower-mode system prompt text. Empty keeps the built-in reminder.'),
37
+ sectionAppend: Schema.string().description('Extra text appended to the Tower-mode system prompt section (team conventions).'),
38
+ allowAgentControl: Schema.boolean().default(false).description('Register tower_control so the model may enter/leave the mode with a reason.'),
39
+ autoPauseGoal: Schema.boolean().default(true).description('Pause an active goal when entering Tower mode.'),
40
+ denyTools: Schema.array(Schema.string())
41
+ .default(DEFAULT_DENY_TOOLS)
42
+ .description('Tools refused while Tower mode is active.'),
43
+ storePath: Schema.string().description('Directory for modes.json; defaults to $DSH_HOME/tower-mode.'),
44
+ maxEntries: Schema.natural().min(1).max(2000).default(200).description('Maximum persisted mode entries.'),
45
+ })
46
+ .default({})
47
+ .description('Tower-mode behavior and persistence.'),
48
+ review: Schema.object({
49
+ maxRoundsPerReviewer: Schema.natural().min(1).default(5).description('Review-round cap per reviewer per mission branch.'),
50
+ })
51
+ .default({})
52
+ .description('Review policy.'),
53
+ merge: Schema.object({
54
+ timeoutMs: Schema.natural().min(1000).default(900000).description('Default tower_merge timeout, including pre-merge hooks.'),
55
+ maxBufferBytes: Schema.natural().min(65536).default(67108864).description('Captured merge output buffer.'),
56
+ })
57
+ .default({})
58
+ .description('Merge execution policy.'),
59
+ worktrees: Schema.object({
60
+ root: Schema.string().description('Default parent directory for mission worktrees; empty derives <main-checkout-parent>/.dsh-tower-worktrees/<hash>.'),
61
+ })
62
+ .default({})
63
+ .description('Worktree placement.'),
64
+ spawn: Schema.object({
65
+ workerPreset: Schema.string().default('tower-worker').description('Agent preset for worker missions.'),
66
+ reviewerPreset: Schema.string().default('tower-reviewer').description('Agent preset for reviewers.'),
67
+ maxDepth: Schema.natural().min(1).default(1).description('Subagent depth requested when spawning tower agents.'),
68
+ provider: Schema.string().description('Subagent provider id; empty picks spawn, then the first registered provider.'),
69
+ workerModel: ModelSchema().description('Model route for workers; omitted inherits the parent session.'),
70
+ reviewerModel: ModelSchema().description('Model route for reviewers; omitted inherits the parent session (recommended).'),
71
+ })
72
+ .default({})
73
+ .description('Worker and reviewer presets plus model routing.'),
74
+ }).description('DSH Tower configuration')
75
+
76
+ let effective = null
77
+
78
+ function clone(value) {
79
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value))
80
+ }
81
+
82
+ export function resolveTowerConfig(rawConfig) {
83
+ const raw = rawConfig ?? {}
84
+ if (raw !== null && typeof raw !== 'object') throw new Error('Tower config must be an object')
85
+ const unknown = Object.keys(raw).filter((key) => !LEGACY_KEYS.includes(key) && !GROUP_KEYS.includes(key))
86
+ if (unknown.length > 0) {
87
+ throw new Error(
88
+ `Tower config has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, mode?, review?, merge?, worktrees?, spawn? }`,
89
+ )
90
+ }
91
+ let parsed
92
+ try {
93
+ parsed = Config(raw)
94
+ } catch (error) {
95
+ throw new Error(`Tower config is invalid: ${String(error?.message ?? error)}`)
96
+ }
97
+ const mode = clone(parsed.mode ?? {})
98
+ if (raw.section !== undefined) mode.promptSection = raw.section
99
+ if (raw.allowAgentControl !== undefined) mode.allowAgentControl = raw.allowAgentControl
100
+ if (!Array.isArray(mode.denyTools) || mode.denyTools.length === 0) mode.denyTools = [...DEFAULT_DENY_TOOLS]
101
+ return {
102
+ section: mode.promptSection ?? '',
103
+ mode,
104
+ review: clone(parsed.review ?? {}),
105
+ merge: clone(parsed.merge ?? {}),
106
+ worktrees: clone(parsed.worktrees ?? {}),
107
+ spawn: clone(parsed.spawn ?? {}),
108
+ }
109
+ }
110
+
111
+ export function configureRuntime(config) {
112
+ effective = resolveTowerConfig(config)
113
+ return effective
114
+ }
115
+
116
+ export function runtimeConfig() {
117
+ if (!effective) effective = resolveTowerConfig({})
118
+ return effective
119
+ }
package/lib/git.js CHANGED
@@ -6,10 +6,13 @@ const GIT_TIMEOUT_MS = 15000
6
6
  const MAX_BUFFER = 16 * 1024 * 1024
7
7
 
8
8
  export class GitError extends Error {
9
- constructor(args, detail, code) {
9
+ constructor(args, detail, code, meta = {}) {
10
10
  super(`git ${args.join(' ')} failed: ${detail}`)
11
11
  this.name = 'GitError'
12
12
  this.code = code
13
+ this.timedOut = meta.timedOut === true
14
+ this.bufferOverflow = meta.bufferOverflow === true
15
+ this.stdout = meta.stdout ?? ''
13
16
  }
14
17
  }
15
18
 
@@ -27,11 +30,14 @@ export function git(cwd, args, options = {}) {
27
30
  },
28
31
  (error, stdout, stderr) => {
29
32
  if (error) {
33
+ const timedOut = error.killed === true || error.signal === 'SIGTERM' || error.code === 'ETIMEDOUT'
34
+ const bufferOverflow = error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
30
35
  reject(
31
36
  new GitError(
32
37
  args,
33
38
  String(stderr || error.message || '').trim(),
34
39
  typeof error.code === 'number' ? error.code : undefined,
40
+ { timedOut, bufferOverflow, stdout: String(stdout ?? '').slice(0, 64 * 1024) },
35
41
  ),
36
42
  )
37
43
  return