dsh-tower 2.0.4 → 2.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +40 -3
  5. package/README.md +38 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +10 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/batch.js +79 -0
  11. package/app/board.js +25 -0
  12. package/app/checkCompletion.js +76 -0
  13. package/app/enrichedMissions.js +26 -0
  14. package/app/findings.js +78 -0
  15. package/app/gate.js +108 -0
  16. package/app/init.js +249 -0
  17. package/app/merge.js +567 -0
  18. package/app/messages.js +66 -0
  19. package/app/mission.js +35 -0
  20. package/app/missionContext.js +14 -0
  21. package/app/plan.js +42 -0
  22. package/app/provision.js +193 -0
  23. package/app/read.js +57 -0
  24. package/app/rebase.js +302 -0
  25. package/app/recording.js +72 -0
  26. package/app/review.js +145 -0
  27. package/app/roster.js +123 -0
  28. package/app/spawn.js +125 -0
  29. package/app/teardown.js +184 -0
  30. package/app/tower.js +524 -0
  31. package/cordis.patch.yml +61 -53
  32. package/docs/README.md +3 -1
  33. package/docs/architecture/overview.md +111 -12
  34. package/docs/board.md +72 -0
  35. package/docs/guide/hot-reload.md +1 -1
  36. package/docs/guide/install-upgrade-migration.md +8 -1
  37. package/docs/guide/release.md +125 -0
  38. package/docs/operations/security.md +47 -2
  39. package/docs/reference/config.generated.md +66 -0
  40. package/docs/reference/config.md +4 -1
  41. package/docs/reference/errors.generated.md +39 -0
  42. package/docs/reference/errors.md +4 -2
  43. package/docs/reference/tools.generated.md +33 -0
  44. package/docs/reference/tools.md +1 -1
  45. package/docs/zh/README.md +3 -2
  46. package/docs/zh/guide/hot-reload.md +1 -1
  47. package/docs/zh/guide/install-upgrade-migration.md +6 -1
  48. package/docs/zh/operations/security.md +30 -1
  49. package/docs/zh/reference/config.md +4 -1
  50. package/docs/zh/reference/errors.md +3 -1
  51. package/docs/zh/reference/tools.md +1 -1
  52. package/domain/collab/batch.js +112 -0
  53. package/domain/collab/messages.js +127 -0
  54. package/domain/collab/roster.js +158 -0
  55. package/domain/collab/spawn.js +188 -0
  56. package/domain/config/schema.js +404 -0
  57. package/domain/contracts/outputs.js +279 -0
  58. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  59. package/domain/contracts/tool-inputs/findings.js +29 -0
  60. package/domain/contracts/tool-inputs/index.js +94 -0
  61. package/domain/contracts/tool-inputs/integration.js +37 -0
  62. package/domain/contracts/tool-inputs/missions.js +94 -0
  63. package/domain/contracts/tool-inputs/ops.js +75 -0
  64. package/domain/contracts/tool-inputs/shared.js +25 -0
  65. package/domain/errors.js +96 -0
  66. package/domain/gate.js +125 -0
  67. package/domain/integration/gate.js +176 -0
  68. package/domain/integration/init.js +225 -0
  69. package/domain/integration/liveness.js +31 -0
  70. package/domain/integration/merge.js +377 -0
  71. package/domain/integration/provision.js +124 -0
  72. package/domain/integration/rebase.js +236 -0
  73. package/domain/integration/review.js +52 -0
  74. package/domain/integration/teardown.js +118 -0
  75. package/domain/mission.js +68 -0
  76. package/domain/missions/completion.js +114 -0
  77. package/domain/missions/findings.js +201 -0
  78. package/domain/missions/lifecycle.js +185 -0
  79. package/domain/missions/plan.js +149 -0
  80. package/domain/missions/review.js +179 -0
  81. package/domain/planning.js +118 -0
  82. package/domain/read/enrichedMissions.js +58 -0
  83. package/domain/read/missionContext.js +53 -0
  84. package/domain/read/views.js +128 -0
  85. package/domain/report.js +59 -0
  86. package/domain/roster.js +40 -0
  87. package/domain/scope.js +105 -0
  88. package/domain/state/schema.js +233 -0
  89. package/domain/state/semantics.js +117 -0
  90. package/domain/state.js +33 -0
  91. package/domain/summary.js +114 -0
  92. package/domain/vocabulary.js +179 -0
  93. package/index.js +63 -20
  94. package/lib/briefing.js +176 -0
  95. package/lib/compatibility.js +108 -0
  96. package/lib/config.js +33 -375
  97. package/lib/doctor.js +97 -4
  98. package/lib/git.js +60 -16
  99. package/lib/hmr.js +4 -3
  100. package/lib/leases.js +3 -2
  101. package/lib/legacy.js +20 -9
  102. package/lib/modeState.js +39 -0
  103. package/lib/modeStore.js +11 -43
  104. package/lib/persistence.js +349 -0
  105. package/lib/presets.js +35 -0
  106. package/lib/spawn-prechecks.js +168 -0
  107. package/lib/spawn-support.js +79 -0
  108. package/lib/store.js +54 -137
  109. package/lib/tests.js +11 -10
  110. package/lib/tool-registry.js +544 -0
  111. package/lib/workspaceLock.js +2 -2
  112. package/lib/worktreeOwnership.js +5 -4
  113. package/locale/en.json +23 -1
  114. package/locale/zh.json +22 -0
  115. package/package.json +32 -4
  116. package/ports/clock.js +6 -0
  117. package/ports/ids.js +7 -0
  118. package/ports/index.js +2 -0
  119. package/ports/store.js +11 -0
  120. package/ports/vcs.js +11 -0
  121. package/scripts/architecture.mjs +94 -0
  122. package/scripts/check-docs.mjs +9 -3
  123. package/scripts/check-pack.mjs +49 -0
  124. package/scripts/domain-metrics.mjs +48 -0
  125. package/scripts/generate-config-reference.mjs +46 -0
  126. package/scripts/generate-error-reference.mjs +29 -0
  127. package/scripts/generate-tools-reference.mjs +56 -0
  128. package/scripts/issue-audit.mjs +259 -0
  129. package/scripts/lib/cli.mjs +76 -0
  130. package/scripts/lib/tmp.mjs +20 -0
  131. package/scripts/mount-smoke/fixtures.mjs +162 -0
  132. package/scripts/mount-smoke/host.json +4 -0
  133. package/scripts/mount-smoke/probe.mjs +125 -0
  134. package/scripts/mount-smoke/surface.mjs +141 -0
  135. package/scripts/mount-smoke.mjs +270 -0
  136. package/scripts/pack-manifest.txt +203 -0
  137. package/scripts/release-check.mjs +303 -0
  138. package/tools/adopt.js +72 -0
  139. package/tools/batch.js +78 -0
  140. package/tools/complete.js +53 -0
  141. package/tools/diff.js +39 -0
  142. package/tools/doctor.js +50 -0
  143. package/tools/finding.js +40 -0
  144. package/tools/findings.js +39 -0
  145. package/tools/import-legacy.js +42 -0
  146. package/tools/inbox.js +39 -0
  147. package/tools/index.js +39 -0
  148. package/tools/init.js +63 -0
  149. package/tools/interrupt.js +42 -0
  150. package/tools/legacy-map.js +44 -0
  151. package/tools/merge.js +39 -0
  152. package/tools/mission.js +41 -0
  153. package/tools/plan.js +39 -0
  154. package/tools/preflight.js +44 -0
  155. package/tools/rebase.js +39 -0
  156. package/tools/recover.js +45 -0
  157. package/tools/reload.js +52 -0
  158. package/tools/resource.js +54 -0
  159. package/tools/review.js +45 -0
  160. package/tools/roster.js +60 -0
  161. package/tools/send.js +147 -0
  162. package/tools/spawn.js +196 -0
  163. package/tools/status.js +94 -0
  164. package/tools/teardown.js +42 -0
  165. package/tools/test.js +61 -0
  166. package/types/cordis.d.ts +6 -0
  167. package/types/index.d.ts +339 -0
  168. package/lib/errors.js +0 -123
  169. package/lib/tools.js +0 -2460
  170. package/lib/tower.js +0 -2912
  171. /package/{lib → domain}/batch.js +0 -0
@@ -0,0 +1,33 @@
1
+ # Tools overview (generated)
2
+
3
+ > Generated by `npm run docs:generate:tools` from the registered tool definitions. Do not edit by hand.
4
+
5
+ | Tool | Description |
6
+ |---|---|
7
+ | `tower_adopt` | Reconcile the roster with the durable subagent catalog after a restart or new session. Agents that are provably gone are marked retired (records are kept) or missing (retireMissing:false, so the tower can inspect/take over); agents whose parent catalog cannot be read stay unknown and are never changed. Only the tower may adopt. |
8
+ | `tower_batch` | Manage finite evidence batches: create freezes missionIds/requiredChecks/receiptPaths, status summarizes members, close verifies every required check and receipt hash, abandon retires a stale open batch. Membership and exclusions are immutable after create; close/abandon never remove missions. |
9
+ | `tower_complete` | Worker-only: report mission completion. Enforced by the store: every task done or dropped, no blockers, the current owner, the mission branch has at least one commit since its spawn base, and no unread inbox messages. checks records verifiable evidence (name/command/exitCode/evidence); gates.requireChecks can require at least one before merge. Stores the full report; completion means ready for preflight and optional review, not merged. |
10
+ | `tower_diff` | Read-only view of a mission branch for review: merge base, changed files, commits, and a capped patch against the recorded base. Safe for reviewers; it never modifies the repository. |
11
+ | `tower_doctor` | Read-only environment and repository health check: git, state schema, config, presets/subagents/HMR, workspace lock, roster staleness, worktree ownership, test discovery, leases. Returns checks with pass/warn/fail and an environment contract. Never writes state. |
12
+ | `tower_finding` | File a structured finding (bug / improve / vuln / idea) for the tower to route. Use this for anything notable OUTSIDE your mission scope instead of editing out-of-scope files. Severity is one of low|medium|high|critical. Use tower_findings to list, read, or close findings later. |
13
+ | `tower_findings` | List, read, or update findings for a repository. Without id it lists findings newest-first with counts by status (filters: status, type, severity, missionId, open, limit). With id it returns that finding; with id plus status and/or note it records a lifecycle transition. Statuses are open -> triaged -> fixed|wontfix, and fixed/wontfix can be reopened to open. Only the tower or a worker may change a finding; reviewers may only list and read. Read-only when no id is given. |
14
+ | `tower_import_legacy` | Read-only import of a Tower Codex (schemaVersion 1) or DSH legacy state file. Stores the full payload under <common>/dsh-tower/legacy/<sha256>.json and records only a summary in state.legacySnapshots; never activates, adopts, or resumes paused missions. |
15
+ | `tower_inbox` | Read your tower inbox: messages addressed to you plus broadcasts, newest first. Only returned messages are acknowledged; the result reports unreadRemaining, so keep reading until it reaches 0. Set include_read for history. Unread messages block tower_complete. |
16
+ | `tower_init` | Initialize Tower mission state for a repository (absolute repoRoot). Creates <git-common-dir>/dsh-tower/state.json and an activity log; idempotent on an existing workspace. Options: autoGitInit (git init + empty initial commit when the directory is not a repository), snapshotDirty (snapshot uncommitted work into a dangling commit used as the mission baseline), reanchor (change base when every mission is merged/abandoned). Only the tower may initialize. |
17
+ | `tower_interrupt` | Interrupt one running tower roster agent (worker or reviewer) and record it as interrupted in the roster. Only the tower may interrupt. Idempotent: an agent that is not running returns interrupted:false. Use it before takeover after a runaway worker, or to stop work that is no longer wanted. |
18
+ | `tower_legacy_map` | Audit-only mapping between a legacy snapshot id and a current mission. map records sourceHash/oldId/newMissionId; status lists mappings. Mapping never adopts or resumes a legacy mission. |
19
+ | `tower_merge` | Merge a mission branch into the recorded base branch with --no-ff, after the hard gate passes. Verifies the two merge parents and records the mission as merged. If the merge commit already landed (manual merge, crashed or slow pre-merge hook), it detects the merge commit and records it as reconciled instead of merging again. A failed gate returns stable blocker codes without merging; a hook failure aborts cleanly and returns "merge-hook-failed" while a real content conflict returns "merge-conflict". |
20
+ | `tower_mission` | Update one mission: lifecycle status, decision note, blocker, clear blockers, mark a task done, drop a task with a reason, or widen the scope (tower only). "merged" is never set here; only a future tower_merge records it. Merged and abandoned are terminal. |
21
+ | `tower_plan` | Plan one or more missions in an initialized repository. Each mission has a printable-ASCII title, relative scope globs, optional task checklist, optional mission-id dependencies, and kind build|survey. Assigns M1.. ids and auto-adds a dependency when build scopes overlap (serializes merges); rejects dependency cycles. |
22
+ | `tower_preflight` | Read-only merge gate for one mission: returns ok plus stable blocker codes (branch-missing, deps-unmerged, read-only-survey, stale-base, out-of-scope, base-dirty, branch-owned-by-closed-missions, and mission/worker guards). Review blockers (no-review, review-hold, not-clean, review-not-approved, tip-moved, review-mission-mismatch) apply only when review.requireReview is on. Never changes anything. |
23
+ | `tower_rebase` | Rebase a mission branch onto the current base tip inside its own worktree. Refuses while its worker is running or the worktree is dirty. A clean conflict-free rebase waives re-review for the old review; a conflict is aborted and the mission is marked blocked. If the branch is already contained in the base, it returns alreadyMerged and leaves the branch untouched so tower_merge can record the landed merge. |
24
+ | `tower_recover` | Read-only recovery diagnostics and an explicit stale-state-lock repair. status reports the state lock owner; clear-stale-lock requires confirm:"CLEAR_STALE_LOCK" and only moves a lock whose PID is provably dead to a backup file. It never resets Tower state or worktrees. |
25
+ | `tower_reload` | Dev-only: ask DSH HMR to reconcile this profile and reload the Tower Host plugin from disk. The reload is scheduled a few milliseconds after this call returns (so the result reaches the model first). Use after editing the plugin source in a linked profile; plugin_manager install/update already triggers reconciliation by itself. Registered while dev.hotReload is on; fails with reload-unavailable when the HMR service is absent. |
26
+ | `tower_resource` | Advisory resource leases per repository (coordination only; no OS CPU/memory hard cap). acquire/release require a token and are tower-only; status is read-only. Expired leases are never taken over silently. |
27
+ | `tower_review` | Submit an independent review verdict for a completed mission, bound to the current branch tip. status is "clean", "p1-Nitems", or "p2-Nitems"; merge is "merge", "fix-then-merge", or "hold" (only clean + merge is later approval). Non-clean returns the mission to active. Reviewers are capped at 5 rounds per branch; a tower-submitted review is break-glass and requires breakGlassReason. |
28
+ | `tower_roster` | List tower roster agents with authoritative live activity from the subagent catalog (running/inactive/missing/unknown). Read-only; never resumes children. Use before completion or merge decisions. |
29
+ | `tower_send` | Send a durable inbox message to a tower participant: a roster agent name, "tower", or "all" (broadcast). The message is persisted first; when the recipient is a currently running roster agent the tower also steers it into that child's current turn. Returns delivery results. |
30
+ | `tower_spawn` | Spawn a worker or reviewer for a mission. Workers use the tower-worker preset; reviewers require a completed mission and use the read-only tower-reviewer preset. Provisions (or reuses) the mission git worktree and branch, generates a self-contained briefing, and starts a background subagent in that worktree. The child final report is delivered back to this session; tower_roster shows authoritative activity. Records the agent in the roster; reviewer spawns record the branch tip so a reviewer that modifies the branch cannot submit a review. Returns immediately unless wait=true. |
31
+ | `tower_status` | Read the Tower dashboard for a repository: state path, base branch, revision, mission status counts, paged mission summaries (scope/deps/tasks/worktree/owner), roster size, findings, config path and the activity tail. Pass missionId for one full mission (tasks, notes, reviews, findings, completion report); offset/limit page the mission list (default 20, max 100); includeGate adds preflight gate digests for the page; format:"markdown" returns the human-readable mission board. Read-only. |
32
+ | `tower_teardown` | Tear down eligible mission worktrees after their missions are merged or abandoned. A worktree whose roster agent is running is always kept (force cannot override); dirty worktrees need force; exclude keeps named missions. dry_run previews the remove/keep decisions without changing anything. |
33
+ | `tower_test` | Run the mission worktree's declared tower.testDiscovery (package.json) and record the receipt. Returns ok:false with diagnostics when discovery fails; does not change mission status. Use tower_complete for automatic admission checks. |
@@ -33,7 +33,7 @@ findings but not mutate them. Violations throw `[tower:not-authorized]` or
33
33
  | `tower_init` | `base?`, `autoGitInit?`, `snapshotDirty?`, `reanchor?` | Create `<git-common-dir>/dsh-tower/state.json`. `autoGitInit` runs `git init` + empty initial commit; `snapshotDirty` records a dangling snapshot commit of uncommitted tracked changes used as the mission baseline; `reanchor` changes base only when no non-terminal mission exists. Returns `dirty`, `snapshotCommit`, `reanchored`. |
34
34
  | `tower_plan` | `missions[]` (`title`, `scope[]`, `tasks[]?`, `deps[]?`, `kind?`, `context?`, `references[]?` ≤20, `reviewPolicy?`, `reviewExemptionReason?`) | Create missions `M1..`; overlapping build scopes auto-add a dependency; rejects unknown deps and cycles. `reviewPolicy:"none"` requires a non-empty reason; when the review gate is on, preflight also requires `gates.allowReviewExemption`. |
35
35
  | `tower_mission` | `id` (alias `missionId`), `status?`, `task_done?`, `task_drop?`+`task_drop_reason?`, `blocker?`, `clear_blockers?`, `note?`, `scope?`, `pause_cause?`, `authorization?` | Update one mission. Worker-safe fields are task/blocker/note; `merged` cannot be set here. Paused missions record `pause_cause` (user/failure/resource/dependency); resuming a user-paused mission honors `mode.requireResumeAuthorization`. |
36
- | `tower_status` | `missionId?`, `offset?`, `limit?`, `includeGate?`, `format?` | Dashboard by default. `missionId` returns full detail (tasks/notes/reviews/findings/completionReport). `offset`/`limit` page missions (default 20, max 100), `includeGate` adds gate digests, `format:"markdown"` returns the board. |
36
+ | `tower_status` | `missionId?`, `offset?`, `limit?`, `includeGate?`, `format?` | Dashboard by default. `missionId` returns full detail (tasks/notes/reviews/findings/completionReport). `offset`/`limit` page missions (default 20, max 100), `includeGate` adds gate digests, `format:"markdown"` returns the board. Includes `mode` with `lastChange` and recent `history` when the host mode hook is available. |
37
37
  | `tower_spawn` | `missionId` (alias `id`), `role` (`worker`/`reviewer`), `name?`, `instructions?`, `worktreeRoot?`, `wait?`, `waitMs?`, `takeover?`, `includeDirtyBase?` | Provision/reuse the worktree and start a subagent. Worker uses `tower-worker`; reviewer requires a `completed` mission and uses the read-only `tower-reviewer`. `takeover:true` replaces a live worker entry that is no longer running (increments ownerGeneration). Only one live worker per mission; `spawn.maxLiveWorkers` caps the repository. |
38
38
  | `tower_roster` | — | Roster with live activity (`running`/`inactive`/`missing`/`unknown`) and `stale` flags from the subagent catalog. Read-only. |
39
39
  | `tower_adopt` | `retireMissing?` | Reconcile roster entries with the durable subagent catalog. Provably gone agents are retired (default) or marked `missing` (`retireMissing:false`) so they can be inspected/taken over. |
package/docs/zh/README.md CHANGED
@@ -29,7 +29,7 @@
29
29
  | 新用户 | [什么时候用 Tower](guide/scenarios-and-philosophy.md) → [快速开始](guide/quickstart.md) → [工具参考](reference/tools.md) → [第一个 mission 示例(英文)](../examples/first-mission.md) |
30
30
  | 正在选择工作流 | [Tower 与 Agent Teams、Ralph、Workflow、普通并行子代理的区别](guide/scenarios-and-philosophy.md) |
31
31
  | 运维 | [运维 runbook(英文)](../operations/runbook.md) → [blocker 与失败码](reference/blockers.md) → [错误码](reference/errors.md) → [事件](reference/events.md) → [Doctor](reference/doctor.md) → [测试](reference/testing.md) → [批次/租约/旧版](reference/operations.md) → [安全模型](operations/security.md) |
32
- | 维护者 | [架构与状态机(英文)](../architecture/overview.md) → [配置参考](reference/config.md) → [热重载](guide/hot-reload.md) → [贡献指南](../../CONTRIBUTING.md) |
32
+ | 维护者 | [架构与状态机(英文)](../architecture/overview.md) → [配置参考](reference/config.md) → [热重载](guide/hot-reload.md) → [发布检查清单(英文)](../guide/release.md) → [看板查询(英文)](../board.md) → [贡献指南](../../CONTRIBUTING.md) |
33
33
 
34
34
  参考页是当前发行版的权威说明;CI 中的 `npm run docs:check` 会校验文档与实现一致(包括中文页)。
35
35
 
@@ -51,7 +51,8 @@
51
51
  - [安全模型](operations/security.md)
52
52
 
53
53
  以下页面目前只有英文版:[架构与状态机](../architecture/overview.md)、
54
- [运维 runbook](../operations/runbook.md)、[示例:第一个 mission](../examples/first-mission.md)。
54
+ [运维 runbook](../operations/runbook.md)、[示例:第一个 mission](../examples/first-mission.md)、
55
+ [发布检查清单](../guide/release.md)、[看板查询](../board.md)。
55
56
 
56
57
  ## 语言 / Language
57
58
 
@@ -97,7 +97,7 @@ Windows 上通常是 `C:\Users\<you>\.dsh`):
97
97
  |---|---|
98
98
  | `dev.hotReload` | 注册 `tower_reload`;关闭时 `/tower reload` 也被禁用(默认 `true`)。无 HMR 服务时工具仍注册,以便返回 `reload-unavailable`。 |
99
99
  | `dev.reloadDelayMs` | 计划中的对账运行前的延迟,单位毫秒(默认 `50`),让工具/命令结果先送达模型,再替换插件。 |
100
- | `dev.reloadRequiredIds` | profile 必须保持活跃的 Loader 条目 id(默认 `tower`、`tower-skills`、`preset-tower-worker`、`preset-tower-reviewer`)。即使某个 required 条目早已处于非活跃状态,也会被报告为重载失败。 |
100
+ | `dev.reloadRequiredIds` | profile 必须保持活跃的 Loader 条目 id(默认 `tower-bundle`、`tower`、`tower-skills`、`preset-tower-worker`、`preset-tower-reviewer`)。即使某个 required 条目早已处于非活跃状态,也会被报告为重载失败。 |
101
101
 
102
102
  重载是**调度而非等待**:`/tower reload` 与 `tower_reload` 立即返回 `scheduled`、`delayMs`、
103
103
  `requiredIds`(以及存在待处理错误时的 `lastError`),随后 HMR 在延迟后对账 profile。这是有意为之——
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 环境要求
6
6
 
7
- - DSH `0.2.1-rc.2` 或更新版本
7
+ - DSH `0.2.1-rc.2`;每个版本实际验证过的 host 构建见 [COMPATIBILITY.md](../../../COMPATIBILITY.md)
8
8
  - Node.js 22+
9
9
  - Git(本地 worktree;不需要网络访问)
10
10
 
@@ -31,6 +31,11 @@ Host 插件在 DSH 启动时加载:**安装或更新后需要重启 DSH**。
31
31
 
32
32
  ## 迁移说明
33
33
 
34
+ - **2.0.4 → 2.1.0-beta.1**:不需要迁移 state/config。这是发布到 `next` dist-tag 的**预发布**,
35
+ `latest` 仍指向 2.0.4——请用 `dsh plugin --profile web add dsh-tower@next`(或精确版本号)安装。
36
+ 运行时行为未变;本版本承载的是交付闸门(CI 中的 mount smoke、`npm run release:check`、证据约定
37
+ 与追踪策略),而不是工具变化。本文档先于人工发布步骤合入:只有在[发布清单](release.md)执行
38
+ `npm publish --tag next` 之后,`next` 才会解析到 2.1.0-beta.1;`latest` 在稳定版发布前保持 2.0.4。
34
39
  - **2.0.3 → 2.0.4**:不需要迁移 state/config。本版本是安全加固与完整性修复(授权、merge SHA 绑定、
35
40
  dirty worktree 完成拒绝、stale lock 恢复、限制 test/git 进程环境、`tower_recover`、UI 0.2.2)。
36
41
  升级后请按威胁模型重新检查 `review.requireReview`、`tests.admission` 与 worker 工具权限。
@@ -26,6 +26,35 @@ Tower 提供的是**协作层面的保证**,不是沙箱。需要理解哪些
26
26
  - **建议性 workspace 锁。** `mode.workspaceLock` 防止两个 Tower 会话意外驱动同一个仓库;它是同用户
27
27
  之间的协调锁,不是用户间的认证机制。
28
28
 
29
+ ## 仓库保护
30
+
31
+ 本节描述的是**本仓库**,不是 Tower 所操作的仓库。服务端分支保护不是插件功能——Tower 只写入本地基线分支,无法配置远端——但 `dsh-tower` 为自己的 `main` 开启了保护,因为门禁代码本身也是攻击者最想修改的代码:
32
+
33
+ - `main` 的所有变更都必须经过 pull request,且**对 admin 同样生效**,因此维护者也无法直接 push。
34
+ - 必需检查为 `test (22)`、`test (24)` 与 `mount`。mount 任务会打包压缩包并在一次性 profile 中启动,所以“测试通过”不能把一个安装后不出现的 bundle 合入。
35
+ - 禁止 force-push 与删除分支,已发布 tag 的历史不能被改写。
36
+ - 不要求强制审批:只有一个维护者时,自我审批只是橡皮图章。替代它的是 two-key 路径的四行 PR 正文(failure mode、change、test、blast radius),见 [CONTRIBUTING.md](../../../CONTRIBUTING.md#landing-changes)。
37
+
38
+ 它带来的保证:未经验证的变更无法进入 `main`,必需检查变红或缺失时连所有者也不能绕过,历史只追加。它**不**提供:第二个审阅者、对泄露的维护者 token 的防护,或对 Tower 所操作仓库的任何保护——那些仍是部署关注点。设置、理由与校验命令记录在 [SECURITY.md](../../../SECURITY.md#repository-protection-on-main)。
39
+
40
+ ## 模型主动进入模式的威胁模型
41
+
42
+ `mode.allowAgentControl:true` 意味着仓库文件、issue 文本或抓取页面中的提示注入可以让模型“进入
43
+ Tower 模式并修复它”。Tower **不认证**这句话背后的人类意图。它提供的是:
44
+
45
+ - 进入会记录操作者、动作、理由与时间;`tower_status.mode.history` 直接展示最近的模式切换,
46
+ 不必手工 grep 活动日志;
47
+ - 模型不能绕过 preflight 门禁、review policy、scope、测试与 clean-base 检查;自己写 plan 也不会
48
+ 自动获得合并权;
49
+ - worker 的工具面受限并被要求留在 worktree,但主检出保护来自 scope/merge 检查与 preset,
50
+ **不是** OS 沙箱;
51
+ - `mode.workspaceLock` 防止第二个 Tower 会话驱动同一仓库。
52
+
53
+ 它**不**防御:完全通过所有门禁但被恶意指令操纵的模型;仓库自己声明的 hooks/测试命令;或无视
54
+ 指令、通过 shell 访问 worktree 之外文件的 worker。更高保证的团队应设
55
+ `mode.allowAgentControl:false`、保持 `review.requireReview:true`、在运行 Tower 前审查仓库,并用
56
+ OS 级隔离运行 host。
57
+
29
58
  ## 不由插件强制执行的约束(部署关注点)
30
59
 
31
60
  - **Worktree 隔离是协议约定,不是 OS 沙箱。** worker 会被分配独立 worktree/分支并被要求留在其中,但
@@ -45,7 +74,7 @@ Tower 提供的是**协作层面的保证**,不是沙箱。需要理解哪些
45
74
  插件。只为你信任的检出配置 HMR 监视 root;重载会执行这些文件当前的代码。
46
75
  - **提示注入。** reviewer 会读取不受信任的分支内容(diff/评论)。只读 preset 限制了影响面,但模型侧
47
76
  没有绝对防护。
48
- - **基线分支保护。** Tower 会写入本地基线分支;服务端的 branch protection/push 规则不属于本插件。
77
+ - **基线分支保护。** Tower 会写入本地基线分支;服务端 branch protection/push 规则不属于本插件。本仓库自身 `main` 的保护见[仓库保护](#仓库保护);为你运行 Tower 的仓库开启同等保护仍属于部署关注点。
49
78
 
50
79
  ## 报告问题
51
80
 
@@ -40,11 +40,13 @@ merge、worktree 与 spawn 策略。它使用与 profile 配置相同的 JSON
40
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
+ | `init.autoExclude` | `true` | 把仓库内产物(`MISSIONS.md`、仓库内 worktree root)追加到 `.git/info/exclude`;绝不修改 `.gitignore`。 |
43
44
  | `mode.promptSection` | `''` | 替换用的提示文案(与 `section` 同时设置时优先)。 |
44
45
  | `mode.sectionAppend` | `''` | 追加在提醒之后的额外文本(团队约定)。 |
45
46
  | `mode.allowAgentControl` | `true` | 注册 `tower_control`;模型可带理由进入/退出。设为 `false` 则仅人类可控制模式。 |
46
47
  | `mode.autoPauseGoal` | `true` | 进入模式时暂停活跃 goal;退出时不会自动恢复。 |
47
48
  | `mode.requireResumeAuthorization` | `false` | 恢复因 `pauseCause:user` 暂停的 mission 时,必须提供 `authorization` 引用。 |
49
+ | `mode.requireHumanAckOnFirstEntry` | `false` | 要求人类先执行 `/tower ack`,模型才能在当前会话首次进入 Tower 模式。 |
48
50
  | `mode.workspaceLock` | `false`(随包 patch:`true`) | 每个仓库只允许一个 Tower 会话(`tower.lock`);其他会话得到 `workspace-busy`。 |
49
51
  | `mode.workspaceLockScope` | `all` | `all` 锁定所有仓库工具;`mutations` 对只读工具跳过 workspace 锁。 |
50
52
  | `mode.planModePolicy` | `clear` | 进入 Tower 模式会清除活跃的 plan mode(或 `refuse` 直到退出计划);前置步骤也会清除稍后激活的计划。 |
@@ -82,10 +84,11 @@ merge、worktree 与 spawn 策略。它使用与 profile 配置相同的 JSON
82
84
  | `gates.allowReviewExemption` | `false` | 在 `review.requireReview:true` 时,允许用 `reviewPolicy:"none"` + 非空理由跳过审查 blocker;默认故意关闭;全局审查关闭时无影响。 |
83
85
  | `docs.board` | `true` | 变更后写入 `MISSIONS.md`。 |
84
86
  | `docs.boardLocation` | `common` | `common` = `<git-common-dir>/dsh-tower/MISSIONS.md`;`repo` = `<repoRoot>/MISSIONS.md`。 |
87
+ | `tools.exposeOperational` | `true` | 注册 batch/resource/import/legacy/recover/test/doctor 工具;`false` 可缩小固定工具 schema 上下文。 |
85
88
  | `leases.ttlMs` | `900000` | 默认咨询式租约寿命;过期不会自动释放。 |
86
89
  | `dev.hotReload` | `true` | 注册 `tower_reload`;关闭时 `/tower reload` 也被禁用。无 HMR 服务时调用返回 `reload-unavailable`。 |
87
90
  | `dev.reloadDelayMs` | `50` | 计划中的 reload 延迟,先让工具结果送达。 |
88
- | `dev.reloadRequiredIds` | 四个 Tower loader id | 作为 required 传给 `reconcileProfile` 的条目 id。 |
91
+ | `dev.reloadRequiredIds` | 五个 Tower loader id | 作为 required 传给 `reconcileProfile` 的条目 id。 |
89
92
 
90
93
  ## 示例
91
94
 
@@ -3,7 +3,7 @@
3
3
  [English](../../reference/errors.md) | **简体中文**
4
4
 
5
5
  工具失败是抛出的错误,不是 `{ok:false}` 结果(门禁结果是例外:见 [blockers.md](blockers.md))。
6
- 每个抛出的错误都以稳定 code 作为消息前缀。本版本定义 31 个工具错误码。
6
+ 每个抛出的错误都以稳定 code 作为消息前缀。本版本定义 33 个工具错误码。
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
+ | `stale-base` | 操作准备期间仓库状态已变化,需要重试。 | two-phase mutation 重校验 |
23
24
  | `worktree-dirty` | mission worktree 有未提交改动,不能完成。 | `tower_complete` 前遗留 staged/modified/untracked 文件 |
24
25
  | `state-locked` | 另一个 Tower 写入者持有 state 锁。 | 锁超时后的并发写 |
25
26
  | `state-corrupt` | Tower state 文件不可读或无效。 | 手工编辑过的 `state.json` |
@@ -29,6 +30,7 @@
29
30
  | `git-timeout` | git 命令超时或输出超出缓冲区。 | 慢 pre-merge 钩子、超大 diff |
30
31
  | `spawn-unavailable` | subagents 服务不可用。 | 没有 `subagents` 的 Host |
31
32
  | `provider-missing` | 没有注册的 subagent provider。 | provider 列表为空 |
33
+ | `provider-cooldown` | 连续可重试的 provider 失败后,spawn 进入短暂冷却。 | 等待 `blockedUntil` 后重试 |
32
34
  | `preset-missing` | 配置的 agent preset 未注册。 | 缺少 `tower-worker`/`tower-reviewer` preset 行、DSH 未重启 |
33
35
  | `reviewer-prerequisite` | reviewer spawn 的前置条件未满足。 | mission 尚未 `completed` 或还没有 worktree 就 spawn reviewer |
34
36
  | `not-authorized` | 调用方 agent 角色无权执行该操作。 | worker 调用仅限控制塔的工具,或修改 mission 状态/scope;reviewer 修改 finding |
@@ -34,7 +34,7 @@ roster 角色由 `exec.agent` 与 roster 共同决定。mission 级工具(`tow
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
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
- | `tower_status` | `missionId?`、`offset?`、`limit?`、`includeGate?`、`format?` | 默认返回面板。`missionId` 返回完整详情(任务/备注/审查/finding/完成报告)。`offset`/`limit` 对 mission 分页(默认 20,最大 100),`includeGate` 附带门禁摘要,`format:"markdown"` 返回看板。 |
37
+ | `tower_status` | `missionId?`、`offset?`、`limit?`、`includeGate?`、`format?` | 默认返回面板。`missionId` 返回完整详情(任务/备注/审查/finding/完成报告)。`offset`/`limit` 对 mission 分页(默认 20,最大 100),`includeGate` 附带门禁摘要,`format:"markdown"` 返回看板。host 提供 mode hook 时还会包含 `mode.lastChange` 与最近 `history`。 |
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` 限制整个仓库。 |
39
39
  | `tower_roster` | — | roster 及来自子代理目录的实时活动(`running`/`inactive`/`missing`/`unknown`)与 `stale` 标记。只读。 |
40
40
  | `tower_adopt` | `retireMissing?` | 把 roster 条目与持久子代理目录对账。可证明已消失的 agent 会被标记为 retired(默认),或标记为 `missing`(`retireMissing:false`)以便检查/接管。 |
@@ -0,0 +1,112 @@
1
+ // Pure batch lifecycle decisions and status projection.
2
+ //
3
+ // `domain/batch.js` owns the admission/close evidence validation; this module
4
+ // owns the id assignment, close/abandon record transitions, and status payload
5
+ // that app/batch.js used to keep inline. Receipt file reads stay in the tool
6
+ // layer.
7
+ // @ts-check
8
+ import { TowerToolError } from '../errors.js'
9
+ import { summarize } from '../batch.js'
10
+
11
+ /** @typedef {import('../../types/index.js').TowerState} TowerState */
12
+
13
+ /**
14
+ * @param {string} message
15
+ * @param {string} [code]
16
+ * @returns {never}
17
+ */
18
+ function fail(message, code = 'batch-invalid') {
19
+ throw new TowerToolError(code, message)
20
+ }
21
+
22
+ /**
23
+ * Next free `B<n>` id.
24
+ * @param {any[]} batches
25
+ * @returns {string}
26
+ */
27
+ export function nextBatchId(batches) {
28
+ const max = batches.reduce((value, batch) => {
29
+ const match = /^B(\d+)$/.exec(String(batch.id ?? ''))
30
+ return match ? Math.max(value, Number(match[1])) : value
31
+ }, 0)
32
+ return `B${max + 1}`
33
+ }
34
+
35
+ /**
36
+ * Project the persisted batch record from normalized create input.
37
+ * @param {string} id
38
+ * @param {any} normalized
39
+ * @param {string} actor
40
+ * @param {number} now
41
+ * @returns {any}
42
+ */
43
+ export function createBatchRecord(id, normalized, actor, now) {
44
+ return {
45
+ id,
46
+ status: 'open',
47
+ ...normalized,
48
+ createdBy: actor,
49
+ createdAt: now,
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Mark a validated open batch closed with its evidence snapshot.
55
+ * @param {any} batch
56
+ * @param {{ checks?: unknown, treeTip?: unknown, receiptHashes?: unknown }} options
57
+ * @param {string} actor
58
+ * @param {number} now
59
+ * @returns {any}
60
+ */
61
+ export function closeBatchRecord(batch, options, actor, now) {
62
+ batch.status = 'closed'
63
+ batch.closedAt = now
64
+ batch.closedBy = actor
65
+ batch.close = {
66
+ checks: options.checks ?? [],
67
+ treeTip: options.treeTip ?? null,
68
+ receiptHashes: options.receiptHashes ?? {},
69
+ at: now,
70
+ }
71
+ return batch
72
+ }
73
+
74
+ /**
75
+ * A closed batch cannot be abandoned; an already-abandoned one is a no-op.
76
+ * @param {any} batch
77
+ * @param {string} batchId
78
+ * @returns {{ alreadyAbandoned: boolean }}
79
+ */
80
+ export function abandonBatchCheck(batch, batchId) {
81
+ if (batch.status === 'closed') fail(`batch ${batchId} is already closed`)
82
+ return { alreadyAbandoned: batch.status === 'abandoned' }
83
+ }
84
+
85
+ /**
86
+ * Apply an abandonment to the draft batch.
87
+ * @param {{ batch: any, actor: string, reason?: unknown, now: number }} params
88
+ * @returns {any}
89
+ */
90
+ export function applyBatchAbandon({ batch, actor, reason, now }) {
91
+ batch.status = 'abandoned'
92
+ batch.abandonedAt = now
93
+ batch.abandonedBy = actor
94
+ batch.abandonReason = typeof reason === 'string' && reason.trim() ? reason.trim() : null
95
+ return batch
96
+ }
97
+
98
+ /**
99
+ * Read-only `tower_batch status` payload.
100
+ * @param {TowerState} state
101
+ * @param {string | undefined} batchId
102
+ * @returns {any}
103
+ */
104
+ export function batchStatusPayload(state, batchId) {
105
+ const batches = state.batches ?? []
106
+ const batch = batchId ? batches.find((entry) => entry.id === batchId) : null
107
+ if (batchId && !batch) fail(`unknown batch "${batchId}"`)
108
+ return {
109
+ batches: batches.map((entry) => summarize(state, entry)),
110
+ ...(batch ? { batch: summarize(state, batch) } : {}),
111
+ }
112
+ }
@@ -0,0 +1,127 @@
1
+ // Pure message routing, read tracking, and inbox projections.
2
+ //
3
+ // app/messages.js owns the state lock, load, persistence, and activity log;
4
+ // app/roster.js's unreadFor delegates to `unreadMessages` here.
5
+ // @ts-check
6
+ import { TowerToolError } from '../errors.js'
7
+
8
+ /** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
9
+ /** @typedef {import('../../types/index.js').TowerState} TowerState */
10
+
11
+ /**
12
+ * @param {string} message
13
+ * @returns {never}
14
+ */
15
+ function fail(message) {
16
+ throw new TowerToolError('invalid-argument', message)
17
+ }
18
+
19
+ /**
20
+ * Validate the recipient against the roster plus the synthetic `tower` name.
21
+ * @param {{ roster?: RosterEntry[], to: string, from: string }} params
22
+ * @returns {void}
23
+ */
24
+ export function assertMessageRecipient({ roster = [], to, from }) {
25
+ const names = new Set(roster.map((entry) => entry.name))
26
+ names.add('tower')
27
+ if (to !== 'all' && !names.has(to)) {
28
+ fail(`unknown message recipient "${to}" (known: ${[...names].join(', ')}, all)`)
29
+ }
30
+ if (to === from) fail('refusing to send a message to yourself')
31
+ }
32
+
33
+ /**
34
+ * Project the persisted message record.
35
+ * @param {any} input
36
+ * @param {number} seq
37
+ * @param {number} now
38
+ * @returns {any}
39
+ */
40
+ export function createMessage(input, seq, now) {
41
+ return {
42
+ id: `msg-${seq}`,
43
+ seq,
44
+ from: input.from,
45
+ to: input.to,
46
+ subject: input.subject,
47
+ body: input.body,
48
+ scope: input.scope ?? null,
49
+ consentRef: input.consentRef ?? null,
50
+ at: now,
51
+ readBy: [],
52
+ delivery: [],
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Append delivery results to a message, preserving earlier entries.
58
+ * @param {{ delivery?: unknown[] }} message
59
+ * @param {unknown[]} results
60
+ * @returns {unknown[]}
61
+ */
62
+ export function addDelivery(message, results) {
63
+ message.delivery = [...(message.delivery ?? []), ...results]
64
+ return message.delivery
65
+ }
66
+
67
+ /**
68
+ * Inbox page plus read-marking mutation. Returns `changed` so the use case can
69
+ * decide whether the state file needs a write.
70
+ * @param {any[]} messages
71
+ * @param {{ reader: string, limit?: number, includeRead?: boolean }} input
72
+ * @param {number} now
73
+ * @returns {{ payload: any, changed: boolean }}
74
+ */
75
+ export function inboxView(messages, input, now) {
76
+ const reader = input.reader
77
+ const limit = Math.max(1, Math.min(100, input.limit ?? 20))
78
+ const includeRead = input.includeRead === true
79
+ const visible = [...messages]
80
+ .reverse()
81
+ .filter((message) => (message.to === reader || message.to === 'all') && message.from !== reader)
82
+ const unread = visible.filter((message) => !(message.readBy ?? []).includes(reader))
83
+ const page = (includeRead ? visible : unread).slice(0, limit)
84
+
85
+ let changed = false
86
+ for (const message of page) {
87
+ if (!(message.readBy ?? []).includes(reader)) {
88
+ message.readBy = [...(message.readBy ?? []), reader]
89
+ message.readAt = { ...(message.readAt ?? {}), [reader]: now }
90
+ changed = true
91
+ }
92
+ }
93
+ const unreadRemaining = visible.filter((message) => !(message.readBy ?? []).includes(reader)).length
94
+ return {
95
+ payload: {
96
+ reader,
97
+ messages: page.map((message) => ({
98
+ id: message.id,
99
+ from: message.from,
100
+ to: message.to,
101
+ subject: message.subject,
102
+ body: message.body,
103
+ scope: message.scope ?? null,
104
+ at: message.at,
105
+ delivery: message.delivery ?? [],
106
+ ackAt: message.readAt?.[reader] ?? null,
107
+ })),
108
+ pageSize: page.length,
109
+ unreadRemaining,
110
+ },
111
+ changed,
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Unread inbox rows for one recipient (messages from self never count).
117
+ * @param {TowerState} state
118
+ * @param {string} name
119
+ * @returns {any[]}
120
+ */
121
+ export function unreadMessages(state, name) {
122
+ return (state.messages ?? []).filter((message) => {
123
+ if (message.from === name) return false
124
+ if (message.to !== name && message.to !== 'all') return false
125
+ return !(/** @type {any[]} */ (message.readBy ?? [])).includes(name)
126
+ })
127
+ }
@@ -0,0 +1,158 @@
1
+ // Pure roster observation and stale/inactive policy.
2
+ //
3
+ // app/roster.js owns the state lock, load, persistence, and activity log; the
4
+ // transitions below mutate only the in-memory roster rows they are given.
5
+ // @ts-check
6
+ import { ROSTER_INACTIVE } from '../roster.js'
7
+
8
+ /** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
9
+
10
+ /**
11
+ * @param {RosterEntry} entry
12
+ * @returns {void}
13
+ */
14
+ export function clearStale(entry) {
15
+ delete entry.stale
16
+ delete entry.staleAt
17
+ }
18
+
19
+ /**
20
+ * Count repeated absence observations and list rows ready to adopt.
21
+ * @param {RosterEntry[]} roster
22
+ * @param {{ present?: unknown[], absent?: unknown[], confirmations?: unknown }} observations
23
+ * @param {number} now
24
+ * @returns {{ candidates: string[], changed: boolean }}
25
+ */
26
+ export function recordRosterObservations(roster, { present = [], absent = [], confirmations = 2 }, now) {
27
+ const presentIds = new Set(present)
28
+ const absentIds = new Set(absent)
29
+ /** @type {string[]} */
30
+ const candidates = []
31
+ let changed = false
32
+ for (const entry of roster) {
33
+ if (presentIds.has(entry.agentId)) {
34
+ if (entry.absentObservations !== undefined) {
35
+ delete entry.absentObservations
36
+ delete entry.absentSince
37
+ changed = true
38
+ }
39
+ continue
40
+ }
41
+ if (!absentIds.has(entry.agentId)) continue
42
+ entry.absentObservations = (/** @type {number} */ (entry.absentObservations ?? 0)) + 1
43
+ entry.absentSince ??= now
44
+ changed = true
45
+ const observations = /** @type {number} */ (entry.absentObservations)
46
+ if (observations >= Math.max(2, Number(confirmations) || 2)) {
47
+ candidates.push(/** @type {string} */ (entry.agentId))
48
+ }
49
+ }
50
+ return { candidates, changed }
51
+ }
52
+
53
+ /**
54
+ * Release live slots whose catalog activity is explicitly inactive.
55
+ * @param {RosterEntry[]} roster
56
+ * @param {Record<string, string>} activity
57
+ * @param {number} now
58
+ * @returns {{ released: string[] }}
59
+ */
60
+ export function reconcileObservedInactive(roster, activity = {}, now) {
61
+ /** @type {string[]} */
62
+ const released = []
63
+ for (const entry of roster) {
64
+ if (entry.state === 'reserved' || ROSTER_INACTIVE.has(entry.state)) continue
65
+ const agentId = /** @type {string} */ (entry.agentId)
66
+ if (activity[agentId] !== 'inactive') continue
67
+ entry.state = 'inactive'
68
+ entry.outcome = 'catalog reported inactive'
69
+ entry.outcomeAt = now
70
+ clearStale(entry)
71
+ released.push(entry.name)
72
+ }
73
+ return { released }
74
+ }
75
+
76
+ /**
77
+ * Retire entries by agent id, skipping rows already retired.
78
+ * @param {RosterEntry[]} roster
79
+ * @param {unknown[]} agentIds
80
+ * @param {number} now
81
+ * @returns {{ retired: string[] }}
82
+ */
83
+ export function retireEntries(roster, agentIds, now) {
84
+ if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
85
+ /** @type {string[]} */
86
+ const retired = []
87
+ for (const entry of roster) {
88
+ if (entry.state !== 'retired' && agentIds.includes(entry.agentId)) {
89
+ entry.state = 'retired'
90
+ entry.retiredAt = now
91
+ clearStale(entry)
92
+ retired.push(/** @type {string} */ (entry.agentId))
93
+ }
94
+ }
95
+ return { retired }
96
+ }
97
+
98
+ /**
99
+ * Mark entries missing unless they are already inactive.
100
+ * @param {RosterEntry[]} roster
101
+ * @param {unknown[]} agentIds
102
+ * @param {number} now
103
+ * @returns {{ missing: string[] }}
104
+ */
105
+ export function markMissingEntries(roster, agentIds, now) {
106
+ if (!Array.isArray(agentIds) || agentIds.length === 0) return { missing: [] }
107
+ /** @type {string[]} */
108
+ const missing = []
109
+ for (const entry of roster) {
110
+ if (!ROSTER_INACTIVE.has(entry.state) && agentIds.includes(entry.agentId)) {
111
+ entry.state = 'missing'
112
+ entry.missingSince = now
113
+ clearStale(entry)
114
+ missing.push(/** @type {string} */ (entry.agentId))
115
+ }
116
+ }
117
+ return { missing }
118
+ }
119
+
120
+ /**
121
+ * Watchdog sweep: catalog-inactive rows become inactive; rows absent from an
122
+ * authoritative catalog and older than `staleMs` become stale. Unknown catalog
123
+ * state is never treated as stopped.
124
+ * @param {RosterEntry[]} roster
125
+ * @param {{ activity?: Record<string, string>, staleMs?: unknown, authoritative?: unknown }} input
126
+ * @param {number} now
127
+ * @returns {{ marked: string[], idled: string[] }}
128
+ */
129
+ export function sweepRosterState(roster, input = {}, now) {
130
+ const staleMs = Number(input.staleMs) || 0
131
+ if (staleMs <= 0 || input.authoritative !== true) return { marked: [], idled: [] }
132
+ const activity = input.activity ?? {}
133
+ /** @type {string[]} */
134
+ const marked = []
135
+ /** @type {string[]} */
136
+ const idled = []
137
+ for (const entry of roster) {
138
+ if (ROSTER_INACTIVE.has(entry.state)) continue
139
+ const observed = activity[/** @type {string} */ (entry.agentId)]
140
+ if (observed === 'inactive') {
141
+ entry.state = 'inactive'
142
+ entry.outcome = entry.outcome ?? 'idle (catalog)'
143
+ entry.outcomeAt = now
144
+ idled.push(entry.name)
145
+ continue
146
+ }
147
+ if (observed === 'running') continue
148
+ if (observed !== undefined) continue
149
+ const age = now - Number(entry.spawnedAt ?? now)
150
+ if (age > staleMs) {
151
+ entry.state = 'stale'
152
+ entry.stale = true
153
+ entry.staleAt = now
154
+ marked.push(entry.name)
155
+ }
156
+ }
157
+ return { marked, idled }
158
+ }