dsh-tower 2.0.2 → 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.
Files changed (52) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.en.md +22 -16
  3. package/README.md +22 -16
  4. package/SECURITY.md +4 -1
  5. package/docs/README.md +30 -0
  6. package/docs/architecture/overview.md +3 -2
  7. package/docs/examples/first-mission.md +2 -1
  8. package/docs/faq.md +15 -6
  9. package/docs/guide/install-upgrade-migration.md +14 -5
  10. package/docs/guide/quickstart.md +12 -7
  11. package/docs/guide/scenarios-and-philosophy.md +150 -62
  12. package/docs/operations/security.md +23 -12
  13. package/docs/reference/blockers.md +11 -10
  14. package/docs/reference/config.md +8 -6
  15. package/docs/reference/errors.md +7 -1
  16. package/docs/reference/events.md +7 -4
  17. package/docs/reference/testing.md +2 -1
  18. package/docs/reference/tools.md +7 -6
  19. package/docs/zh/README.md +1 -1
  20. package/docs/zh/faq.md +12 -4
  21. package/docs/zh/guide/install-upgrade-migration.md +11 -4
  22. package/docs/zh/guide/quickstart.md +13 -8
  23. package/docs/zh/guide/scenarios-and-philosophy.md +108 -36
  24. package/docs/zh/operations/security.md +16 -6
  25. package/docs/zh/reference/blockers.md +10 -10
  26. package/docs/zh/reference/config.md +8 -6
  27. package/docs/zh/reference/errors.md +2 -1
  28. package/docs/zh/reference/events.md +6 -3
  29. package/docs/zh/reference/testing.md +3 -2
  30. package/docs/zh/reference/tools.md +7 -6
  31. package/index.js +26 -17
  32. package/lib/config.js +103 -6
  33. package/lib/doctor.js +11 -5
  34. package/lib/errors.js +14 -2
  35. package/lib/git.js +36 -2
  36. package/lib/modeStore.js +54 -20
  37. package/lib/store.js +65 -10
  38. package/lib/tests.js +35 -1
  39. package/lib/tools.js +257 -47
  40. package/lib/tower.js +281 -55
  41. package/lib/workspaceLock.js +44 -20
  42. package/package.json +3 -2
  43. package/scripts/check-docs.mjs +35 -0
  44. package/skills/tower-recovery.md +7 -5
  45. package/ui-bundle/README.md +111 -0
  46. package/ui-bundle/client.js +1980 -0
  47. package/ui-bundle/cordis.patch.yml +3 -0
  48. package/ui-bundle/icon.svg +6 -0
  49. package/ui-bundle/index.js +2 -0
  50. package/ui-bundle/locale/en.json +6 -0
  51. package/ui-bundle/locale/zh.json +6 -0
  52. package/ui-bundle/package.json +35 -0
@@ -22,7 +22,7 @@ merge、worktree 与 spawn 策略。它使用与 profile 配置相同的 JSON
22
22
 
23
23
  ```json
24
24
  {
25
- "review": { "maxRoundsPerReviewer": 3 },
25
+ "review": { "requireReview": true, "maxRoundsPerReviewer": 3 },
26
26
  "merge": { "timeoutMs": 1800000 },
27
27
  "worktrees": { "root": "D:/tower-worktrees" },
28
28
  "spawn": { "reviewerPreset": "tower-reviewer", "maxDepth": 2 }
@@ -37,12 +37,12 @@ merge、worktree 与 spawn 策略。它使用与 profile 配置相同的 JSON
37
37
  | 键 | 默认值 | 含义 |
38
38
  |---|---|---|
39
39
  | `section` | `''` | 兼容别名:替换整段 Tower 模式提示文案。空值保留内置提醒。 |
40
- | `allowAgentControl` | `false`(随包 patch:`true`) | 兼容别名:注册 `tower_control`。 |
40
+ | `allowAgentControl` | `true`(`mode.allowAgentControl` 的别名) | 兼容别名:注册 `tower_control`,允许模型切换模式。 |
41
41
  | `init.autoGitInit` | `false`(随包 patch:`true`) | 当 `repoRoot` 是绝对、已存在但不是仓库的目录时,`tower_init` 可以执行 `git init` 并创建一个空的初始提交。 |
42
42
  | `init.snapshotDirty` | `false` | `tower_init` 可以把未提交的**已跟踪**改动快照为游离提交作为 mission 基线(不含未跟踪文件;该提交通过 `refs/tower/snapshot/*` 保持存活)。 |
43
43
  | `mode.promptSection` | `''` | 替换用的提示文案(与 `section` 同时设置时优先)。 |
44
44
  | `mode.sectionAppend` | `''` | 追加在提醒之后的额外文本(团队约定)。 |
45
- | `mode.allowAgentControl` | `false` | 注册 `tower_control`;模型可带理由进入/退出。 |
45
+ | `mode.allowAgentControl` | `true` | 注册 `tower_control`;模型可带理由进入/退出。设为 `false` 则仅人类可控制模式。 |
46
46
  | `mode.autoPauseGoal` | `true` | 进入模式时暂停活跃 goal;退出时不会自动恢复。 |
47
47
  | `mode.requireResumeAuthorization` | `false` | 恢复因 `pauseCause:user` 暂停的 mission 时,必须提供 `authorization` 引用。 |
48
48
  | `mode.workspaceLock` | `false`(随包 patch:`true`) | 每个仓库只允许一个 Tower 会话(`tower.lock`);其他会话得到 `workspace-busy`。 |
@@ -53,9 +53,11 @@ merge、worktree 与 spawn 策略。它使用与 profile 配置相同的 JSON
53
53
  | `mode.denyTools` | goal/todo/team/workflow/subagent 工具(默认 12 个) | 模式激活期间被拒绝的工具。 |
54
54
  | `mode.storePath` | `$DSH_HOME/tower-mode` | `modes.json` 所在目录。 |
55
55
  | `mode.maxEntries` | `200` | 最多持久化的模式条目数(1–2000)。 |
56
+ | `review.requireReview` | `false` | 合并前是否强制独立 clean 审查。默认 `false`(审查可选);设为 `true` 则要求 `no-review`/`not-clean`/`review-hold`/`review-not-approved`/`tip-moved`/`review-mission-mismatch` 全部通过。`tower_review` 始终可用。 |
56
57
  | `review.maxRoundsPerReviewer` | `5` | 每个 reviewer 对每条分支的审查轮数上限。 |
57
58
  | `merge.timeoutMs` | `900000`(15 分钟) | `tower_merge` 默认超时,包含钩子;单次调用的 `timeoutMs` 优先。 |
58
59
  | `merge.maxBufferBytes` | `67108864`(64 MB) | 合并输出缓冲区。 |
60
+ | `git.timeoutMs` | `15000` | 除 merge 外 Git 命令的默认超时;merge 使用 `merge.timeoutMs`。 |
59
61
  | `worktrees.root` | `''` | 默认 worktree 父目录;空值推导为 `<主检出父目录>/.dsh-tower-worktrees/<hash>`。 |
60
62
  | `spawn.workerPreset` | `tower-worker` | `role=worker` 使用的 preset。 |
61
63
  | `spawn.reviewerPreset` | `tower-reviewer` | `role=reviewer` 使用的 preset。 |
@@ -70,14 +72,14 @@ merge、worktree 与 spawn 策略。它使用与 profile 配置相同的 JSON
70
72
  | `send.maxPerMinute` | `120` | 每个仓库的 `tower_send` 每分钟速率限制(0 关闭);超出返回 `rate-limited`。 |
71
73
  | `send.interruptDefault` | `false`(随包 patch:`true`) | `tower_send.interrupt` 的默认值(投递前先中断运行中的回合)。 |
72
74
  | `tests.enabled` | `true` | worktree 的 `package.json` 配置了测试发现时运行它。 |
73
- | `tests.admission` | `complete` | `spawn` 在启动 worker 前运行发现(fail-closed,不启动子代理);`complete` 在 `tower_complete` 时运行;`off` 只在显式 `tower_test` 时运行。 |
75
+ | `tests.admission` | `off` | `spawn` 在启动 worker 前运行发现(fail-closed,不启动子代理);`complete` 在 `tower_complete` 时运行;`off` 只在显式 `tower_test` 时运行(默认;不会自动执行仓库代码)。 |
74
76
  | `tests.timeoutMs` | `60000` | 每条发现命令的超时。 |
75
77
  | `tests.maxBytes` | `16777216`(16 MiB) | 每条命令捕获的 stdout 上限。 |
76
78
  | `tests.autoChecks` | `true` | 向完成报告附加自动 `tests` 检查。 |
77
79
  | `tests.allowPackageDiscovery` | `true` | 允许仓库控制的 `tower.testDiscovery` 命令(会执行 worktree 代码;见安全页)。 |
78
80
  | `gates.requireChecks` | `false` | 合并前要求至少一条 `tower_complete.checks` 证据(`no-check-evidence`)。 |
79
- | `gates.requireTests` | `false` | 要求针对被审查 tip 的通过测试回执(`tests-missing`)。 |
80
- | `gates.allowReviewExemption` | `false` | 允许用 `reviewPolicy:"none"` + 理由跳过审查 blocker;默认故意关闭。 |
81
+ | `gates.requireTests` | `false` | 要求针对当前 mission 分支 tip 的通过测试回执(`tests-missing`);与审查门禁独立。 |
82
+ | `gates.allowReviewExemption` | `false` | 在 `review.requireReview:true` 时,允许用 `reviewPolicy:"none"` + 非空理由跳过审查 blocker;默认故意关闭;全局审查关闭时无影响。 |
81
83
  | `docs.board` | `true` | 变更后写入 `MISSIONS.md`。 |
82
84
  | `docs.boardLocation` | `common` | `common` = `<git-common-dir>/dsh-tower/MISSIONS.md`;`repo` = `<repoRoot>/MISSIONS.md`。 |
83
85
  | `leases.ttlMs` | `900000` | 默认咨询式租约寿命;过期不会自动释放。 |
@@ -3,7 +3,7 @@
3
3
  [English](../../reference/errors.md) | **简体中文**
4
4
 
5
5
  工具失败是抛出的错误,不是 `{ok:false}` 结果(门禁结果是例外:见 [blockers.md](blockers.md))。
6
- 每个抛出的错误都以稳定 code 作为消息前缀:
6
+ 每个抛出的错误都以稳定 code 作为消息前缀。本版本定义 31 个工具错误码。
7
7
 
8
8
  ```
9
9
  [tower:mission-not-found] unknown mission "M9"
@@ -20,6 +20,7 @@
20
20
  | `mission-not-found` | 不存在该 id 的 mission。 | 过期或拼错的 `M9` |
21
21
  | `finding-not-found` | 不存在该 id 的 finding。 | `tower_findings({id:"F99"})` |
22
22
  | `invalid-transition` | 请求的状态转换不被允许。 | 尝试 blocked→merged;finding 状态图之外的流转;reviewer 修改 finding;在还有未完成任务、blocker、无提交或未读消息时完成 mission。 |
23
+ | `worktree-dirty` | mission worktree 有未提交改动,不能完成。 | `tower_complete` 前遗留 staged/modified/untracked 文件 |
23
24
  | `state-locked` | 另一个 Tower 写入者持有 state 锁。 | 锁超时后的并发写 |
24
25
  | `state-corrupt` | Tower state 文件不可读或无效。 | 手工编辑过的 `state.json` |
25
26
  | `config-invalid` | Tower 配置无效。 | 格式错误的仓库 `config.json`、违反 schema 的值 |
@@ -3,7 +3,7 @@
3
3
  [English](../../reference/events.md) | **简体中文**
4
4
 
5
5
  Tower 在工具成功调用(变更与只读)后发出进程内 Cordis 事件,其他插件因此可以观察仓库,而不必轮询。
6
- 事件**不会**通过网络发送,也不包含遥测。
6
+ 本版本发出 29 个进程内事件。事件**不会**通过网络发送,也不包含遥测。
7
7
 
8
8
  ```js
9
9
  ctx.on('tower/merge', (payload) => {
@@ -33,14 +33,17 @@ ctx.on('tower/merge', (payload) => {
33
33
  | `tower/status` | `tower_status` 读取面板之后 |
34
34
  | `tower/preflight` | `tower_preflight` 评估门禁之后 |
35
35
  | `tower/doctor` | `tower_doctor` 完成只读检查之后 |
36
+ | `tower/recover` | `tower_recover` 查看或清理 stale state 锁之后 |
36
37
  | `tower/test` | `tower_test` 运行测试发现之后 |
37
38
  | `tower/batch` | `tower_batch` 创建、查看或关闭 batch 之后 |
38
39
  | `tower/resource` | `tower_resource` 获取/释放/查看租约之后 |
39
40
  | `tower/import_legacy` | `tower_import_legacy` 保存旧版快照之后 |
40
41
  | `tower/legacy_map` | `tower_legacy_map` 映射或列出旧 id 之后 |
42
+ | `tower/mode` | `tower_mode` 读取/变更会话模式之后(`value.mode`、`allowAgentControl`、`deniedWhileActive`) |
43
+ | `tower/control` | `tower_control` 进入/退出模式之后(`value.mode`,操作者/理由在 `value` 中) |
44
+ | `tower/reload` | `tower_reload` 返回重载调度/结果之后 |
41
45
 
42
- `tower_mode`、`tower_control` 与 `tower_reload` 不发出 Tower 事件。payload 是 JSON:
43
- `{tool, repoRoot, value}`。监听器抛错不会影响工具结果。
46
+ payload 是 JSON:`{tool, repoRoot, value}`。监听器抛错不会影响工具结果。
44
47
 
45
48
  需要可持久审计的调用链时,请使用 `<git-common-dir>/dsh-tower/activity.jsonl` 与 `MISSIONS.md`;
46
49
  事件是进程内的,不会重放。
@@ -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` 要求针对被审查 tip 的通过回执
53
- (`tests-missing`)。
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
- 本构建最多 28 个工具:下列 19 个核心仓库工具(含 `tower_interrupt`)、`tower_mode`、
7
- `mode.allowAgentControl` 开启时注册的 `tower_control`、`dev.hotReload` 开启时注册的
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"` 需要理由,并且要求 `gates.allowReviewExemption`。 |
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`,等待审查)。要求当前 owner、无未完成任务/blocker、≥1 个提交、无未读消息。`checks` 记录 `{name, command?, exitCode?, evidence?}` 证据;`gates.requireChecks` 可以强制要求。配置了测试发现时还会自动运行并添加 `tests` 检查;失败会抛出 `[tower:tests-failed]`。 |
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` | 模型主动切换模式;仅当 `mode.allowAgentControl` 为 `true` 时可用;`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. The human owns it through the
4
- // `/tower` command: bare `/tower`, `/tower on [message]`, `/tower off
5
- // [message]`, and `/tower <message>` all work, and any trailing text is
6
- // forwarded to the model as the next user message. With
7
- // config.allowAgentControl the model may also enter or leave the mode through
8
- // `tower_control` when the user intent or task state clearly calls for it.
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
- 'Only the human can normally change this mode with /tower on|off.',
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
- 'If tower_control is available you may also enter or leave Tower mode yourself when the user intent or task state clearly calls for it, and you must state a concrete reason.'
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
- MODE.delete(key)
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: true, pausedGoal: previous.pausedGoal ?? null, goalNote: null, by, reason: reason ?? null }
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 || !MODE.has(key)) return undefined
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 && MODE.has(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 && MODE.has(agentKey(context.agent)) ? reminder : ''),
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: MODE.has(key) ? 'on' : 'off',
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({
@@ -88,6 +131,9 @@ export const Config = Schema.object({
88
131
  .default({})
89
132
  .description('Tower-mode behavior and persistence.'),
90
133
  review: Schema.object({
134
+ requireReview: Schema.boolean()
135
+ .default(false)
136
+ .description('Require an independent clean review before merge. Default false: review is optional; set true to enforce the review gate globally.'),
91
137
  maxRoundsPerReviewer: Schema.natural().min(1).default(5).description('Review-round cap per reviewer per mission branch.'),
92
138
  })
93
139
  .default({})
@@ -98,6 +144,11 @@ export const Config = Schema.object({
98
144
  })
99
145
  .default({})
100
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.'),
101
152
  worktrees: Schema.object({
102
153
  root: Schema.string().description('Default parent directory for mission worktrees; empty derives <main-checkout-parent>/.dsh-tower-worktrees/<hash>.'),
103
154
  })
@@ -142,8 +193,8 @@ export const Config = Schema.object({
142
193
  tests: Schema.object({
143
194
  enabled: Schema.boolean().default(true).description('Run declared test discovery when a mission configures it.'),
144
195
  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.'),
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).'),
147
198
  timeoutMs: Schema.natural().min(1000).max(1800000).default(60000).description('Per discovery command timeout.'),
148
199
  maxBytes: Schema.natural().min(1024).max(16 * 1024 * 1024).default(16 * 1024 * 1024).description('Maximum captured stdout bytes per command.'),
149
200
  autoChecks: Schema.boolean().default(true).description('Attach a tests check to the completion report when discovery runs.'),
@@ -233,14 +284,39 @@ function normalizeLegacy(raw = {}) {
233
284
  return out
234
285
  }
235
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
+
236
294
  function assertKnownKeys(raw, label) {
237
295
  const unknown = Object.keys(raw).filter((key) => !LEGACY_KEYS.includes(key) && !GROUP_KEYS.includes(key))
238
296
  if (unknown.length > 0) {
239
297
  throw new TowerToolError(
240
298
  '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? }`,
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? }`,
242
300
  )
243
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
+ }
244
320
  }
245
321
 
246
322
  const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
@@ -281,7 +357,7 @@ export function resolveTowerConfig(rawConfig) {
281
357
  // Apply the canonical default only after alias resolution: a validated
282
358
  // Cordis config may omit this key, and an explicit legacy top-level
283
359
  // `allowAgentControl:true` must not be shadowed by a schema default.
284
- if (mode.allowAgentControl === undefined) mode.allowAgentControl = false
360
+ if (mode.allowAgentControl === undefined) mode.allowAgentControl = true
285
361
  if (!Array.isArray(mode.denyTools) || mode.denyTools.length === 0) mode.denyTools = [...DEFAULT_DENY_TOOLS]
286
362
  return {
287
363
  section: mode.promptSection ?? '',
@@ -289,6 +365,7 @@ export function resolveTowerConfig(rawConfig) {
289
365
  mode,
290
366
  review: clone(parsed.review ?? {}),
291
367
  merge: clone(parsed.merge ?? {}),
368
+ git: clone(parsed.git ?? {}),
292
369
  worktrees: clone(parsed.worktrees ?? {}),
293
370
  spawn: clone(parsed.spawn ?? {}),
294
371
  send: clone(parsed.send ?? {}),
@@ -370,7 +447,27 @@ export async function readRepoConfig(commonDir) {
370
447
  export async function resolveRepoConfig(repo) {
371
448
  const base = runtimeConfig()
372
449
  const layer = await readRepoConfig(repo.commonDir)
373
- const merged = deepMerge(base, layer.config)
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
+ }
374
471
  if (!Array.isArray(merged.mode?.denyTools) || merged.mode.denyTools.length === 0) {
375
472
  merged.mode = { ...(merged.mode ?? {}), denyTools: [...DEFAULT_DENY_TOOLS] }
376
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 stateLock = path.join(tower.paths.dir, 'state.lock')
82
+ const lock = await readStateLock(tower.paths)
82
83
  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'),
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}`, 'run tower_preflight or tower_recover')
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), { cause: 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)) return new TowerToolError(code, message, { cause: error })
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 ?? GIT_TIMEOUT_MS,
60
+ timeout: options.timeoutMs ?? configuredTimeoutMs,
27
61
  maxBuffer: options.maxBuffer ?? MAX_BUFFER,
28
62
  windowsHide: true,
29
- env: process.env,
63
+ env: gitEnv(),
30
64
  },
31
65
  (error, stdout, stderr) => {
32
66
  if (error) {