dsh-tower 2.1.0 → 2.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.
Files changed (170) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/COMPATIBILITY.md +5 -1
  3. package/CONTRIBUTING.md +64 -5
  4. package/PRINCIPLES.md +145 -0
  5. package/README.en.md +7 -4
  6. package/README.md +7 -4
  7. package/SECURITY.md +6 -0
  8. package/{lib → adapters}/archive.js +1 -1
  9. package/{lib → adapters}/caseSensitivity.js +1 -1
  10. package/adapters/default.js +51 -10
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +1 -1
  13. package/{lib → adapters}/legacy.js +1 -1
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/store-fs.js +28 -1
  16. package/adapters/store.js +204 -0
  17. package/{lib → adapters}/tests.js +9 -5
  18. package/adapters/vcs-git.js +15 -1
  19. package/{lib → adapters}/workspaceLock.js +1 -1
  20. package/{lib → adapters}/worktreeOwnership.js +1 -1
  21. package/app/archive.js +4 -6
  22. package/app/batch.js +9 -10
  23. package/app/board.js +2 -4
  24. package/app/checkCompletion.js +9 -10
  25. package/app/enrichedMissions.js +2 -4
  26. package/app/findings.js +6 -7
  27. package/app/gate.js +1 -2
  28. package/app/init.js +24 -29
  29. package/app/merge.js +58 -51
  30. package/app/messages.js +7 -8
  31. package/app/mission.js +5 -5
  32. package/app/plan.js +8 -5
  33. package/app/provision.js +12 -14
  34. package/app/read.js +6 -3
  35. package/app/rebase.js +14 -16
  36. package/app/recording.js +7 -8
  37. package/app/review.js +15 -8
  38. package/app/roster.js +14 -15
  39. package/{lib → app}/spawn-prechecks.js +4 -3
  40. package/app/spawn.js +12 -13
  41. package/app/teardown.js +8 -9
  42. package/app/tower.js +89 -35
  43. package/docs/README.md +6 -2
  44. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  45. package/docs/adr/0002-single-state-json.md +50 -0
  46. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  47. package/docs/adr/0004-surface-budget.md +50 -0
  48. package/docs/adr/README.md +51 -0
  49. package/docs/agents/workflow.md +51 -0
  50. package/docs/architecture/overview.md +167 -36
  51. package/docs/guide/hot-reload.md +1 -1
  52. package/docs/guide/release.md +1 -1
  53. package/docs/operations/incident.md +82 -0
  54. package/docs/operations/runbook.md +12 -3
  55. package/docs/reference/blockers.md +2 -2
  56. package/docs/reference/config.generated.md +1 -0
  57. package/docs/reference/doctor.md +48 -2
  58. package/docs/reference/errors.generated.md +41 -37
  59. package/docs/reference/errors.md +53 -41
  60. package/docs/reference/events.md +38 -6
  61. package/docs/reference/surface-budget.md +128 -0
  62. package/docs/reference/tools.generated.md +5 -4
  63. package/docs/reference/tools.md +4 -4
  64. package/docs/zh/guide/hot-reload.md +1 -1
  65. package/docs/zh/reference/blockers.md +2 -2
  66. package/docs/zh/reference/doctor.md +29 -1
  67. package/docs/zh/reference/tools.md +4 -4
  68. package/domain/batch.js +2 -2
  69. package/domain/config/schema.js +25 -1
  70. package/domain/contracts/outputs.js +6 -2
  71. package/domain/contracts/tool-inputs/ops.js +1 -0
  72. package/domain/errors.js +297 -42
  73. package/domain/events.js +207 -0
  74. package/domain/integration/gate.js +17 -2
  75. package/domain/integration/merge.js +35 -0
  76. package/domain/lockBudget.js +30 -0
  77. package/domain/missions/completion.js +14 -0
  78. package/domain/missions/lifecycle.js +9 -3
  79. package/domain/missions/plan.js +14 -0
  80. package/domain/missions/review.js +90 -7
  81. package/domain/read/views.js +3 -0
  82. package/domain/state/archive.js +1 -1
  83. package/domain/state.js +1 -1
  84. package/domain/surface.js +248 -0
  85. package/{lib → host}/doctor.js +116 -9
  86. package/{lib → host}/modeStore.js +1 -1
  87. package/{lib → host}/tool-registry.js +106 -31
  88. package/host/tower.js +32 -0
  89. package/index.js +12 -9
  90. package/package.json +10 -3
  91. package/ports/archive.js +18 -0
  92. package/ports/caseSensitivity.js +15 -0
  93. package/ports/clock.js +10 -2
  94. package/ports/config.js +19 -0
  95. package/ports/contract.js +38 -0
  96. package/ports/events.js +24 -0
  97. package/ports/ids.js +10 -2
  98. package/ports/index.js +54 -2
  99. package/ports/ownership.js +17 -0
  100. package/ports/state.js +16 -0
  101. package/ports/store.js +52 -3
  102. package/ports/vcs.js +25 -4
  103. package/scripts/architecture-waivers.json +19 -0
  104. package/scripts/architecture.mjs +634 -23
  105. package/scripts/check-docs.mjs +8 -35
  106. package/scripts/check-pack.mjs +68 -39
  107. package/scripts/check-philosophy.mjs +266 -0
  108. package/scripts/domain-metrics.mjs +13 -4
  109. package/scripts/generate-config-reference.mjs +14 -1
  110. package/scripts/generate-error-reference.mjs +68 -14
  111. package/scripts/generate-events-reference.mjs +83 -0
  112. package/scripts/generate-tools-reference.mjs +11 -0
  113. package/scripts/issue-audit.mjs +2 -2
  114. package/scripts/lib/surface.mjs +87 -0
  115. package/scripts/pack-manifest.txt +58 -22
  116. package/scripts/regression-audit.mjs +164 -0
  117. package/scripts/release-check.mjs +1 -1
  118. package/scripts/surface-baseline.json +21 -0
  119. package/scripts/surface-baseline.mjs +120 -0
  120. package/tools/adopt.js +2 -3
  121. package/tools/batch.js +2 -3
  122. package/tools/complete.js +2 -3
  123. package/tools/diff.js +3 -3
  124. package/tools/doctor.js +7 -10
  125. package/tools/finding.js +2 -3
  126. package/tools/findings.js +3 -3
  127. package/tools/import-legacy.js +3 -5
  128. package/tools/inbox.js +3 -3
  129. package/tools/index.js +7 -7
  130. package/tools/init.js +3 -6
  131. package/tools/interrupt.js +2 -3
  132. package/tools/legacy-map.js +3 -5
  133. package/tools/merge.js +3 -3
  134. package/tools/mission.js +2 -3
  135. package/tools/plan.js +6 -5
  136. package/tools/preflight.js +2 -3
  137. package/tools/rebase.js +3 -3
  138. package/tools/recover.js +38 -13
  139. package/tools/reload.js +3 -6
  140. package/tools/resource.js +3 -5
  141. package/tools/review.js +3 -4
  142. package/tools/roster.js +2 -3
  143. package/tools/send.js +2 -2
  144. package/tools/spawn.js +8 -5
  145. package/tools/status.js +3 -3
  146. package/tools/teardown.js +2 -3
  147. package/tools/test.js +2 -2
  148. package/types/index.d.ts +1 -1
  149. package/ui-bundle/README.md +37 -3
  150. package/ui-bundle/client.cards.js +356 -0
  151. package/ui-bundle/client.denied.js +142 -0
  152. package/ui-bundle/client.dock.js +333 -0
  153. package/ui-bundle/client.helpers.js +271 -0
  154. package/ui-bundle/client.js +79 -1747
  155. package/ui-bundle/client.locales.js +247 -0
  156. package/ui-bundle/client.mode.js +206 -0
  157. package/ui-bundle/client.overlay.js +64 -0
  158. package/ui-bundle/client.styles.js +151 -0
  159. package/ui-bundle/client.toolview.js +82 -0
  160. package/ui-bundle/package.json +1 -0
  161. package/lib/persistence.js +0 -356
  162. package/lib/store.js +0 -133
  163. /package/{lib → adapters}/stateFile.js +0 -0
  164. /package/{lib → app}/briefing.js +0 -0
  165. /package/{lib → app}/spawn-support.js +0 -0
  166. /package/{lib → host}/compatibility.js +0 -0
  167. /package/{lib → host}/config.js +0 -0
  168. /package/{lib → host}/hmr.js +0 -0
  169. /package/{lib → host}/modeState.js +0 -0
  170. /package/{lib → host}/presets.js +0 -0
@@ -4,54 +4,66 @@ Tool failures are thrown errors, not `{ok:false}` results (gate outcomes are the
4
4
  [blockers.md](blockers.md)). Every thrown error carries a stable code as a message prefix. This
5
5
  release defines 35 tool error codes.
6
6
 
7
- Every `TowerToolError` also carries `retryable` (boolean), `nextAction` (string or `null`) and `details`
8
- (structured context or `null`) for programmatic callers; the message prefix remains the stable contract
9
- for text-only clients.
7
+ Every `TowerToolError` carries `retryable` (boolean), `nextAction` (string or `null`) and `details`
8
+ (structured context or `null`) for programmatic callers. The model-visible surface is the message:
9
+ when the catalog supplies a recovery step, exactly one trailing ` — next: …` segment is rendered;
10
+ when the code is also retryable, that same segment ends with ` (retryable)`. Codes without a
11
+ `nextAction` keep the historical `[tower:<code>] <message>` form byte-for-byte, and a message that
12
+ already contains its action is not suffixed twice. The table below is generated from
13
+ `domain/errors.js`, the same catalog the runtime uses.
10
14
 
11
15
  ```
12
- [tower:mission-not-found] unknown mission "M9"
16
+ [tower:mission-not-found] unknown mission "M9" — next: list missions with tower_status
13
17
  ```
14
18
 
15
19
  Callers can match the prefix instead of the prose. Codes are reserved for the conditions below; a
16
20
  plain `Error` without a structured code is reported as `tool-internal`.
17
21
 
18
- | Code | Meaning | Typical trigger |
19
- |---|---|---|
20
- | `invalid-argument` | A tool argument is missing, malformed, or not allowed. | `missionId`/`id` omitted; unknown status; empty report; bad scope glob; `timeoutMs < 1000`; `status: "all"` on an update. |
21
- | `repo-invalid` | `repoRoot` is missing, relative, or not inside a Git repository. | passing a path in a directory that is not a repo |
22
- | `not-initialized` | Tower is not initialized for this repository. | a read tool before `tower_init` |
23
- | `mission-not-found` | No mission with that id exists. | a stale or mistyped `M9` |
24
- | `finding-not-found` | No finding with that id exists. | `tower_findings({id:"F99"})` |
25
- | `invalid-transition` | The requested state transition is not allowed. | a blocked→merged attempt; a finding transition not in the graph; a reviewer mutating findings; completing a mission with open tasks, blockers, no commits, or unread inbox messages. |
26
- | `stale-base` | The repository state changed while the operation was being prepared; retry. | two-phase mutation revalidation |
27
- | `worktree-dirty` | The mission worktree has uncommitted changes and cannot complete. | staged, modified, or untracked files left behind before `tower_complete` |
28
- | `state-locked` | Another Tower writer holds the state lock. | concurrent mutation after the lock timeout |
29
- | `state-corrupt` | The Tower state file is unreadable or invalid. | hand-edited `state.json`; invalid JSON; no usable `schemaVersion` |
30
- | `state-version-unsupported` | The state file's schema version is newer than this build supports, or cannot be migrated. | opening a repository written by a newer plugin; an unknown older version; this build refuses to downgrade |
31
- | `state-migration-failed` | A state migration threw; the original file was not modified. | a bug in a migration step; see the pre-migration `state.json.v<N>.bak` |
32
- | `config-invalid` | Tower configuration is invalid. | malformed repo `config.json`, a value that violates the schema |
33
- | `config-unknown-key` | Tower configuration has an unknown top-level key. | a typo such as `allowAgentToggle` |
34
- | `git-failed` | A git command failed for a reason other than the delegated cases. | a rejected merge command, invalid ref |
35
- | `git-timeout` | A git command timed out or exceeded its output buffer. | slow pre-merge hook, huge diff |
36
- | `spawn-unavailable` | The subagents service is unavailable. | a Host without `subagents` |
37
- | `provider-missing` | No subagent provider is registered. | an empty provider list |
38
- | `provider-cooldown` | spawn is in a short cooldown after repeated retryable provider failures. | wait for `blockedUntil`, then retry |
39
- | `preset-missing` | The configured agent preset is not registered. | missing `tower-worker`/`tower-reviewer` preset rows, DSH not restarted |
40
- | `reviewer-prerequisite` | The reviewer spawn prerequisite is not met. | reviewer spawned before the mission is `completed`, or before a worktree exists |
41
- | `not-authorized` | The calling agent role may not perform this action. | a worker calling a tower-only tool or changing mission status/scope; a reviewer mutating findings |
42
- | `workspace-busy` | Another Tower session owns this repository's workspace lock. | `mode.workspaceLock:true` and a second session calls a repo-scoped tool |
43
- | `worker-limit` | The repository reached `spawn.maxLiveWorkers` live workers. | spawning a fifth worker with the default limit |
44
- | `rate-limited` | The `tower_send` per-minute rate limit was reached. | `send.maxPerMinute` exceeded; retry later |
45
- | `takeover-refused` | The mission still has a running worker. | `tower_spawn({takeover:true})` before `tower_interrupt` |
46
- | `reload-unavailable` | The DSH HMR service is not available in this profile. | `tower_reload` / `/tower reload` on a profile without HMR |
47
- | `reload-failed` | The HMR reload failed. | `reconcileProfile` threw; check the DSH log and restart |
48
- | `tests-failed` | Declared test discovery failed or did not meet admission requirements. | nonzero command, malformed output, minCount/required shortfall |
49
- | `tests-timeout` | Test discovery exceeded its time or output budget. | `tests.timeoutMs`, or stdout over `tests.maxBytes` |
50
- | `lease-busy` | Another holder owns the requested advisory lease. | `tower_resource acquire` while `heavy-ci` is held |
51
- | `lease-token` | The lease release token does not match the holder. | releasing with another session's token |
52
- | `batch-invalid` | A batch request or its close evidence is invalid. | unknown member, duplicate id, unsafe receipt path, missing receipt hash |
53
- | `legacy-invalid` | The legacy snapshot or mapping is invalid. | unknown schema, source inside the Tower state dir, mapping rewrite |
54
- | `tool-internal` | An unexpected failure. | a bug; read the message and the activity log |
22
+ <!-- generated:error-table:start -->
23
+ | Code | Meaning | Retryable | Next action | Typical trigger |
24
+ |---|---|---|---|---|
25
+ | `invalid-argument` | A tool argument is missing, malformed, or not allowed. | no | — | `missionId`/`id` omitted; unknown status; empty report; bad scope glob; `timeoutMs < 1000`; `status: "all"` on an update. |
26
+ | `repo-invalid` | repoRoot is missing, relative, or not inside a Git repository. | no | pass an absolute path to a directory inside a Git repository | passing a path in a directory that is not a repo |
27
+ | `not-initialized` | Tower is not initialized for this repository; run tower_init. | no | run tower_init for this repository | a read tool before `tower_init` |
28
+ | `mission-not-found` | No mission with that id exists. | no | list missions with tower_status | a stale or mistyped `M9` |
29
+ | `finding-not-found` | No finding with that id exists. | no | list findings with tower_findings | `tower_findings({id:"F99"})` |
30
+ | `invalid-transition` | The requested state transition is not allowed. | no | — | a blocked→merged attempt; a finding transition not in the graph; a reviewer mutating findings; completing a mission with open tasks, blockers, no commits, or unread inbox messages. |
31
+ | `stale-base` | The repository state changed while the operation was being prepared; retry. | yes | retry the tool; if it keeps failing, run tower_preflight and inspect tower_status | two-phase mutation revalidation |
32
+ | `state-locked` | Another Tower writer holds the state lock. | yes | wait for the other Tower writer to finish, then retry | concurrent mutation after the lock timeout |
33
+ | `state-corrupt` | The Tower state file is unreadable or invalid. | no | restore state.json from a backup, or re-initialize after filing the corruption | hand-edited `state.json`; invalid JSON; no usable `schemaVersion` |
34
+ | `state-version-unsupported` | The Tower state file uses a schema version this build cannot read or downgrade. | no | upgrade dsh-tower to a release that supports this state schemaVersion | opening a repository written by a newer plugin; an unknown older version; this build refuses to downgrade |
35
+ | `state-migration-failed` | A Tower state migration failed; the original state file was not modified. | no | restore the pre-migration backup state.json.v<N>.bak and report the migration bug | a bug in a migration step; see the pre-migration `state.json.v<N>.bak` |
36
+ | `config-invalid` | Tower configuration is invalid. | no | fix the repository config file, then retry | malformed repo `config.json`, a value that violates the schema |
37
+ | `config-unknown-key` | Tower configuration has an unknown top-level key. | no | remove the unknown key from the repository config file, then retry | a typo such as `allowAgentToggle` |
38
+ | `git-failed` | A git command failed. | no | — | a rejected merge command, invalid ref |
39
+ | `git-timeout` | A git command timed out or exceeded its output buffer. | yes | retry; if it persists, raise git.timeoutMs or merge.timeoutMs | slow pre-merge hook, huge diff |
40
+ | `worktree-dirty` | The mission worktree has uncommitted changes and cannot complete. | no | commit or remove the worktree changes, then retry tower_complete | staged, modified, or untracked files left behind before `tower_complete` |
41
+ | `spawn-unavailable` | The subagents service is unavailable. | no | enable the DSH subagents service, then retry | a Host without `subagents` |
42
+ | `provider-missing` | No subagent provider is registered. | no | register a subagent provider in the DSH profile, then retry | an empty provider list |
43
+ | `provider-cooldown` | The provider is throttled; spawn is in a short cooldown. | yes | wait for the cooldown to expire, then retry | repeated retryable provider failures; wait for `blockedUntil` |
44
+ | `preset-missing` | The configured agent preset is not registered. | no | install the tower-worker and tower-reviewer agent presets and restart DSH | missing `tower-worker`/`tower-reviewer` preset rows, DSH not restarted |
45
+ | `reviewer-prerequisite` | The reviewer spawn prerequisite is not met. | no | complete the mission first, then spawn the reviewer | reviewer spawned before the mission is `completed`, or before a worktree exists |
46
+ | `not-authorized` | The calling agent role may not perform this action. | no | call the tool as the tower, or as the worker/reviewer it is assigned to | a worker calling a tower-only tool or changing mission status/scope; a reviewer mutating findings |
47
+ | `workspace-busy` | Another Tower session owns this repository workspace lock. | yes | run /tower off in the other session or wait for its lock to become stale | `mode.workspaceLock:true` and a second session calls a repo-scoped tool |
48
+ | `worker-limit` | The repository reached spawn.maxLiveWorkers live workers. | no | wait for a live worker to finish or raise spawn.maxLiveWorkers | spawning a fifth worker with the default limit |
49
+ | `rate-limited` | The tower_send per-minute rate limit was reached. | yes | wait for the next per-minute window or raise send.maxPerMinute | `send.maxPerMinute` exceeded |
50
+ | `takeover-refused` | The mission already has a live worker that was not stopped first. | no | run tower_interrupt for the live worker, then retry the takeover | `tower_spawn({takeover:true})` before `tower_interrupt` |
51
+ | `reload-unavailable` | The DSH HMR service is not available in this profile. | no | restart DSH instead of reloading | `tower_reload` / `/tower reload` on a profile without HMR |
52
+ | `reload-failed` | The HMR reload failed; check the DSH log and restart if needed. | no | check the DSH log and restart DSH | `reconcileProfile` threw |
53
+ | `tests-failed` | Declared test discovery failed or did not meet its admission requirements. | no | fix the failing tests, then retry tower_complete | nonzero command, malformed output, minCount/required shortfall |
54
+ | `tests-timeout` | Test discovery exceeded its time or output budget. | no | raise tests.timeoutMs or tests.maxBytes (or speed up the suite), then retry | `tests.timeoutMs`, or stdout over `tests.maxBytes` |
55
+ | `lease-busy` | Another holder owns the requested advisory lease. | no | release the lease or wait for it to expire, then retry | `tower_resource acquire` while `heavy-ci` is held |
56
+ | `lease-token` | The lease release token does not match the current holder. | no | use the token returned when the lease was acquired | releasing with another session's token |
57
+ | `batch-invalid` | The batch request or its close evidence is invalid. | no | fix the batch request or the receipt hashes, then retry | unknown member, duplicate id, unsafe receipt path, missing receipt hash |
58
+ | `legacy-invalid` | The legacy snapshot or mapping is invalid. | no | check the legacy snapshot path, schema and mapping, then retry | unknown schema, source inside the Tower state dir, mapping rewrite |
59
+ | `tool-internal` | An unexpected failure; read the message and the activity log. | no | — | a bug; read the message and the activity log |
60
+ <!-- generated:error-table:end -->
61
+
62
+ A git-mutex timeout is a `state-locked` error that names the holder. Its `details` is
63
+ `{ holderPid, holderPhase, heldMs, waitedMs }`, and the message renders the same facts as
64
+ `held by pid 1234 (merge) for 92 s; waited 60 s`. `holderPhase` is the operation that holds the lock
65
+ (`merge`, `rebase`, `init`, `teardown`, `provision`); `tower_status` and `tower_doctor` expose the same
66
+ holder as `gitLock: { held, pid, phase, heldMs }` while it is held.
55
67
 
56
68
  ## Why thrown?
57
69
 
@@ -1,15 +1,42 @@
1
1
  # Tower events
2
2
 
3
+ > Generated by `npm run docs:generate:events` from `domain/events.js`. Do not edit by hand.
4
+
3
5
  Tower emits process-local Cordis events after successful tool calls (mutations and read-only tools), so
4
- other plugins can observe a repository without polling. This release emits 29 process-local events.
5
- Events are **not** sent over the network and carry no telemetry.
6
+ other plugins can observe a repository without polling. This release defines 34 process-local events:
7
+ 5 stable domain events and 29 deprecated tool-shaped
8
+ aliases. Events are **not** sent over the network and carry no telemetry.
9
+
10
+ ## Domain events (stable)
11
+
12
+ Domain events describe what changed, not which tool ran. They are emitted only after the state write
13
+ succeeds, so `revision` is the revision a reader can load back from `state.json`; a failed tool emits
14
+ no domain event. `at` is the transition timestamp in epoch milliseconds. `state.json` stays
15
+ authoritative: these events are an observation channel, not an event-sourced log, and are never
16
+ replayed.
17
+
18
+ | Event | Payload | Emitted after |
19
+ |---|---|---|
20
+ | `tower.mission.planned` | `{ missionId, revision, at }` | tower_plan persisted one or more new missions |
21
+ | `tower.mission.completed` | `{ missionId, revision, at }` | tower_complete recorded a completion report (or tower_mission set status "completed") |
22
+ | `tower.mission.reviewed` | `{ missionId, revision, at }` | tower_review recorded a verdict for the mission branch |
23
+ | `tower.mission.merged` | `{ missionId, revision, at, commit }` | tower_merge recorded the merge commit that landed the mission branch |
24
+ | `tower.mission.abandoned` | `{ missionId, revision, at }` | a mission transitioned to "abandoned" |
6
25
 
7
26
  ```js
8
- ctx.on('tower/merge', (payload) => {
9
- // payload.tool, payload.repoRoot, payload.value (the tool's JSON result)
27
+ ctx.on('tower.mission.merged', ({ missionId, revision, at, commit }) => {
28
+ // the merge is already durable; commit is the merge commit that landed
10
29
  })
11
30
  ```
12
31
 
32
+ ## Tool-shaped aliases (deprecated)
33
+
34
+ The older `tower/<tool>` events are kept for one minor release as a **deprecated** alias for the
35
+ tool-shaped observation channel; prefer the domain events above and expect the alias to be removed in
36
+ the next minor. They carry `{ tool, repoRoot, value }`, fire after a successful tool call, and never
37
+ fail a tool when a listener throws. For `tower_merge` the alias also fires for a gate failure whose
38
+ `value.ok` is false.
39
+
13
40
  | Event | Emitted after |
14
41
  |---|---|
15
42
  | `tower/init` | `tower_init` created or re-anchored state |
@@ -38,11 +65,16 @@ ctx.on('tower/merge', (payload) => {
38
65
  | `tower/resource` | `tower_resource` acquired/released/inspected leases |
39
66
  | `tower/import_legacy` | `tower_import_legacy` stored a legacy snapshot |
40
67
  | `tower/legacy_map` | `tower_legacy_map` mapped or listed legacy ids |
41
- | `tower/mode` | `tower_mode` read/changed the session stance (`value.mode`, `allowAgentControl`, `deniedWhileActive`) |
68
+ | `tower/mode` | `tower_mode` read the session stance (`value.mode`, `allowAgentControl`, `deniedWhileActive`) |
42
69
  | `tower/control` | `tower_control` entered/left the mode (`value.mode`, actor/reason in `value`) |
43
70
  | `tower/reload` | `tower_reload` returned a reload schedule/result |
44
71
 
45
- Payloads are JSON: `{tool, repoRoot, value}`. Listener exceptions never affect the tool result.
72
+ ```js
73
+ ctx.on('tower/merge', (payload) => {
74
+ // deprecated: prefer tower.mission.merged
75
+ // payload.tool, payload.repoRoot, payload.value (the tool's JSON result)
76
+ })
77
+ ```
46
78
 
47
79
  For a durable audit trail use `<git-common-dir>/dsh-tower/activity.jsonl` and `MISSIONS.md` instead;
48
80
  events are process-local and are not replayed.
@@ -0,0 +1,128 @@
1
+ # Surface budget
2
+
3
+ The plugin measures its user-facing surface in three numbers: **config leaf keys**,
4
+ **config sections**, and **registered tools**. The committed baseline is
5
+ [`scripts/surface-baseline.json`](../../scripts/surface-baseline.json); the brake
6
+ is `npm run surface:check`; the decision record is
7
+ [ADR 0004](../adr/0004-surface-budget.md).
8
+
9
+ Decision date: 2026-10-23
10
+
11
+ Every key and tool is classified here so "why is this here" has an answer that a
12
+ check can read. `core` is a behavioural or safety contract, `tuning` is a safe
13
+ default a user may want to change, and `niche` was added for one workflow and
14
+ must carry a removal note for the next minor. A live key or tool missing from
15
+ these tables fails `npm run surface:check`.
16
+
17
+ ## Config keys
18
+
19
+ | Key | Class | Note |
20
+ |---|---|---|
21
+ | `section` | core | Legacy alias for `mode.promptSection`; changes the Tower-mode prompt contract. |
22
+ | `allowAgentControl` | core | Legacy alias for `mode.allowAgentControl`; decides whether the model may toggle the mode. |
23
+ | `init.autoGitInit` | tuning | Repository bootstrap convenience; safe default is off. |
24
+ | `init.snapshotDirty` | tuning | Baseline snapshot convenience; tracks dirty state explicitly. |
25
+ | `init.autoExclude` | tuning | Keeps generated artifacts out of user dirt without touching `.gitignore`. |
26
+ | `mode.promptSection` | core | Replaces the whole Tower-mode prompt text. |
27
+ | `mode.sectionAppend` | tuning | Appends team conventions to the prompt. |
28
+ | `mode.allowAgentControl` | core | The human-owned-mode toggle described by ADR 0003. |
29
+ | `mode.autoPauseGoal` | core | Entering the mode pauses an active goal; exit never resumes it. |
30
+ | `mode.requireResumeAuthorization` | core | Guards resuming a user-paused mission. |
31
+ | `mode.requireHumanAckOnFirstEntry` | core | First entry can require an explicit human ack. |
32
+ | `mode.workspaceLock` | core | One Tower session per repository is a safety contract. |
33
+ | `mode.workspaceLockScope` | tuning | Chooses whether read-only tools skip the workspace lock. |
34
+ | `mode.planModePolicy` | core | Entering Tower mode either clears plan mode or refuses. |
35
+ | `mode.workspaceStaleMs` | tuning | Stale window for the advisory workspace lock. |
36
+ | `mode.heartbeatMs` | tuning | Heartbeat interval while the mode is on. |
37
+ | `mode.denyTools` | core | The denied-tool list is part of the mode guard. |
38
+ | `mode.storePath` | niche | Custom mode-store location added for one deployment workflow. Removal note: derive the path from `$DSH_HOME` in the next minor; no in-tree caller sets this key. |
39
+ | `mode.maxEntries` | tuning | Bounds the persisted mode store. |
40
+ | `mode.lockTimeoutMs` | tuning | Bounded wait for the cross-process mode lock. |
41
+ | `review.requireReview` | core | Turns the review gate into a merge blocker. |
42
+ | `review.maxRoundsPerReviewer` | core | Caps review rounds; changes the review lifecycle. |
43
+ | `merge.timeoutMs` | tuning | Default merge timeout; a per-call value wins. |
44
+ | `merge.maxBufferBytes` | tuning | Captured merge output buffer. |
45
+ | `git.timeoutMs` | tuning | Default Git command timeout outside merge. |
46
+ | `worktrees.root` | tuning | Default worktree placement. |
47
+ | `spawn.workerPreset` | core | Chooses the tool surface a worker receives. |
48
+ | `spawn.reviewerPreset` | core | Chooses the read-only reviewer surface. |
49
+ | `spawn.maxDepth` | tuning | Subagent depth requested at spawn. |
50
+ | `spawn.provider` | tuning | Provider route for spawned agents. |
51
+ | `spawn.workerModel` | tuning | Optional model route for workers. |
52
+ | `spawn.reviewerModel` | tuning | Optional independent model route for reviewers. |
53
+ | `spawn.maxLiveWorkers` | core | Resource-safety cap on live workers. |
54
+ | `spawn.staleMs` | tuning | Roster staleness window before merge is blocked. |
55
+ | `spawn.maxWaitMs` | tuning | Upper bound for a blocking spawn wait. |
56
+ | `spawn.requireLease` | niche | Advisory-lease gate added for one multi-agent workflow. Removal note: fold into `tower_resource` policy in the next minor unless a second workflow uses it. |
57
+ | `send.maxPerMinute` | tuning | Message rate limit; zero disables it. |
58
+ | `send.interruptDefault` | tuning | Default for the interrupt-before-deliver flag. |
59
+ | `tests.enabled` | core | Whether declared discovery may run at all. |
60
+ | `tests.admission` | core | Chooses when repository code is executed. |
61
+ | `tests.timeoutMs` | tuning | Per-command discovery timeout. |
62
+ | `tests.maxBytes` | tuning | Captured discovery output cap. |
63
+ | `tests.autoChecks` | tuning | Attaches a tests check to completion. |
64
+ | `tests.allowPackageDiscovery` | core | Allows repository code to execute; a security contract. |
65
+ | `gates.requireChecks` | core | Requires completion evidence before merge. |
66
+ | `gates.requireTests` | core | Requires a test receipt before merge. |
67
+ | `gates.allowReviewExemption` | core | Allows a review exemption and its reason. |
68
+ | `gates.maxReportBytes` | tuning | Report size cap; the refusal itself is not configurable. |
69
+ | `docs.board` | tuning | Writes the human-readable board. |
70
+ | `docs.boardLocation` | tuning | Chooses the board location. |
71
+ | `archive.enabled` | core | Controls whether terminal missions leave the hot state. |
72
+ | `archive.afterDays` | tuning | Archive age threshold. |
73
+ | `archive.maxMissions` | tuning | Hot terminal-mission cap. |
74
+ | `tools.exposeOperational` | core | Master switch for the operational tool surface. |
75
+ | `tools.exposeBatch` | core | Registers the evidence-batch tool. |
76
+ | `tools.exposeImport` | core | Registers the legacy import tools. |
77
+ | `tools.exposeLeases` | core | Registers advisory leases. |
78
+ | `tools.exposeTests` | core | Registers test discovery; required when the test gate is on. |
79
+ | `tools.exposeRecovery` | core | Registers the stale-lock escape hatch. |
80
+ | `tools.exposeDoctor` | core | Registers the read-only health contract. |
81
+ | `leases.ttlMs` | tuning | Default advisory lease lifetime. |
82
+ | `dev.hotReload` | tuning | Registers the dev reload tool when HMR exists. |
83
+ | `dev.reloadDelayMs` | tuning | Delay so a tool result is delivered before reload. |
84
+ | `dev.reloadRequiredIds` | niche | Loader ids added for one linked-profile development setup. Removal note: derive the required ids from the bundle manifest in the next minor. |
85
+
86
+ ## Tools
87
+
88
+ Tools are `core` when the product does not work without them and `optional` when
89
+ an operational switch (`tools.expose*`) or the dev mode can legitimately remove
90
+ them from the surface.
91
+
92
+ | Tool | Class | Note |
93
+ |---|---|---|
94
+ | `tower_adopt` | core | Reconciles a restored roster with the durable catalog. |
95
+ | `tower_batch` | optional | Evidence-batch workflow; `tools.exposeBatch`. |
96
+ | `tower_complete` | core | The only completion path for a worker. |
97
+ | `tower_diff` | core | The read-only review view. |
98
+ | `tower_doctor` | optional | Read-only health contract; `tools.exposeDoctor`. |
99
+ | `tower_finding` | core | Out-of-scope issue recording. |
100
+ | `tower_findings` | core | Finding lifecycle. |
101
+ | `tower_import_legacy` | optional | Legacy migration; `tools.exposeImport`. |
102
+ | `tower_inbox` | core | Message delivery and acknowledgement. |
103
+ | `tower_init` | core | Creates the repository state. |
104
+ | `tower_interrupt` | core | Stops a runaway worker. |
105
+ | `tower_legacy_map` | optional | Legacy audit mapping; `tools.exposeImport`. |
106
+ | `tower_merge` | core | The only merge path. |
107
+ | `tower_mission` | core | Mission progress and blockers. |
108
+ | `tower_plan` | core | Mission planning. |
109
+ | `tower_preflight` | core | The read-only merge gate. |
110
+ | `tower_rebase` | core | Keeps a mission branch current. |
111
+ | `tower_recover` | optional | Stale-lock recovery; `tools.exposeRecovery`. |
112
+ | `tower_reload` | optional | Dev-only reload; registered while the HMR/dev mode is on. |
113
+ | `tower_resource` | optional | Advisory leases; `tools.exposeLeases`. |
114
+ | `tower_review` | core | Independent review verdicts. |
115
+ | `tower_roster` | core | Roster and live activity. |
116
+ | `tower_send` | core | Durable messages to agents. |
117
+ | `tower_spawn` | core | Starts workers and reviewers. |
118
+ | `tower_status` | core | The dashboard. |
119
+ | `tower_teardown` | core | Worktree cleanup and archiving. |
120
+ | `tower_test` | optional | Explicit discovery; `tools.exposeTests`. |
121
+
122
+ ## Non-goals
123
+
124
+ This is a measurement and a brake, not a cleanup epic. The numbers report the
125
+ surface; they do not decide which key is bad.
126
+
127
+ Enforced by: `scripts/surface-baseline.mjs` and `domain/surface.js`; tests in
128
+ `tests/surface.test.mjs`; gate: `npm run surface:check`.
@@ -1,6 +1,7 @@
1
1
  # Tools overview (generated)
2
2
 
3
3
  > Generated by `npm run docs:generate:tools` from the registered tool definitions. Do not edit by hand.
4
+ > Surface: config keys: 64 · sections: 15 · tools: 27 · baseline 2026-10-23. Generated from `scripts/surface-baseline.json`; do not edit by hand.
4
5
 
5
6
  | Tool | Description |
6
7
  |---|---|
@@ -8,7 +9,7 @@
8
9
  | `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
10
  | `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, capped at gates.maxReportBytes (default 32768 UTF-8 bytes); a larger report is rejected with invalid-argument and an actionable nextAction, never truncated. Persists reportBytes on the stored report. completion means ready for preflight and optional review, not merged. |
10
11
  | `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, case-sensitivity detection (caseInsensitive and the active scope comparison mode), config, presets/subagents/HMR, workspace lock, roster staleness, worktree ownership, test discovery, leases, and the terminal-mission archive (count, bytes, years, unreadable diagnostics and the configured afterDays/maxMissions policy). Returns checks with pass/warn/fail and an environment contract. Never writes state and never probes the filesystem. |
12
+ | `tower_doctor` | Read-only environment and repository health check: the concept-budget surface (config keys/sections/tools), git, state schema, case-sensitivity detection (caseInsensitive and the active scope comparison mode), config, presets/subagents/HMR, the state lock age/heartbeat freshness, the repo-scoped git lock holder (gitLock: {held,pid,phase,heldMs}), workspace lock, roster staleness, worktree ownership, test discovery, leases, and the terminal-mission archive (count, bytes, years, unreadable diagnostics and the configured afterDays/maxMissions policy). Returns checks with pass/warn/fail, state-lock and git-lock reports and an environment contract. Never writes state and never probes the filesystem. |
12
13
  | `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
14
  | `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
15
  | `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. |
@@ -21,13 +22,13 @@
21
22
  | `tower_plan` | Plan one or more missions in an initialized repository. Each mission has a printable-Unicode title (NFC-normalized, at most 120 characters), 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
23
  | `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
24
  | `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_recover` | Read-only recovery diagnostics and an explicit stale-state-lock repair. status reports the state lock owner with its age, heartbeat age and staleness; clear-stale-lock requires confirm:"CLEAR_STALE_LOCK" and only moves a lock whose PID is provably dead to a backup file. force:true additionally clears a live-but-silent PID (a recycled PID) and still renames the record to a .recovered-<ts> backup. It never resets Tower state or worktrees. |
25
26
  | `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
27
  | `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_review` | Submit an independent review verdict bound to the current branch tip: a completed mission starts a review round, and while the mission is active a later route may still record a verdict for that same reviewed tip, appending to the same round without changing the status (a moved tip is rejected with an explanation). 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
29
  | `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
30
  | `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
31
  | `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, archive count, config path and the activity tail. Pass missionId for one full mission (tasks, notes, reviews, findings, completion report; archived missions are loaded from their year file); offset/limit page the mission list (default 20, max 100); includeGate adds preflight gate digests for the page; includeArchived appends compact archived summaries after the active page; format:"markdown" returns the human-readable mission board (active missions plus an archived count). Read-only. |
32
+ | `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, archive count, config path and the activity tail. Also reports the repo-scoped git lock holder (`gitLock: {held,pid,phase,heldMs}`) so a merge holding the mutex is visible. Pass missionId for one full mission (tasks, notes, reviews, findings, completion report; archived missions are loaded from their year file); offset/limit page the mission list (default 20, max 100); includeGate adds preflight gate digests for the page; includeArchived appends compact archived summaries after the active page; format:"markdown" returns the human-readable mission board (active missions plus an archived count). Read-only. |
32
33
  | `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. Terminal missions whose worktree is gone and whose configured archive policy is due (archive.afterDays / archive.maxMissions) are moved to <git-common-dir>/dsh-tower/archive/ so state.json stops rewriting them; nothing is deleted. |
33
34
  | `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. |
@@ -34,7 +34,7 @@ findings but not mutate them. Violations throw `[tower:not-authorized]` or
34
34
  | `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`. |
35
35
  | `tower_plan` | `missions[]` (`title` — printable Unicode, NFC-normalized, ≤120 characters, `scope[]`, `tasks[]?`, `deps[]?`, `kind?`, `context?`, `references[]?` ≤20, `reviewPolicy?`, `reviewExemptionReason?`) | Create missions `M1..`; overlapping build scopes auto-add a dependency; rejects unknown deps and cycles. Mission ids stay ASCII (`M1`, `M2`); invisible characters and over-long titles are rejected with `invalid-argument`. `reviewPolicy:"none"` requires a non-empty reason; when the review gate is on, preflight also requires `gates.allowReviewExemption`. |
36
36
  | `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`. |
37
- \|\ `tower_status`\ \|\ `missionId\?`,\ `offset\?`,\ `limit\?`,\ `includeGate\?`,\ `includeArchived\?`,\ `format\?`\ \|\ Dashboard\ by\ default\.\ Paged\ missions\ carry\ `reportBytes`\ only;\ `missionId`\ returns\ full\ detail\ \(tasks/notes/reviews/findings/completionReport\ body\),\ and\ an\ archived\ mission\ is\ loaded\ from\ its\ year\ file\.\ `offset`/`limit`\ page\ active\ missions\ \(default\ 20,\ max\ 100\),\ `includeGate`\ adds\ gate\ digests,\ `includeArchived`\ appends\ compact\ archived\ summaries\ \(flagged\ `archived:true`,\ no\ report\ body\)\ after\ the\ active\ page,\ and\ `format:"markdown"`\ returns\ the\ board\ \(active\ rows\ plus\ an\ archived\ count\)\.\ Includes\ `mode`\ with\ `lastChange`\ and\ recent\ `history`\ when\ the\ host\ mode\ hook\ is\ available\.\ `archive`\ reports\ `\{count,\ years,\ unreadable,\ message}`;\ a\ corrupt\ archive\ degrades\ to\ `unreadable:true`\ instead\ of\ failing\ the\ dashboard\.\ \|
37
+ \|\ `tower_status`\ \|\ `missionId\?`,\ `offset\?`,\ `limit\?`,\ `includeGate\?`,\ `includeArchived\?`,\ `format\?`\ \|\ Dashboard\ by\ default\.\ Paged\ missions\ carry\ `reportBytes`\ only;\ `missionId`\ returns\ full\ detail\ \(tasks/notes/reviews/findings/completionReport\ body\),\ and\ an\ archived\ mission\ is\ loaded\ from\ its\ year\ file\.\ `offset`/`limit`\ page\ active\ missions\ \(default\ 20,\ max\ 100\),\ `includeGate`\ adds\ gate\ digests,\ `includeArchived`\ appends\ compact\ archived\ summaries\ \(flagged\ `archived:true`,\ no\ report\ body\)\ after\ the\ active\ page,\ and\ `format:"markdown"`\ returns\ the\ board\ \(active\ rows\ plus\ an\ archived\ count\)\.\ Includes\ `mode`\ with\ `lastChange`\ and\ recent\ `history`\ when\ the\ host\ mode\ hook\ is\ available\.\ `archive`\ reports\ `\{count,\ years,\ unreadable,\ message}`;\ a\ corrupt\ archive\ degrades\ to\ `unreadable:true`\ instead\ of\ failing\ the\ dashboard\.\ Also\ returns\ `gitLock`\ \(`\{held,\ pid,\ phase,\ heldMs}`\)\ for\ the\ repo-scoped\ git\ mutex,\ and\ `\{held:\ false}`\ when\ it\ is\ free\.\ \|
38
38
  | `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. |
39
39
  | `tower_roster` | — | Roster with live activity (`running`/`inactive`/`missing`/`unknown`) and `stale` flags from the subagent catalog. Read-only. |
40
40
  | `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. |
@@ -45,7 +45,7 @@ findings but not mutate them. Violations throw `[tower:not-authorized]` or
45
45
  | `tower_interrupt` | `to?` (roster name), `agentId?`, `reason?` | Interrupt one running roster agent via `subagents.interrupt`, record `state:"interrupted"`. Idempotent (not running → `interrupted:false`). |
46
46
  | `tower_finding` | `type`, `title`, `summary`, `details`, `suggested_fix`, `severity?`, `missionId?`, `location?` | Record an out-of-scope issue; reviewers use this instead of editing. Finds start as `open`. |
47
47
  | `tower_findings` | `id?`, `status?`, `note?`, filters (`type?`, `severity?`, `missionId?`, `open?`, `limit?`) | Lifecycle tool: list/read/transition. Statuses `open → triaged → fixed`/`wontfix`, reopen allowed. Only the tower or a worker may mutate; a reviewer can list/read. |
48
- | `tower_review` | `missionId` (alias `id`), `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). |
48
+ | `tower_review` | `missionId` (alias `id`), `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). A later route may record a verdict for the same reviewed tip while the mission is active: it appends to that round (`appended:true`) without changing the status; a verdict for a moved tip is rejected with an explanation. |
49
49
  | `tower_preflight` | `missionId` (alias `id`) | Read-only gate: returns `{ok, blockers[], branchTip, baseTip, changedFiles, review}`. |
50
50
  | `tower_merge` | `missionId` (alias `id`), `timeoutMs?` (alias `timeout_ms`) | Merge after a green gate. Records already-landed merges (`reconciled`), recovers a failed call whose commit exists (`recoveredFromFailure`), and separates `merge-hook-failed` from `merge-conflict`. |
51
51
  | `tower_rebase` | `missionId` (alias `id`) | Rebase the branch onto the base in its worktree. Refuses while the worker runs or the worktree is dirty. Already-contained branches return `alreadyMerged` untouched. |
@@ -53,8 +53,8 @@ findings but not mutate them. Violations throw `[tower:not-authorized]` or
53
53
  | `tower_mode` | — | Report mode, last change, paused goal, denied tools, store path, plugin version, HMR availability, workspace-lock holder. |
54
54
  | `tower_control` | `action` (`enter`/`exit`), `reason` | Model-initiated mode change; registered by default; disable with `mode.allowAgentControl:false`; reason is mandatory. |
55
55
  | `tower_reload` | `repoRoot?` | Dev-only: ask DSH HMR to reconcile the profile and reload the Host plugin from disk. Registered while `dev.hotReload` is on; scheduled after the result (default 50 ms); fails with `reload-unavailable` without HMR. When `repoRoot` is supplied the caller must be the tower. |
56
- | `tower_doctor` | — (takes `repoRoot`) | Read-only health contract: git, state schema, config, presets/subagents/HMR, workspace lock, roster, worktree realpath/ownership, test config, leases and the terminal-mission archive. Returns `{ok, checks[{id,status,detail}], stateSchema, archive, environmentContract}`; `archive` reports `{missions, archivedMissions, stateBytes, archiveBytes, years, files, unreadable, policy{enabled,afterDays,maxMissions}}`. A missing archive directory is healthy; a corrupt file is a `warn` with its diagnostic. Never writes. |
57
- | `tower_recover` | `action` (`status`/`clear-stale-lock`), `confirm?` | Recovery diagnostics plus an explicit stale state-lock repair. `clear-stale-lock` requires `confirm:"CLEAR_STALE_LOCK"` and only moves a lock whose PID is provably dead to a backup; it never resets state/worktrees. |
56
+ | `tower_doctor` | — (takes `repoRoot`) | Read-only health contract: the concept-budget surface (`config keys: N · sections: N · tools: N`), git, state schema, config, presets/subagents/HMR, the state lock's age and heartbeat freshness, the repo-scoped git lock holder, workspace lock, roster, worktree realpath/ownership, test config, leases and the terminal-mission archive. Returns `{ok, checks[{id,status,detail}], surface, stateLock, gitLock, stateSchema, archive, environmentContract}`; `surface` reports `{configKeys, sections, tools, baselineDate, line}` so growth against `scripts/surface-baseline.json` is visible in the output, `stateLock` reports `{held, pid, startedAt, at, alive, ageMs, heartbeatAgeMs, staleAfterMs, stale}` so a stuck tower is diagnosable without reading the file, and `gitLock` reports `{held, pid, phase, heldMs}` (`{held:false}` when free) with a `git-lock` check. `archive` reports `{missions, archivedMissions, stateBytes, archiveBytes, years, files, unreadable, policy{enabled,afterDays,maxMissions}}`. A missing archive directory is healthy; a corrupt file is a `warn` with its diagnostic. Never writes. |
57
+ | `tower_recover` | `action` (`status`/`clear-stale-lock`), `confirm?`, `force?` | Recovery diagnostics plus an explicit stale state-lock repair. `clear-stale-lock` requires `confirm:"CLEAR_STALE_LOCK"` and only moves a lock whose PID is provably dead to a `.recovered-<ts>` backup. `force:true` additionally clears a live-but-silent PID (a recycled PID): the output names `{pid, startedAt, ageMs, heartbeatAgeMs}` and the record is still renamed, never unlinked. It never resets state/worktrees. |
58
58
  | `tower_test` | `missionId` (alias `id`) | Run the worktree's `package.json` `tower.testDiscovery` and record a receipt; `ok:false` on failure. Use `tower_complete` for automatic admission checks. |
59
59
  | `tower_batch` | `action` (`create`/`status`/`close`/`abandon`), `missionIds?`, `exclusions?`, `requiredChecks?`, `receiptPaths?`, `batchId?`, `checks?`, `treeTip?`, `receiptHashes?`, `reason?` | Finite evidence batch. Create freezes members/checks/receipts; close verifies every required check and receipt hash; merged members are eligible; abandon retires a stale open batch without deleting missions. Membership is immutable. |
60
60
  | `tower_resource` | `action` (`acquire`/`release`/`status`), `name?`, `holder?`, `token?`, `ttlMs?`, `force?` | Advisory per-repository lease in `<common>/dsh-tower/leases.json`. No OS CPU/memory hard cap; expired leases require explicit `force` and the correct token. |
@@ -82,7 +82,7 @@ Windows 上通常是 `C:\Users\<you>\.dsh`):
82
82
 
83
83
  ## 编辑循环
84
84
 
85
- 1. 编辑 `lib/*.js`、`index.js`、`worker.js`、`reviewer.js` 或 `skills.js`。
85
+ 1. 编辑 `host/*.js`、`adapters/*.js`、`app/*.js`、`domain/**/*.js`、`index.js`、`worker.js`、`reviewer.js` 或 `skills.js`。
86
86
  2. 保存文件。监视器会去抖(DSH 默认 100 ms)并对账 profile;变更的模块被替换,受影响的 Loader
87
87
  条目重启。
88
88
  3. 观察 DSH 日志中的 `reload plugin at <path>` / `restart required: …` 消息。
@@ -19,10 +19,10 @@
19
19
  | `read-only-survey` | survey mission 修改了文件;survey mission 必须只读。 |
20
20
  | `out-of-scope` | 改动文件超出 mission scope;有意为之就扩大 scope,否则回退。在大小写敏感的检出上,仅大小写不同的路径仍会被拒绝,detail 会指出折叠大小写后本可匹配的 scope 条目。 |
21
21
  | `base-dirty` | 主检出存在未提交改动,合并会覆盖它们。 |
22
- | `deps-unmerged` | 依赖 mission 尚未 merged/abandoned。 |
22
+ | `deps-unmerged` | 依赖 mission 尚未满足:build 依赖须 `merged`/`abandoned`;survey 依赖在 `completed` 即视为满足(含归档记录)。 |
23
23
  | `no-review` | `review.requireReview:true` 且分支上没有审查:spawn reviewer(或关闭审查门禁)。 |
24
24
  | `review-mission-mismatch` | `review.requireReview:true` 且最新审查属于另一个 mission。 |
25
- | `not-clean` | `review.requireReview:true` 且最新审查是 `p1-Nitems`/`p2-Nitems`;需要再来一轮。 |
25
+ | `not-clean` | `review.requireReview:true` 且最新审查是 `p1-Nitems`/`p2-Nitems`;需要再来一轮(同一轮的各 route 结论会追加记录,不会让状态反复翻转)。 |
26
26
  | `review-hold` | `review.requireReview:true` 且最新审查建议 hold;必须由后续的干净审查取代。 |
27
27
  | `review-not-approved` | `review.requireReview:true` 且最新干净审查给出的是 `fix-then-merge` 或 `hold`;只有 `merge` 才算批准。 |
28
28
  | `tip-moved` | `review.requireReview:true` 且审查之后分支发生变化;重新审查(或使用干净 rebase 的免审豁免)。 |
@@ -11,6 +11,23 @@
11
11
  { "id": "git", "status": "pass", "detail": "git version 2.55.0" },
12
12
  { "id": "state", "status": "pass", "detail": ".../state.json revision 7" }
13
13
  ],
14
+ "stateLock": {
15
+ "held": true,
16
+ "pid": 4242,
17
+ "startedAt": 1761280000000,
18
+ "at": 1761280120000,
19
+ "alive": true,
20
+ "ageMs": 121000,
21
+ "heartbeatAgeMs": 1000,
22
+ "staleAfterMs": 3000,
23
+ "stale": false
24
+ },
25
+ "gitLock": {
26
+ "held": true,
27
+ "pid": 4242,
28
+ "phase": "merge",
29
+ "heldMs": 92000
30
+ },
14
31
  "archive": {
15
32
  "missions": 3,
16
33
  "archivedMissions": 12,
@@ -33,6 +50,17 @@
33
50
  }
34
51
  ```
35
52
 
53
+ `stateLock`(以及 `state-lock` 检查)报告锁的年龄(`ageMs`,从记录的 `since` 起算)与心跳新鲜度
54
+ (`heartbeatAgeMs`,从最后一次 `at` 起算),并对照过期窗口(`staleAfterMs = 3 × 1000 ms`)。
55
+ PID 存活并不等于写者存活:停止心跳的持有者会报告 `stale: true`,下一次 mutation 会回收它(或用
56
+ `tower_recover clear-stale-lock force:true` 移到备份);心跳新鲜则报告为持有中,绝不会被抢走。
57
+ 这样无需手工读取 `state.lock` 就能诊断 PID 被复用的情况。
58
+
59
+ `gitLock`(以及 `git-lock` 检查)报告仓库级 git 互斥锁:被 merge/rebase/init/teardown/provision 持有时为
60
+ `{held, pid, phase, heldMs}`,空闲时为 `{held: false}`。merge 可以合法持有到配置的 merge 超时(默认 15 分钟),
61
+ 因此“被持有”是 `warn` 而不是失败;持有者的阶段与时长让卡住的控制塔可被诊断。`tower_status` 返回同一个
62
+ `gitLock` 对象。
63
+
36
64
  `archive` 报告终态任务归档:活跃 `missions`、`archivedMissions` 与 `stateBytes`,归档目录的
37
65
  `archiveBytes`/`years`/`files`,每条 `unreadable` 诊断,以及配置的 `policy`(`enabled`、`afterDays`、
38
66
  `maxMissions`)。归档目录不存在是健康的空归档(`archive` 检查为 `pass`);截断的 index/year 文件是
@@ -51,7 +79,7 @@ test-discovery 策略、完成报告大小、归档健康度、旧版快照、ba
51
79
  使用安全的 `case-sensitive` 默认值。
52
80
 
53
81
  检查覆盖:git 可用性、仓库身份、state schema/加载、已探测的大小写模式、仓库配置是否存在、
54
- worker/reviewer preset 名称、subagents 与 HMR 服务、workspace 锁持有者/是否过期、实时 roster、
82
+ worker/reviewer preset 名称、subagents 与 HMR 服务、state 锁年龄/心跳、workspace 锁持有者/是否过期、实时 roster、
55
83
  worktree 是否存在/归属/是否脏、test-discovery 策略、旧版快照、batch 与租约。被 `tower_teardown`
56
84
  正常清理的 mission worktree 会被跳过(检查中会注明已移除数量),不会被误报为 missing。`warn` 是提示;`fail` 表示仓库在 spawn/merge 前需要
57
85
  处理。state 加载失败会作为失败的检查报告而不是抛出,因此 doctor 在损坏的仓库上仍然有用。
@@ -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`——可打印 Unicode,NFC 规范化,≤120 字符、`scope[]`、`tasks[]?`、`deps[]?`、`kind?`、`context?`、`references[]?` ≤20、`reviewPolicy?`、`reviewExemptionReason?`) | 创建 mission `M1..`;重叠的 build scope 会自动加依赖;拒绝未知依赖与依赖循环。mission id 保持 ASCII(`M1`、`M2`);不可见字符与超长标题会以 `invalid-argument` 拒绝。`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?` | 默认返回面板。分页 mission 只带 `reportBytes`;`missionId` 返回完整详情(任务/备注/审查/finding/完成报告正文)。`offset`/`limit` 对 mission 分页(默认 20,最大 100),`includeGate` 附带门禁摘要,`format:"markdown"` 返回看板。host 提供 mode hook 时还会包含 `mode.lastChange` 与最近 `history`。 |
37
+ | `tower_status` | `missionId?`、`offset?`、`limit?`、`includeGate?`、`format?` | 默认返回面板。分页 mission 只带 `reportBytes`;`missionId` 返回完整详情(任务/备注/审查/finding/完成报告正文)。`offset`/`limit` 对 mission 分页(默认 20,最大 100),`includeGate` 附带门禁摘要,`format:"markdown"` 返回看板。host 提供 mode hook 时还会包含 `mode.lastChange` 与最近 `history`。还返回仓库级 git 互斥锁 `gitLock`(`{held, pid, phase, heldMs}`,空闲时 `{held:false}`)。 |
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`)以便检查/接管。 |
@@ -45,7 +45,7 @@ roster 角色由 `exec.agent` 与 roster 共同决定。mission 级工具(`tow
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 只能列出/读取。 |
48
- | `tower_review` | `missionId`(别名 `id`)、`status`(`clean`/`p1-Nitems`/`p2-Nitems`)、`merge`(`merge`/`fix-then-merge`/`hold`)、`findings`、`decision`、`checks?`、`breakGlassReason?` | 绑定当前 tip 的 reviewer 结论。reviewer 必须是获派的 reviewer(或由控制塔使用 break-glass)。 |
48
+ | `tower_review` | `missionId`(别名 `id`)、`status`(`clean`/`p1-Nitems`/`p2-Nitems`)、`merge`(`merge`/`fix-then-merge`/`hold`)、`findings`、`decision`、`checks?`、`breakGlassReason?` | 绑定当前 tip 的 reviewer 结论。reviewer 必须是获派的 reviewer(或由控制塔使用 break-glass)。非 clean 结论使 mission 回到 active 后,其他 route 仍可为同一 reviewed tip 提交结论:追加到同一轮(`appended:true`),不再改变状态;tip 已移动时会被明确拒绝。 |
49
49
  | `tower_preflight` | `missionId`(别名 `id`) | 只读门禁:返回 `{ok, blockers[], branchTip, baseTip, changedFiles, review}`。 |
50
50
  | `tower_merge` | `missionId`(别名 `id`)、`timeoutMs?`(别名 `timeout_ms`) | 门禁全绿后合并。若合并提交已落地则记录(`reconciled`);若命令失败但提交已存在则恢复(`recoveredFromFailure`);区分 `merge-hook-failed` 与 `merge-conflict`。 |
51
51
  | `tower_rebase` | `missionId`(别名 `id`) | 在 worktree 内把分支 rebase 到基线。worker 运行中或 worktree 脏时拒绝。分支已包含在基线中时返回 `alreadyMerged` 且保持分支不动。 |
@@ -53,8 +53,8 @@ roster 角色由 `exec.agent` 与 roster 共同决定。mission 级工具(`tow
53
53
  | `tower_mode` | — | 报告模式、最近修改、被暂停的 goal、被拒绝的工具、store 路径、插件版本、HMR 可用性、workspace 锁持有者。 |
54
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
- | `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。 |
56
+ | `tower_doctor` | —(需要 `repoRoot`) | 只读健康契约:git、state schema、config、preset/subagents/HMR、state 锁的年龄与心跳新鲜度、仓库级 git 锁持有者、workspace 锁、roster、worktree realpath/归属、测试配置、leases。返回 `{ok, checks[{id,status,detail}], stateLock, gitLock, environmentContract}`;`stateLock` 报告 `{held, pid, startedAt, at, alive, ageMs, heartbeatAgeMs, staleAfterMs, stale}`,`gitLock` 报告 `{held, pid, phase, heldMs}`(空闲时 `{held:false}`)并附带 `git-lock` 检查,无需读取文件即可诊断卡住的控制塔。从不写状态。 |
57
+ | `tower_recover` | `action`(`status`/`clear-stale-lock`)、`confirm?`、`force?` | 恢复诊断与显式 stale state-lock 修复。`clear-stale-lock` 需要 `confirm:"CLEAR_STALE_LOCK"`,只把 PID 已死的锁改名为 `.recovered-<ts>` 备份。`force:true` 还能清理 PID 仍存活但心跳静默(PID 被复用)的锁:输出 `{pid, startedAt, ageMs, heartbeatAgeMs}`,记录仍只改名、绝不 unlink。不会重置状态或 worktree。 |
58
58
  | `tower_test` | `missionId`(别名 `id`) | 运行 worktree 的 `package.json` 中 `tower.testDiscovery` 配置的发现命令并记录回执;失败时 `ok:false`。自动准入检查请用 `tower_complete`。 |
59
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。成员关系不可变。 |
60
60
  | `tower_resource` | `action`(`acquire`/`release`/`status`)、`name?`、`holder?`、`token?`、`ttlMs?`、`force?` | 每个仓库的咨询式租约,位于 `<common>/dsh-tower/leases.json`。没有 OS CPU/内存硬上限;过期的租约需要显式 `force` 与正确 token。 |
package/domain/batch.js CHANGED
@@ -120,7 +120,7 @@ function normalizeReceiptPathOrNull(value, where) {
120
120
  }
121
121
  }
122
122
 
123
- /** Legacy snapshots imported by lib/legacy.js; only paused missions may be excluded. */
123
+ /** Legacy snapshots imported by adapters/legacy.js; only paused missions may be excluded. */
124
124
  function pausedLegacyIds(state) {
125
125
  const ids = new Set()
126
126
  const collectMission = (mission) => {
@@ -138,7 +138,7 @@ function pausedLegacyIds(state) {
138
138
  collectList(snapshot.missions)
139
139
  if (isPlainObject(snapshot.snapshot)) collectList(snapshot.snapshot.missions)
140
140
  if (isPlainObject(snapshot.payload)) collectList(snapshot.payload.missions)
141
- // lib/legacy.js stores imported snapshots with `pausedMissionIds`; older,
141
+ // adapters/legacy.js stores imported snapshots with `pausedMissionIds`; older,
142
142
  // hand-written fixtures may use `pausedIds`.
143
143
  for (const key of ['pausedIds', 'pausedMissionIds']) {
144
144
  if (Array.isArray(snapshot[key])) {
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // This module is pure: it declares the Schemastery schema, validates raw config
4
4
  // objects, and materializes defaults. Profile and repository loading (fs and the
5
- // process-local profile snapshot) live in lib/config.js, which re-exports
5
+ // process-local profile snapshot) live in host/config.js, which re-exports
6
6
  // `Config` and `resolveTowerConfig` for compatibility.
7
7
  //
8
8
  // Layers, lowest precedence first:
@@ -290,6 +290,30 @@ export const Config = Schema.object({
290
290
  .description('Development and hot-reload behavior.'),
291
291
  }).description('DSH Tower configuration')
292
292
 
293
+ /**
294
+ * The user-facing config surface (#84): every leaf key one level deep plus the
295
+ * names of the object sections. The generated config reference, `tower_doctor`
296
+ * and the surface baseline all count this same shape, so "how many concepts"
297
+ * is one number rather than three near-identical ones.
298
+ *
299
+ * @param {any} [config] the Schemastery root (defaults to the live `Config`)
300
+ * @returns {{ keys: string[], sections: string[], count: number }}
301
+ */
302
+ export function configSurface(config = Config) {
303
+ const keys = []
304
+ const sections = []
305
+ for (const [key, schema] of Object.entries(config?.dict ?? {})) {
306
+ const dict = schema?.dict
307
+ if (dict && typeof dict === 'object') {
308
+ sections.push(key)
309
+ for (const child of Object.keys(dict)) keys.push(`${key}.${child}`)
310
+ } else {
311
+ keys.push(key)
312
+ }
313
+ }
314
+ return { keys, sections, count: keys.length }
315
+ }
316
+
293
317
  export function clone(value) {
294
318
  return value === undefined ? undefined : JSON.parse(JSON.stringify(value))
295
319
  }