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
@@ -79,6 +79,7 @@ const SCHEMAS = {
79
79
  recentFindings: S.arr(S.obj()),
80
80
  missions: S.arr(S.obj()),
81
81
  activity: S.arr(S.obj()),
82
+ gitLock: S.obj({ held: S.bool, pid: S.any, phase: S.any, heldMs: S.any }),
82
83
  mode: S.any,
83
84
  spawn: S.any,
84
85
  towerAgentId: S.any,
@@ -169,7 +170,7 @@ const SCHEMAS = {
169
170
  ['counts', 'total'],
170
171
  ),
171
172
  review: S.obj(
172
- { review: S.obj(), branchTip: S.str, missionStatus: S.str, round: S.int, reviewerRounds: S.int },
173
+ { review: S.obj(), branchTip: S.str, missionStatus: S.str, round: S.int, appended: S.bool, reviewerRounds: S.int },
173
174
  ['review', 'branchTip', 'missionStatus', 'round', 'reviewerRounds'],
174
175
  ),
175
176
  preflight: S.obj(
@@ -243,6 +244,8 @@ const SCHEMAS = {
243
244
  {
244
245
  ok: S.bool,
245
246
  checks: S.arr(S.obj()),
247
+ stateLock: S.any,
248
+ gitLock: S.any,
246
249
  stateSchema: S.obj({
247
250
  currentVersion: S.any,
248
251
  supportedVersion: S.int,
@@ -250,6 +253,7 @@ const SCHEMAS = {
250
253
  status: S.str,
251
254
  }),
252
255
  reports: S.any,
256
+ surface: S.any,
253
257
  archive: S.obj({
254
258
  missions: S.any,
255
259
  archivedMissions: S.int,
@@ -266,7 +270,7 @@ const SCHEMAS = {
266
270
  ['ok', 'checks'],
267
271
  ),
268
272
  recover: S.obj(
269
- { ok: S.bool, action: S.str, stateLock: S.any, cleared: S.bool, backup: S.any, error: S.str },
273
+ { ok: S.bool, action: S.str, stateLock: S.any, cleared: S.bool, backup: S.any, forced: S.bool, error: S.str },
270
274
  ['ok', 'action'],
271
275
  ),
272
276
  test: S.obj(
@@ -25,6 +25,7 @@ export const RecoverInput = Type.Object({
25
25
  repoRoot: Type.String({ description: 'Absolute path inside the target Git repository.' }),
26
26
  action: enumOf(RECOVERY_ACTIONS, { description: 'Recovery action.' }),
27
27
  confirm: Type.Optional(Type.String({ description: 'Required literal CLEAR_STALE_LOCK for clear-stale-lock.' })),
28
+ force: Type.Optional(Type.Boolean({ description: 'clear-stale-lock: clear the lock even when its recorded PID is alive (a recycled PID); requires confirm and still moves the record to a .recovered-<ts> backup.' })),
28
29
  }, { additionalProperties: false })
29
30
 
30
31
  export const TestInput = Type.Object({
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,58 +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
- 'state-version-unsupported': 'The Tower state file uses a schema version this build cannot read or downgrade.',
32
- 'state-migration-failed': 'A Tower state migration failed; the original state file was not modified.',
33
- 'config-invalid': 'Tower configuration is invalid.',
34
- 'config-unknown-key': 'Tower configuration has an unknown top-level key.',
35
- 'git-failed': 'A git command failed.',
36
- 'git-timeout': 'A git command timed out or exceeded its output buffer.',
37
- 'worktree-dirty': 'The mission worktree has uncommitted changes and cannot complete.',
38
- 'spawn-unavailable': 'The subagents service is unavailable.',
39
- 'provider-missing': 'No subagent provider is registered.',
40
- 'provider-cooldown': 'The provider is throttled; spawn is in a short cooldown.',
41
- 'preset-missing': 'The configured agent preset is not registered.',
42
- 'reviewer-prerequisite': 'The reviewer spawn prerequisite is not met.',
43
- 'not-authorized': 'The calling agent role may not perform this action.',
44
- 'workspace-busy': 'Another Tower session owns this repository workspace lock.',
45
- 'worker-limit': 'The repository reached spawn.maxLiveWorkers live workers.',
46
- 'rate-limited': 'The tower_send per-minute rate limit was reached.',
47
- 'takeover-refused': 'The mission already has a live worker that was not stopped first.',
48
- 'reload-unavailable': 'The DSH HMR service is not available in this profile.',
49
- 'reload-failed': 'The HMR reload failed; check the DSH log and restart if needed.',
50
- 'tests-failed': 'Declared test discovery failed or did not meet its admission requirements.',
51
- 'tests-timeout': 'Test discovery exceeded its time or output budget.',
52
- 'lease-busy': 'Another holder owns the requested advisory lease.',
53
- 'lease-token': 'The lease release token does not match the current holder.',
54
- 'batch-invalid': 'The batch request or its close evidence is invalid.',
55
- 'legacy-invalid': 'The legacy snapshot or mapping is invalid.',
56
- '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
+ },
57
262
  }
58
263
 
59
264
  export const ERROR_CODE_VALUES = Object.freeze(Object.keys(ERROR_CODES))
265
+
266
+ /** @param {unknown} value */
60
267
  export function isErrorCode(value) {
61
268
  return typeof value === 'string' && Object.hasOwn(ERROR_CODES, value)
62
269
  }
63
270
 
64
- 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
+ }
65
310
 
66
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
+ */
67
317
  constructor(code, message, options = {}) {
68
- 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)
69
322
  this.name = 'TowerToolError'
70
323
  this.code = code
71
- this.retryable = options.retryable ?? RETRYABLE_CODES.has(code)
72
- this.nextAction = options.nextAction ?? null
324
+ this.retryable = retryable
325
+ this.nextAction = nextAction
73
326
  this.details = options.details ?? null
74
327
  }
75
328
  }
@@ -84,8 +337,10 @@ export function classifyError(error) {
84
337
  if (error && typeof error.code === 'string' && ERROR_CODES[error.code]) {
85
338
  return new TowerToolError(error.code, originalMessage(error), {
86
339
  cause: error,
87
- retryable: error.retryable === true,
88
- 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,
89
344
  details: error.details ?? null,
90
345
  })
91
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
+ }