dsh-tower 2.1.0-beta.1 → 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 (188) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +71 -5
  4. package/PRINCIPLES.md +145 -0
  5. package/README.en.md +14 -5
  6. package/README.md +14 -5
  7. package/SECURITY.md +6 -0
  8. package/adapters/archive.js +279 -0
  9. package/adapters/caseSensitivity.js +66 -0
  10. package/adapters/default.js +51 -8
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +15 -6
  13. package/{lib → adapters}/legacy.js +20 -6
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/stateFile.js +99 -0
  16. package/adapters/store-fs.js +28 -1
  17. package/adapters/store.js +204 -0
  18. package/{lib → adapters}/tests.js +15 -7
  19. package/adapters/vcs-git.js +15 -1
  20. package/{lib → adapters}/workspaceLock.js +1 -1
  21. package/{lib → adapters}/worktreeOwnership.js +18 -7
  22. package/app/archive.js +188 -0
  23. package/app/batch.js +9 -10
  24. package/app/board.js +4 -4
  25. package/app/checkCompletion.js +23 -11
  26. package/app/enrichedMissions.js +2 -4
  27. package/app/findings.js +6 -7
  28. package/app/gate.js +32 -15
  29. package/app/init.js +59 -22
  30. package/app/merge.js +64 -51
  31. package/app/messages.js +7 -8
  32. package/app/mission.js +7 -5
  33. package/app/plan.js +20 -9
  34. package/app/provision.js +12 -14
  35. package/app/read.js +65 -6
  36. package/app/rebase.js +14 -16
  37. package/app/recording.js +7 -8
  38. package/app/review.js +17 -8
  39. package/app/roster.js +14 -15
  40. package/{lib → app}/spawn-prechecks.js +4 -3
  41. package/app/spawn.js +12 -13
  42. package/app/teardown.js +28 -8
  43. package/app/tower.js +121 -39
  44. package/docs/README.md +6 -2
  45. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  46. package/docs/adr/0002-single-state-json.md +50 -0
  47. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  48. package/docs/adr/0004-surface-budget.md +50 -0
  49. package/docs/adr/README.md +51 -0
  50. package/docs/agents/domain.md +38 -0
  51. package/docs/agents/issue-tracker.md +46 -0
  52. package/docs/agents/triage-labels.md +15 -0
  53. package/docs/agents/workflow.md +51 -0
  54. package/docs/architecture/overview.md +170 -37
  55. package/docs/guide/hot-reload.md +1 -1
  56. package/docs/guide/install-upgrade-migration.md +18 -1
  57. package/docs/guide/release.md +1 -1
  58. package/docs/operations/incident.md +82 -0
  59. package/docs/operations/runbook.md +12 -3
  60. package/docs/reference/blockers.md +3 -3
  61. package/docs/reference/config.generated.md +5 -0
  62. package/docs/reference/config.md +4 -0
  63. package/docs/reference/doctor.md +104 -8
  64. package/docs/reference/errors.generated.md +41 -35
  65. package/docs/reference/errors.md +54 -40
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +128 -0
  68. package/docs/reference/tools.generated.md +9 -8
  69. package/docs/reference/tools.md +9 -8
  70. package/docs/zh/guide/hot-reload.md +1 -1
  71. package/docs/zh/guide/install-upgrade-migration.md +13 -1
  72. package/docs/zh/reference/blockers.md +3 -3
  73. package/docs/zh/reference/config.md +4 -0
  74. package/docs/zh/reference/doctor.md +63 -2
  75. package/docs/zh/reference/errors.md +4 -2
  76. package/docs/zh/reference/tools.md +6 -6
  77. package/domain/batch.js +2 -2
  78. package/domain/config/schema.js +51 -4
  79. package/domain/contracts/outputs.js +33 -3
  80. package/domain/contracts/tool-inputs/missions.js +2 -1
  81. package/domain/contracts/tool-inputs/ops.js +1 -0
  82. package/domain/errors.js +297 -40
  83. package/domain/events.js +207 -0
  84. package/domain/integration/gate.js +41 -12
  85. package/domain/integration/init.js +40 -4
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +1 -1
  89. package/domain/missions/completion.js +72 -5
  90. package/domain/missions/lifecycle.js +11 -4
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/planning.js +6 -3
  94. package/domain/read/views.js +17 -1
  95. package/domain/report.js +9 -1
  96. package/domain/scope.js +83 -11
  97. package/domain/state/archive.js +257 -0
  98. package/domain/state/migrations.js +145 -0
  99. package/domain/state/schema.js +6 -0
  100. package/domain/state.js +154 -4
  101. package/domain/summary.js +2 -0
  102. package/domain/surface.js +248 -0
  103. package/host/doctor.js +555 -0
  104. package/{lib → host}/modeStore.js +1 -1
  105. package/{lib → host}/tool-registry.js +106 -31
  106. package/host/tower.js +32 -0
  107. package/index.js +12 -9
  108. package/package.json +10 -3
  109. package/ports/archive.js +18 -0
  110. package/ports/caseSensitivity.js +15 -0
  111. package/ports/clock.js +10 -2
  112. package/ports/config.js +19 -0
  113. package/ports/contract.js +38 -0
  114. package/ports/events.js +24 -0
  115. package/ports/ids.js +10 -2
  116. package/ports/index.js +54 -2
  117. package/ports/ownership.js +17 -0
  118. package/ports/state.js +16 -0
  119. package/ports/store.js +52 -3
  120. package/ports/vcs.js +25 -4
  121. package/scripts/architecture-waivers.json +19 -0
  122. package/scripts/architecture.mjs +634 -23
  123. package/scripts/check-docs.mjs +8 -35
  124. package/scripts/check-pack.mjs +68 -39
  125. package/scripts/check-philosophy.mjs +266 -0
  126. package/scripts/domain-metrics.mjs +13 -4
  127. package/scripts/generate-config-reference.mjs +14 -1
  128. package/scripts/generate-error-reference.mjs +68 -14
  129. package/scripts/generate-events-reference.mjs +83 -0
  130. package/scripts/generate-tools-reference.mjs +11 -0
  131. package/scripts/issue-audit.mjs +2 -2
  132. package/scripts/lib/surface.mjs +87 -0
  133. package/scripts/pack-manifest.txt +64 -19
  134. package/scripts/regression-audit.mjs +164 -0
  135. package/scripts/release-check.mjs +1 -1
  136. package/scripts/surface-baseline.json +21 -0
  137. package/scripts/surface-baseline.mjs +120 -0
  138. package/tools/adopt.js +2 -3
  139. package/tools/batch.js +2 -3
  140. package/tools/complete.js +3 -4
  141. package/tools/diff.js +3 -3
  142. package/tools/doctor.js +7 -10
  143. package/tools/finding.js +2 -3
  144. package/tools/findings.js +3 -3
  145. package/tools/import-legacy.js +3 -5
  146. package/tools/inbox.js +3 -3
  147. package/tools/index.js +7 -7
  148. package/tools/init.js +4 -7
  149. package/tools/interrupt.js +2 -3
  150. package/tools/legacy-map.js +3 -5
  151. package/tools/merge.js +3 -3
  152. package/tools/mission.js +2 -3
  153. package/tools/plan.js +7 -6
  154. package/tools/preflight.js +3 -3
  155. package/tools/rebase.js +3 -3
  156. package/tools/recover.js +38 -13
  157. package/tools/reload.js +3 -6
  158. package/tools/resource.js +3 -5
  159. package/tools/review.js +3 -4
  160. package/tools/roster.js +2 -3
  161. package/tools/send.js +2 -2
  162. package/tools/spawn.js +8 -5
  163. package/tools/status.js +16 -8
  164. package/tools/teardown.js +3 -4
  165. package/tools/test.js +2 -2
  166. package/types/index.d.ts +35 -1
  167. package/ui-bundle/README.md +37 -3
  168. package/ui-bundle/client.cards.js +356 -0
  169. package/ui-bundle/client.denied.js +142 -0
  170. package/ui-bundle/client.dock.js +333 -0
  171. package/ui-bundle/client.helpers.js +271 -0
  172. package/ui-bundle/client.js +79 -1747
  173. package/ui-bundle/client.locales.js +247 -0
  174. package/ui-bundle/client.mode.js +206 -0
  175. package/ui-bundle/client.overlay.js +64 -0
  176. package/ui-bundle/client.styles.js +151 -0
  177. package/ui-bundle/client.toolview.js +82 -0
  178. package/ui-bundle/package.json +1 -0
  179. package/lib/doctor.js +0 -270
  180. package/lib/persistence.js +0 -349
  181. package/lib/store.js +0 -118
  182. /package/{lib → app}/briefing.js +0 -0
  183. /package/{lib → app}/spawn-support.js +0 -0
  184. /package/{lib → host}/compatibility.js +0 -0
  185. /package/{lib → host}/config.js +0 -0
  186. /package/{lib → host}/hmr.js +0 -0
  187. /package/{lib → host}/modeState.js +0 -0
  188. /package/{lib → host}/presets.js +0 -0
package/domain/errors.js CHANGED
@@ -3,8 +3,24 @@
3
3
  // Everything a Tower tool throws reaches the model as
4
4
  // [tower:<code>] <original message>
5
5
  // so callers (and tests) can branch on the code instead of matching prose.
6
+ // The catalog also carries the recovery metadata the model can act on:
7
+ //
8
+ // - `retryable` — the same input may succeed later without a change;
9
+ // - `nextAction` — the concrete recovery step, rendered once as a trailing
10
+ // ` — next: …` segment (plus ` (retryable)` when retryable);
11
+ // - `trigger` — the typical condition, documented in errors.md.
12
+ //
13
+ // Why the message suffix rather than a structured error payload: the DSH tool
14
+ // contract declares output schemas for successful values only, and a thrown
15
+ // error reaches the model as its rendered message. There is no separate
16
+ // structured error channel to return `{ code, retryable, nextAction }` through,
17
+ // so the suffix is the model-visible channel; the error object still carries
18
+ // `code`, `retryable`, `nextAction` and `details` for programmatic callers.
19
+ // Codes without a recovery step render byte-identically to the pre-#74 form.
20
+ //
6
21
  // The catalog is documented in docs/reference/errors.md; scripts/check-docs.mjs
7
- // keeps the two in sync.
22
+ // and scripts/generate-error-reference.mjs keep the two in sync.
23
+ // @ts-check
8
24
 
9
25
  /** Neutral error type shared by the git adapter and the domain classifier. */
10
26
  export class GitError extends Error {
@@ -18,56 +34,295 @@ export class GitError extends Error {
18
34
  }
19
35
  }
20
36
 
37
+ /**
38
+ * @typedef {object} ErrorCodeEntry
39
+ * @property {string} description
40
+ * @property {string} trigger
41
+ * @property {boolean} retryable
42
+ * @property {string | null} nextAction
43
+ */
44
+
45
+ /**
46
+ * One stable entry per failure family. `description` and `trigger` are the
47
+ * documented meaning; `retryable`/`nextAction` are the recovery contract the
48
+ * tool boundary renders.
49
+ * @type {Record<string, ErrorCodeEntry>}
50
+ */
21
51
  export const ERROR_CODES = {
22
- 'invalid-argument': 'A tool argument is missing, malformed, or not allowed.',
23
- 'repo-invalid': 'repoRoot is missing, relative, or not inside a Git repository.',
24
- 'not-initialized': 'Tower is not initialized for this repository; run tower_init.',
25
- 'mission-not-found': 'No mission with that id exists.',
26
- 'finding-not-found': 'No finding with that id exists.',
27
- 'invalid-transition': 'The requested state transition is not allowed.',
28
- 'stale-base': 'The repository state changed while the operation was being prepared; retry.',
29
- 'state-locked': 'Another Tower writer holds the state lock.',
30
- 'state-corrupt': 'The Tower state file is unreadable or invalid.',
31
- 'config-invalid': 'Tower configuration is invalid.',
32
- 'config-unknown-key': 'Tower configuration has an unknown top-level key.',
33
- 'git-failed': 'A git command failed.',
34
- 'git-timeout': 'A git command timed out or exceeded its output buffer.',
35
- 'worktree-dirty': 'The mission worktree has uncommitted changes and cannot complete.',
36
- 'spawn-unavailable': 'The subagents service is unavailable.',
37
- 'provider-missing': 'No subagent provider is registered.',
38
- 'provider-cooldown': 'The provider is throttled; spawn is in a short cooldown.',
39
- 'preset-missing': 'The configured agent preset is not registered.',
40
- 'reviewer-prerequisite': 'The reviewer spawn prerequisite is not met.',
41
- 'not-authorized': 'The calling agent role may not perform this action.',
42
- 'workspace-busy': 'Another Tower session owns this repository workspace lock.',
43
- 'worker-limit': 'The repository reached spawn.maxLiveWorkers live workers.',
44
- 'rate-limited': 'The tower_send per-minute rate limit was reached.',
45
- 'takeover-refused': 'The mission already has a live worker that was not stopped first.',
46
- 'reload-unavailable': 'The DSH HMR service is not available in this profile.',
47
- 'reload-failed': 'The HMR reload failed; check the DSH log and restart if needed.',
48
- 'tests-failed': 'Declared test discovery failed or did not meet its admission requirements.',
49
- 'tests-timeout': 'Test discovery exceeded its time or output budget.',
50
- 'lease-busy': 'Another holder owns the requested advisory lease.',
51
- 'lease-token': 'The lease release token does not match the current holder.',
52
- 'batch-invalid': 'The batch request or its close evidence is invalid.',
53
- 'legacy-invalid': 'The legacy snapshot or mapping is invalid.',
54
- 'tool-internal': 'An unexpected failure; read the message and the activity log.',
52
+ 'invalid-argument': {
53
+ description: 'A tool argument is missing, malformed, or not allowed.',
54
+ trigger: '`missionId`/`id` omitted; unknown status; empty report; bad scope glob; `timeoutMs < 1000`; `status: "all"` on an update.',
55
+ retryable: false,
56
+ nextAction: null,
57
+ },
58
+ 'repo-invalid': {
59
+ description: 'repoRoot is missing, relative, or not inside a Git repository.',
60
+ trigger: 'passing a path in a directory that is not a repo',
61
+ retryable: false,
62
+ nextAction: 'pass an absolute path to a directory inside a Git repository',
63
+ },
64
+ 'not-initialized': {
65
+ description: 'Tower is not initialized for this repository; run tower_init.',
66
+ trigger: 'a read tool before `tower_init`',
67
+ retryable: false,
68
+ nextAction: 'run tower_init for this repository',
69
+ },
70
+ 'mission-not-found': {
71
+ description: 'No mission with that id exists.',
72
+ trigger: 'a stale or mistyped `M9`',
73
+ retryable: false,
74
+ nextAction: 'list missions with tower_status',
75
+ },
76
+ 'finding-not-found': {
77
+ description: 'No finding with that id exists.',
78
+ trigger: '`tower_findings({id:"F99"})`',
79
+ retryable: false,
80
+ nextAction: 'list findings with tower_findings',
81
+ },
82
+ 'invalid-transition': {
83
+ description: 'The requested state transition is not allowed.',
84
+ trigger: '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.',
85
+ retryable: false,
86
+ nextAction: null,
87
+ },
88
+ 'stale-base': {
89
+ description: 'The repository state changed while the operation was being prepared; retry.',
90
+ trigger: 'two-phase mutation revalidation',
91
+ retryable: true,
92
+ nextAction: 'retry the tool; if it keeps failing, run tower_preflight and inspect tower_status',
93
+ },
94
+ 'state-locked': {
95
+ description: 'Another Tower writer holds the state lock.',
96
+ trigger: 'concurrent mutation after the lock timeout',
97
+ retryable: true,
98
+ nextAction: 'wait for the other Tower writer to finish, then retry',
99
+ },
100
+ 'state-corrupt': {
101
+ description: 'The Tower state file is unreadable or invalid.',
102
+ trigger: 'hand-edited `state.json`; invalid JSON; no usable `schemaVersion`',
103
+ retryable: false,
104
+ nextAction: 'restore state.json from a backup, or re-initialize after filing the corruption',
105
+ },
106
+ 'state-version-unsupported': {
107
+ description: 'The Tower state file uses a schema version this build cannot read or downgrade.',
108
+ trigger: 'opening a repository written by a newer plugin; an unknown older version; this build refuses to downgrade',
109
+ retryable: false,
110
+ nextAction: 'upgrade dsh-tower to a release that supports this state schemaVersion',
111
+ },
112
+ 'state-migration-failed': {
113
+ description: 'A Tower state migration failed; the original state file was not modified.',
114
+ trigger: 'a bug in a migration step; see the pre-migration `state.json.v<N>.bak`',
115
+ retryable: false,
116
+ nextAction: 'restore the pre-migration backup state.json.v<N>.bak and report the migration bug',
117
+ },
118
+ 'config-invalid': {
119
+ description: 'Tower configuration is invalid.',
120
+ trigger: 'malformed repo `config.json`, a value that violates the schema',
121
+ retryable: false,
122
+ nextAction: 'fix the repository config file, then retry',
123
+ },
124
+ 'config-unknown-key': {
125
+ description: 'Tower configuration has an unknown top-level key.',
126
+ trigger: 'a typo such as `allowAgentToggle`',
127
+ retryable: false,
128
+ nextAction: 'remove the unknown key from the repository config file, then retry',
129
+ },
130
+ 'git-failed': {
131
+ description: 'A git command failed.',
132
+ trigger: 'a rejected merge command, invalid ref',
133
+ retryable: false,
134
+ nextAction: null,
135
+ },
136
+ 'git-timeout': {
137
+ description: 'A git command timed out or exceeded its output buffer.',
138
+ trigger: 'slow pre-merge hook, huge diff',
139
+ retryable: true,
140
+ nextAction: 'retry; if it persists, raise git.timeoutMs or merge.timeoutMs',
141
+ },
142
+ 'worktree-dirty': {
143
+ description: 'The mission worktree has uncommitted changes and cannot complete.',
144
+ trigger: 'staged, modified, or untracked files left behind before `tower_complete`',
145
+ retryable: false,
146
+ nextAction: 'commit or remove the worktree changes, then retry tower_complete',
147
+ },
148
+ 'spawn-unavailable': {
149
+ description: 'The subagents service is unavailable.',
150
+ trigger: 'a Host without `subagents`',
151
+ retryable: false,
152
+ nextAction: 'enable the DSH subagents service, then retry',
153
+ },
154
+ 'provider-missing': {
155
+ description: 'No subagent provider is registered.',
156
+ trigger: 'an empty provider list',
157
+ retryable: false,
158
+ nextAction: 'register a subagent provider in the DSH profile, then retry',
159
+ },
160
+ 'provider-cooldown': {
161
+ description: 'The provider is throttled; spawn is in a short cooldown.',
162
+ trigger: 'repeated retryable provider failures; wait for `blockedUntil`',
163
+ retryable: true,
164
+ nextAction: 'wait for the cooldown to expire, then retry',
165
+ },
166
+ 'preset-missing': {
167
+ description: 'The configured agent preset is not registered.',
168
+ trigger: 'missing `tower-worker`/`tower-reviewer` preset rows, DSH not restarted',
169
+ retryable: false,
170
+ nextAction: 'install the tower-worker and tower-reviewer agent presets and restart DSH',
171
+ },
172
+ 'reviewer-prerequisite': {
173
+ description: 'The reviewer spawn prerequisite is not met.',
174
+ trigger: 'reviewer spawned before the mission is `completed`, or before a worktree exists',
175
+ retryable: false,
176
+ nextAction: 'complete the mission first, then spawn the reviewer',
177
+ },
178
+ 'not-authorized': {
179
+ description: 'The calling agent role may not perform this action.',
180
+ trigger: 'a worker calling a tower-only tool or changing mission status/scope; a reviewer mutating findings',
181
+ retryable: false,
182
+ nextAction: 'call the tool as the tower, or as the worker/reviewer it is assigned to',
183
+ },
184
+ 'workspace-busy': {
185
+ description: 'Another Tower session owns this repository workspace lock.',
186
+ trigger: '`mode.workspaceLock:true` and a second session calls a repo-scoped tool',
187
+ retryable: true,
188
+ nextAction: 'run /tower off in the other session or wait for its lock to become stale',
189
+ },
190
+ 'worker-limit': {
191
+ description: 'The repository reached spawn.maxLiveWorkers live workers.',
192
+ trigger: 'spawning a fifth worker with the default limit',
193
+ retryable: false,
194
+ nextAction: 'wait for a live worker to finish or raise spawn.maxLiveWorkers',
195
+ },
196
+ 'rate-limited': {
197
+ description: 'The tower_send per-minute rate limit was reached.',
198
+ trigger: '`send.maxPerMinute` exceeded',
199
+ retryable: true,
200
+ nextAction: 'wait for the next per-minute window or raise send.maxPerMinute',
201
+ },
202
+ 'takeover-refused': {
203
+ description: 'The mission already has a live worker that was not stopped first.',
204
+ trigger: '`tower_spawn({takeover:true})` before `tower_interrupt`',
205
+ retryable: false,
206
+ nextAction: 'run tower_interrupt for the live worker, then retry the takeover',
207
+ },
208
+ 'reload-unavailable': {
209
+ description: 'The DSH HMR service is not available in this profile.',
210
+ trigger: '`tower_reload` / `/tower reload` on a profile without HMR',
211
+ retryable: false,
212
+ nextAction: 'restart DSH instead of reloading',
213
+ },
214
+ 'reload-failed': {
215
+ description: 'The HMR reload failed; check the DSH log and restart if needed.',
216
+ trigger: '`reconcileProfile` threw',
217
+ retryable: false,
218
+ nextAction: 'check the DSH log and restart DSH',
219
+ },
220
+ 'tests-failed': {
221
+ description: 'Declared test discovery failed or did not meet its admission requirements.',
222
+ trigger: 'nonzero command, malformed output, minCount/required shortfall',
223
+ retryable: false,
224
+ nextAction: 'fix the failing tests, then retry tower_complete',
225
+ },
226
+ 'tests-timeout': {
227
+ description: 'Test discovery exceeded its time or output budget.',
228
+ trigger: '`tests.timeoutMs`, or stdout over `tests.maxBytes`',
229
+ retryable: false,
230
+ nextAction: 'raise tests.timeoutMs or tests.maxBytes (or speed up the suite), then retry',
231
+ },
232
+ 'lease-busy': {
233
+ description: 'Another holder owns the requested advisory lease.',
234
+ trigger: '`tower_resource acquire` while `heavy-ci` is held',
235
+ retryable: false,
236
+ nextAction: 'release the lease or wait for it to expire, then retry',
237
+ },
238
+ 'lease-token': {
239
+ description: 'The lease release token does not match the current holder.',
240
+ trigger: 'releasing with another session\'s token',
241
+ retryable: false,
242
+ nextAction: 'use the token returned when the lease was acquired',
243
+ },
244
+ 'batch-invalid': {
245
+ description: 'The batch request or its close evidence is invalid.',
246
+ trigger: 'unknown member, duplicate id, unsafe receipt path, missing receipt hash',
247
+ retryable: false,
248
+ nextAction: 'fix the batch request or the receipt hashes, then retry',
249
+ },
250
+ 'legacy-invalid': {
251
+ description: 'The legacy snapshot or mapping is invalid.',
252
+ trigger: 'unknown schema, source inside the Tower state dir, mapping rewrite',
253
+ retryable: false,
254
+ nextAction: 'check the legacy snapshot path, schema and mapping, then retry',
255
+ },
256
+ 'tool-internal': {
257
+ description: 'An unexpected failure; read the message and the activity log.',
258
+ trigger: 'a bug; read the message and the activity log',
259
+ retryable: false,
260
+ nextAction: null,
261
+ },
55
262
  }
56
263
 
57
264
  export const ERROR_CODE_VALUES = Object.freeze(Object.keys(ERROR_CODES))
265
+
266
+ /** @param {unknown} value */
58
267
  export function isErrorCode(value) {
59
268
  return typeof value === 'string' && Object.hasOwn(ERROR_CODES, value)
60
269
  }
61
270
 
62
- const RETRYABLE_CODES = new Set(['state-locked', 'rate-limited', 'provider-cooldown', 'git-timeout', 'workspace-busy', 'merge-intent-pending', 'stale-base'])
271
+ /** @param {string} code */
272
+ export function errorEntry(code) {
273
+ return isErrorCode(code) ? ERROR_CODES[code] : null
274
+ }
275
+
276
+ /** @param {string} code */
277
+ export function errorDescription(code) {
278
+ return errorEntry(code)?.description ?? ''
279
+ }
280
+
281
+ /** @param {string} code */
282
+ export function errorRetryable(code) {
283
+ return errorEntry(code)?.retryable === true
284
+ }
285
+
286
+ /** @param {string} code */
287
+ export function errorNextAction(code) {
288
+ return errorEntry(code)?.nextAction ?? null
289
+ }
290
+
291
+ /**
292
+ * The model-visible message. Exactly one trailing ` — next: …` segment is
293
+ * appended when there is a recovery step; a retryable code marks the same
294
+ * segment with ` (retryable)`. A message that already carries the action is
295
+ * never suffixed twice.
296
+ * @param {string} code
297
+ * @param {string} message
298
+ * @param {{ nextAction?: string | null, retryable?: boolean }} [options]
299
+ * @returns {string}
300
+ */
301
+ export function renderErrorMessage(code, message, options = {}) {
302
+ const text = String(message)
303
+ const base = `[tower:${code}] ${text}`
304
+ const nextAction = options.nextAction ?? errorNextAction(code)
305
+ if (nextAction === null || nextAction === undefined) return base
306
+ if (text.includes(nextAction) || text.includes('— next:')) return base
307
+ const retryable = options.retryable ?? errorRetryable(code)
308
+ return `${base} — next: ${nextAction}${retryable ? ' (retryable)' : ''}`
309
+ }
63
310
 
64
311
  export class TowerToolError extends Error {
312
+ /**
313
+ * @param {string} code
314
+ * @param {string} message
315
+ * @param {{ cause?: unknown, retryable?: boolean, nextAction?: string | null, details?: unknown }} [options]
316
+ */
65
317
  constructor(code, message, options = {}) {
66
- super(`[tower:${code}] ${message}`, options.cause ? { cause: options.cause } : undefined)
318
+ const entry = errorEntry(code)
319
+ const retryable = options.retryable ?? entry?.retryable === true
320
+ const nextAction = options.nextAction ?? entry?.nextAction ?? null
321
+ super(renderErrorMessage(code, message, { nextAction, retryable }), options.cause ? { cause: options.cause } : undefined)
67
322
  this.name = 'TowerToolError'
68
323
  this.code = code
69
- this.retryable = options.retryable ?? RETRYABLE_CODES.has(code)
70
- this.nextAction = options.nextAction ?? null
324
+ this.retryable = retryable
325
+ this.nextAction = nextAction
71
326
  this.details = options.details ?? null
72
327
  }
73
328
  }
@@ -82,8 +337,10 @@ export function classifyError(error) {
82
337
  if (error && typeof error.code === 'string' && ERROR_CODES[error.code]) {
83
338
  return new TowerToolError(error.code, originalMessage(error), {
84
339
  cause: error,
85
- retryable: error.retryable === true,
86
- nextAction: error.nextAction ?? null,
340
+ // Let the catalog supply a default when the thrown object does not
341
+ // explicitly override the recovery metadata.
342
+ retryable: typeof error.retryable === 'boolean' ? error.retryable : undefined,
343
+ nextAction: error.nextAction ?? undefined,
87
344
  details: error.details ?? null,
88
345
  })
89
346
  }
@@ -0,0 +1,207 @@
1
+ // Stable domain events (#75).
2
+ //
3
+ // The domain layer *describes* what changed; `app/` persists the state and only
4
+ // then emits the event through the injected event port. `state.json` remains the
5
+ // authoritative record — events are a process-local observation channel, not an
6
+ // event-sourced log, and are never replayed.
7
+ //
8
+ // The table below is the single source of truth for the generated reference
9
+ // (`docs/reference/events.md`), so a rename cannot drift from the runtime.
10
+ // Every domain event carries `{ missionId, revision, at }`; `merged` also
11
+ // carries the merge `commit`. Names are dotted (`tower.mission.*`); the older
12
+ // tool-shaped `tower/<tool>` aliases stay for one minor release and are marked
13
+ // deprecated in the generated reference.
14
+ // @ts-check
15
+
16
+ /** @typedef {'planned'|'completed'|'reviewed'|'merged'|'abandoned'} MissionEventType */
17
+
18
+ /**
19
+ * @typedef {object} MissionEventEntry
20
+ * @property {MissionEventType} type
21
+ * @property {string} name
22
+ * @property {string[]} payload
23
+ * @property {string} description
24
+ */
25
+
26
+ /** Stable domain-fact events (`tower.mission.*`). */
27
+ export const MISSION_EVENTS = Object.freeze({
28
+ planned: {
29
+ type: 'planned',
30
+ name: 'tower.mission.planned',
31
+ payload: ['missionId', 'revision', 'at'],
32
+ description: 'tower_plan persisted one or more new missions',
33
+ },
34
+ completed: {
35
+ type: 'completed',
36
+ name: 'tower.mission.completed',
37
+ payload: ['missionId', 'revision', 'at'],
38
+ description: 'tower_complete recorded a completion report (or tower_mission set status "completed")',
39
+ },
40
+ reviewed: {
41
+ type: 'reviewed',
42
+ name: 'tower.mission.reviewed',
43
+ payload: ['missionId', 'revision', 'at'],
44
+ description: 'tower_review recorded a verdict for the mission branch',
45
+ },
46
+ merged: {
47
+ type: 'merged',
48
+ name: 'tower.mission.merged',
49
+ payload: ['missionId', 'revision', 'at', 'commit'],
50
+ description: 'tower_merge recorded the merge commit that landed the mission branch',
51
+ },
52
+ abandoned: {
53
+ type: 'abandoned',
54
+ name: 'tower.mission.abandoned',
55
+ payload: ['missionId', 'revision', 'at'],
56
+ description: 'a mission transitioned to "abandoned"',
57
+ },
58
+ })
59
+
60
+ /** @type {readonly MissionEventType[]} */
61
+ export const MISSION_EVENT_TYPES = Object.freeze(['planned', 'completed', 'reviewed', 'merged', 'abandoned'])
62
+
63
+ /** @type {readonly MissionEventEntry[]} */
64
+ export const MISSION_EVENT_TABLE = Object.freeze(
65
+ MISSION_EVENT_TYPES.map((type) => /** @type {MissionEventEntry} */ (MISSION_EVENTS[type])),
66
+ )
67
+
68
+ /** Tool-shaped alias events, kept for one minor release (#75). */
69
+ export const LEGACY_TOOL_EVENT_TABLE = Object.freeze([
70
+ { name: 'tower/init', description: '`tower_init` created or re-anchored state' },
71
+ { name: 'tower/plan', description: '`tower_plan` created missions' },
72
+ { name: 'tower/mission', description: '`tower_mission` changed one mission' },
73
+ { name: 'tower/spawn', description: '`tower_spawn` recorded a worker/reviewer' },
74
+ { name: 'tower/complete', description: '`tower_complete` recorded a completion report' },
75
+ { name: 'tower/review', description: '`tower_review` recorded a verdict' },
76
+ { name: 'tower/merge', description: '`tower_merge` returned (including gate failures; inspect `value.ok`)' },
77
+ { name: 'tower/rebase', description: '`tower_rebase` returned' },
78
+ { name: 'tower/teardown', description: '`tower_teardown` removed worktrees' },
79
+ { name: 'tower/finding', description: '`tower_finding` created one finding' },
80
+ { name: 'tower/findings', description: '`tower_findings` read/listed/updated findings' },
81
+ { name: 'tower/send', description: '`tower_send` persisted a message' },
82
+ { name: 'tower/inbox', description: '`tower_inbox` acknowledged messages' },
83
+ { name: 'tower/interrupt', description: '`tower_interrupt` stopped a live agent' },
84
+ { name: 'tower/diff', description: '`tower_diff` read a mission diff' },
85
+ { name: 'tower/adopt', description: '`tower_adopt` reconciled the roster' },
86
+ { name: 'tower/roster', description: '`tower_roster` read the roster' },
87
+ { name: 'tower/status', description: '`tower_status` read the dashboard' },
88
+ { name: 'tower/preflight', description: '`tower_preflight` evaluated the gate' },
89
+ { name: 'tower/doctor', description: '`tower_doctor` completed its read-only checks' },
90
+ { name: 'tower/recover', description: '`tower_recover` inspected or cleared a stale state lock' },
91
+ { name: 'tower/test', description: '`tower_test` ran discovery' },
92
+ { name: 'tower/batch', description: '`tower_batch` created, inspected or closed a batch' },
93
+ { name: 'tower/resource', description: '`tower_resource` acquired/released/inspected leases' },
94
+ { name: 'tower/import_legacy', description: '`tower_import_legacy` stored a legacy snapshot' },
95
+ { name: 'tower/legacy_map', description: '`tower_legacy_map` mapped or listed legacy ids' },
96
+ { name: 'tower/mode', description: '`tower_mode` read the session stance (`value.mode`, `allowAgentControl`, `deniedWhileActive`)' },
97
+ { name: 'tower/control', description: '`tower_control` entered/left the mode (`value.mode`, actor/reason in `value`)' },
98
+ { name: 'tower/reload', description: '`tower_reload` returned a reload schedule/result' },
99
+ ])
100
+
101
+ /** @type {readonly string[]} */
102
+ export const LEGACY_TOOL_EVENT_NAMES = Object.freeze(LEGACY_TOOL_EVENT_TABLE.map((entry) => entry.name))
103
+
104
+ /** Total number of process-local events described by the generated reference. */
105
+ export const EVENT_COUNT = MISSION_EVENT_TABLE.length + LEGACY_TOOL_EVENT_TABLE.length
106
+
107
+ /**
108
+ * The runtime name of a domain event type. Throws on an unknown type so a typo
109
+ * fails in tests instead of emitting an unobservable event.
110
+ * @param {string} type
111
+ * @returns {string}
112
+ */
113
+ export function missionEventName(type) {
114
+ const entry = /** @type {Record<string, MissionEventEntry>} */ (MISSION_EVENTS)[type]
115
+ if (!entry) throw new TypeError(`unknown mission event type "${type}"`)
116
+ return entry.name
117
+ }
118
+
119
+ /**
120
+ * A domain transition's event *descriptor*: what changed, before the caller
121
+ * knows the persisted revision and timestamp. App code finalises it with
122
+ * `buildMissionEvent` after the state write.
123
+ * @param {MissionEventType} type
124
+ * @param {string} missionId
125
+ * @param {{ commit?: string }} [extra]
126
+ * @returns {{ type: MissionEventType, missionId: string, commit?: string }}
127
+ */
128
+ export function missionEventDescriptor(type, missionId, extra = {}) {
129
+ if (!MISSION_EVENT_TYPES.includes(type)) throw new TypeError(`unknown mission event type "${type}"`)
130
+ if (typeof missionId !== 'string' || missionId.length === 0) {
131
+ throw new TypeError('mission events require a non-empty missionId')
132
+ }
133
+ const descriptor = /** @type {{ type: MissionEventType, missionId: string, commit?: string }} */ ({
134
+ type,
135
+ missionId,
136
+ })
137
+ if (type === 'merged') {
138
+ if (typeof extra.commit !== 'string' || extra.commit.length === 0) {
139
+ throw new TypeError('tower.mission.merged requires the merge commit')
140
+ }
141
+ descriptor.commit = extra.commit
142
+ }
143
+ return descriptor
144
+ }
145
+
146
+ /**
147
+ * Finalise one descriptor into `{ name, payload }` for the event port.
148
+ * Payload shapes are fixed here so `docs/reference/events.md` (generated from
149
+ * the same table) cannot describe a different shape than the runtime emits.
150
+ * @param {{ type: MissionEventType, missionId: string, commit?: string }} descriptor
151
+ * @param {{ revision: number, at: number }} context
152
+ * @returns {{ name: string, payload: Record<string, any> }}
153
+ */
154
+ export function buildMissionEvent(descriptor, { revision, at }) {
155
+ const entry = /** @type {Record<string, MissionEventEntry>} */ (MISSION_EVENTS)[descriptor.type]
156
+ if (!entry) throw new TypeError(`unknown mission event type "${descriptor.type}"`)
157
+ if (!Number.isInteger(revision)) throw new TypeError('mission events require the persisted integer revision')
158
+ if (!Number.isFinite(at)) throw new TypeError('mission events require the transition timestamp')
159
+ /** @type {Record<string, any>} */
160
+ const payload = { missionId: descriptor.missionId, revision, at }
161
+ if (entry.payload.includes('commit')) payload.commit = descriptor.commit
162
+ return { name: entry.name, payload }
163
+ }
164
+
165
+ /**
166
+ * Descriptors for a set of freshly persisted missions.
167
+ * @param {Array<{ id: string }>} missions
168
+ * @returns {Array<{ type: MissionEventType, missionId: string }>}
169
+ */
170
+ export function plannedEvents(missions) {
171
+ return missions.map((mission) => missionEventDescriptor('planned', mission.id))
172
+ }
173
+
174
+ /**
175
+ * @param {{ id: string }} mission
176
+ * @returns {{ type: MissionEventType, missionId: string }}
177
+ */
178
+ export function completedEvent(mission) {
179
+ return missionEventDescriptor('completed', mission.id)
180
+ }
181
+
182
+ /**
183
+ * @param {{ id: string }} mission
184
+ * @returns {{ type: MissionEventType, missionId: string }}
185
+ */
186
+ export function reviewedEvent(mission) {
187
+ return missionEventDescriptor('reviewed', mission.id)
188
+ }
189
+
190
+ /**
191
+ * @param {{ id: string }} mission
192
+ * @param {string} commit
193
+ * @returns {{ type: MissionEventType, missionId: string, commit: string }}
194
+ */
195
+ export function mergedEvent(mission, commit) {
196
+ return /** @type {{ type: MissionEventType, missionId: string, commit: string }} */ (
197
+ missionEventDescriptor('merged', mission.id, { commit })
198
+ )
199
+ }
200
+
201
+ /**
202
+ * @param {{ id: string }} mission
203
+ * @returns {{ type: MissionEventType, missionId: string }}
204
+ */
205
+ export function abandonedEvent(mission) {
206
+ return missionEventDescriptor('abandoned', mission.id)
207
+ }
@@ -2,7 +2,7 @@
2
2
  // facts the app already read (tips, changed files, checkout branch). The app
3
3
  // performs every read and owns the result assembly.
4
4
  // @ts-check
5
- import { fileInScope } from '../scope.js'
5
+ import { caseInsensitiveScopeMatch, fileInScope } from '../scope.js'
6
6
 
7
7
  /** @typedef {import('../../types/index.js').Blocker} Blocker */
8
8
  /** @typedef {import('../../types/index.js').Mission} Mission */
@@ -76,16 +76,29 @@ export function surveyChangedFilesBlocker({ missionId, changedFiles }) {
76
76
  * @param {object} input
77
77
  * @param {Mission} input.mission
78
78
  * @param {string[]} input.changedFiles
79
+ * @param {boolean} [input.caseInsensitive] the checkout's detected comparison mode
79
80
  * @returns {Blocker | null}
80
81
  */
81
- export function outOfScopeBlocker({ mission, changedFiles }) {
82
- const outOfScope = changedFiles.filter((file) => !fileInScope(mission.scope, file))
83
- return outOfScope.length > 0
84
- ? {
85
- code: 'out-of-scope',
86
- detail: `changed files outside scope (${mission.scope.join(', ')}): ${outOfScope.slice(0, 8).join(', ')}`,
87
- }
88
- : null
82
+ export function outOfScopeBlocker({ mission, changedFiles, caseInsensitive = false }) {
83
+ const outOfScope = changedFiles.filter((file) => !fileInScope(mission.scope, file, { caseInsensitive }))
84
+ if (outOfScope.length === 0) return null
85
+ const parts = [`changed files outside scope (${mission.scope.join(', ')}): ${outOfScope.slice(0, 8).join(', ')}`]
86
+ // On a case-sensitive checkout a case-only mismatch is a human error worth
87
+ // naming, but it stays a rejection: folding here would make src/Api and
88
+ // src/api the same directory on a filesystem where they are not (#90).
89
+ if (!caseInsensitive) {
90
+ const hints = outOfScope
91
+ .map((file) => {
92
+ const pattern = caseInsensitiveScopeMatch(mission.scope, file)
93
+ return pattern
94
+ ? `${file} matches "${pattern}" only when case is folded; this checkout is case-sensitive`
95
+ : null
96
+ })
97
+ .filter(Boolean)
98
+ .slice(0, 3)
99
+ if (hints.length > 0) parts.push(`case mismatch: ${hints.join('; ')}`)
100
+ }
101
+ return { code: 'out-of-scope', detail: parts.join('. ') }
89
102
  }
90
103
 
91
104
  /**
@@ -110,18 +123,34 @@ export function baseDirtyBlocker(wouldOverwrite) {
110
123
  : null
111
124
  }
112
125
 
126
+ /**
127
+ * Whether a dependency already satisfies the gate. A build mission must be
128
+ * merged (or abandoned), but a survey is terminal-success at `completed`: it is
129
+ * read-only and has no branch to merge, so requiring `merged` would keep the
130
+ * dependent mission blocked forever (#107). Archived entries carry `kind` for
131
+ * the same decision.
132
+ * @param {{ kind?: string, status?: string } | null | undefined} dep
133
+ * @returns {boolean}
134
+ */
135
+ export function dependencySatisfied(dep) {
136
+ if (!dep) return false
137
+ if (dep.status === 'merged' || dep.status === 'abandoned') return true
138
+ return dep.kind === 'survey' && dep.status === 'completed'
139
+ }
140
+
113
141
  /**
114
142
  * @param {object} input
115
143
  * @param {string[]} input.deps
116
144
  * @param {TowerState} input.state
145
+ * @param {Map<string, { status: string, kind?: string }>} [input.archived] compact archived missions (#73)
117
146
  * @returns {Blocker[]}
118
147
  */
119
- export function dependencyBlockers({ deps = [], state }) {
148
+ export function dependencyBlockers({ deps = [], state, archived = new Map() }) {
120
149
  /** @type {Blocker[]} */
121
150
  const blockers = []
122
151
  for (const depId of deps) {
123
- const dep = state.missions.find((entry) => entry.id === depId)
124
- if (!dep || !['merged', 'abandoned'].includes(dep.status)) {
152
+ const dep = state.missions.find((entry) => entry.id === depId) ?? archived.get(depId)
153
+ if (!dependencySatisfied(dep)) {
125
154
  blockers.push({ code: 'deps-unmerged', detail: `${depId} is ${dep ? dep.status : 'missing'}` })
126
155
  }
127
156
  }