dsh-tower 2.1.0 → 2.4.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 (203) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +78 -5
  4. package/PRINCIPLES.md +163 -0
  5. package/README.en.md +35 -19
  6. package/README.md +28 -18
  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/cordis.patch.yml +56 -28
  44. package/docs/README.md +7 -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 +70 -0
  49. package/docs/adr/0005-component-surface.md +130 -0
  50. package/docs/adr/README.md +52 -0
  51. package/docs/agents/workflow.md +51 -0
  52. package/docs/architecture/overview.md +192 -43
  53. package/docs/guide/hot-reload.md +5 -5
  54. package/docs/guide/install-upgrade-migration.md +13 -0
  55. package/docs/guide/release.md +1 -1
  56. package/docs/operations/incident.md +82 -0
  57. package/docs/operations/runbook.md +12 -3
  58. package/docs/operations/security.md +1 -1
  59. package/docs/reference/blockers.md +2 -2
  60. package/docs/reference/component-surface.md +135 -0
  61. package/docs/reference/config.generated.md +2 -9
  62. package/docs/reference/config.md +11 -9
  63. package/docs/reference/doctor.md +53 -2
  64. package/docs/reference/errors.generated.md +41 -37
  65. package/docs/reference/errors.md +53 -41
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +157 -0
  68. package/docs/reference/tools.generated.md +8 -5
  69. package/docs/reference/tools.md +10 -7
  70. package/docs/zh/README.md +1 -1
  71. package/docs/zh/guide/hot-reload.md +4 -4
  72. package/docs/zh/guide/install-upgrade-migration.md +10 -0
  73. package/docs/zh/operations/security.md +1 -1
  74. package/docs/zh/reference/blockers.md +2 -2
  75. package/docs/zh/reference/config.md +9 -9
  76. package/docs/zh/reference/doctor.md +29 -1
  77. package/docs/zh/reference/tools.md +10 -8
  78. package/domain/batch.js +2 -2
  79. package/domain/components.js +641 -0
  80. package/domain/config/schema.js +62 -28
  81. package/domain/contracts/outputs.js +16 -2
  82. package/domain/contracts/tool-inputs/ops.js +1 -0
  83. package/domain/errors.js +308 -43
  84. package/domain/events.js +207 -0
  85. package/domain/integration/gate.js +17 -2
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +12 -1
  89. package/domain/missions/completion.js +14 -0
  90. package/domain/missions/lifecycle.js +9 -3
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/read/views.js +3 -0
  94. package/domain/state/archive.js +1 -1
  95. package/domain/state.js +1 -1
  96. package/domain/surface.js +347 -0
  97. package/host/capability-batch.js +15 -0
  98. package/host/capability-hmr.js +30 -0
  99. package/host/capability-import.js +11 -0
  100. package/host/capability-leases.js +11 -0
  101. package/host/capability-registry.js +149 -0
  102. package/host/capability-tests.js +16 -0
  103. package/{lib → host}/doctor.js +157 -18
  104. package/{lib → host}/hmr.js +2 -4
  105. package/{lib → host}/modeStore.js +1 -1
  106. package/host/presets.js +77 -0
  107. package/{lib → host}/tool-registry.js +235 -44
  108. package/host/tower.js +32 -0
  109. package/index.js +40 -25
  110. package/locale/en.json +31 -11
  111. package/locale/zh.json +30 -10
  112. package/package.json +24 -4
  113. package/ports/archive.js +18 -0
  114. package/ports/caseSensitivity.js +15 -0
  115. package/ports/clock.js +10 -2
  116. package/ports/config.js +19 -0
  117. package/ports/contract.js +38 -0
  118. package/ports/events.js +24 -0
  119. package/ports/ids.js +10 -2
  120. package/ports/index.js +54 -2
  121. package/ports/ownership.js +17 -0
  122. package/ports/state.js +16 -0
  123. package/ports/store.js +52 -3
  124. package/ports/vcs.js +25 -4
  125. package/scripts/architecture-waivers.json +19 -0
  126. package/scripts/architecture.mjs +634 -23
  127. package/scripts/check-components.mjs +235 -0
  128. package/scripts/check-docs.mjs +8 -35
  129. package/scripts/check-pack.mjs +68 -39
  130. package/scripts/check-philosophy.mjs +266 -0
  131. package/scripts/component-surface.json +395 -0
  132. package/scripts/domain-metrics.mjs +13 -4
  133. package/scripts/generate-component-surface.mjs +59 -0
  134. package/scripts/generate-config-reference.mjs +14 -1
  135. package/scripts/generate-error-reference.mjs +68 -14
  136. package/scripts/generate-events-reference.mjs +83 -0
  137. package/scripts/generate-tools-reference.mjs +18 -25
  138. package/scripts/generate-types.mjs +127 -0
  139. package/scripts/issue-audit.mjs +2 -2
  140. package/scripts/lib/surface.mjs +87 -0
  141. package/scripts/lib/tool-surface.mjs +85 -0
  142. package/scripts/mount-smoke/fixtures.mjs +3 -3
  143. package/scripts/mount-smoke/surface.mjs +96 -27
  144. package/scripts/mount-smoke.mjs +3 -1
  145. package/scripts/pack-manifest.txt +75 -22
  146. package/scripts/regression-audit.mjs +164 -0
  147. package/scripts/release-check.mjs +1 -1
  148. package/scripts/surface-baseline.json +52 -0
  149. package/scripts/surface-baseline.mjs +118 -0
  150. package/scripts/types-coverage.mjs +184 -0
  151. package/scripts/types-strict-manifest.txt +10 -0
  152. package/scripts/types-strict.mjs +141 -0
  153. package/skills.js +8 -1
  154. package/tools/adopt.js +2 -3
  155. package/tools/batch.js +2 -3
  156. package/tools/complete.js +2 -3
  157. package/tools/diff.js +3 -3
  158. package/tools/doctor.js +7 -10
  159. package/tools/finding.js +2 -3
  160. package/tools/findings.js +3 -3
  161. package/tools/import-legacy.js +3 -5
  162. package/tools/inbox.js +3 -3
  163. package/tools/index.js +9 -8
  164. package/tools/init.js +3 -6
  165. package/tools/interrupt.js +2 -3
  166. package/tools/legacy-map.js +3 -5
  167. package/tools/merge.js +3 -3
  168. package/tools/mission.js +2 -3
  169. package/tools/plan.js +6 -5
  170. package/tools/preflight.js +2 -3
  171. package/tools/rebase.js +3 -3
  172. package/tools/recover.js +38 -13
  173. package/tools/reload.js +36 -40
  174. package/tools/resource.js +3 -5
  175. package/tools/review.js +3 -4
  176. package/tools/roster.js +2 -3
  177. package/tools/send.js +2 -2
  178. package/tools/spawn.js +8 -5
  179. package/tools/status.js +11 -4
  180. package/tools/teardown.js +2 -3
  181. package/tools/test.js +2 -2
  182. package/types/index.d.ts +29 -13
  183. package/ui-bundle/README.md +37 -3
  184. package/ui-bundle/client.cards.js +356 -0
  185. package/ui-bundle/client.denied.js +142 -0
  186. package/ui-bundle/client.dock.js +333 -0
  187. package/ui-bundle/client.helpers.js +271 -0
  188. package/ui-bundle/client.js +79 -1747
  189. package/ui-bundle/client.locales.js +247 -0
  190. package/ui-bundle/client.mode.js +206 -0
  191. package/ui-bundle/client.overlay.js +64 -0
  192. package/ui-bundle/client.styles.js +151 -0
  193. package/ui-bundle/client.toolview.js +82 -0
  194. package/ui-bundle/package.json +1 -0
  195. package/lib/persistence.js +0 -356
  196. package/lib/presets.js +0 -35
  197. package/lib/store.js +0 -133
  198. /package/{lib → adapters}/stateFile.js +0 -0
  199. /package/{lib → app}/briefing.js +0 -0
  200. /package/{lib → app}/spawn-support.js +0 -0
  201. /package/{lib → host}/compatibility.js +0 -0
  202. /package/{lib → host}/config.js +0 -0
  203. /package/{lib → host}/modeState.js +0 -0
@@ -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:
@@ -33,7 +33,10 @@ export const DEFAULT_DENY_TOOLS = [
33
33
  ]
34
34
 
35
35
  const LEGACY_KEYS = ['section', 'allowAgentControl']
36
- const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'git', 'worktrees', 'spawn', 'send', 'tests', 'gates', 'docs', 'archive', 'leases', 'dev', 'tools']
36
+ // #121: the `tools` group is gone. Every capability that used to be a
37
+ // `tools.expose*` boolean is now a component row in `cordis.patch.yml`, and a
38
+ // component row is the switch. What remains here is genuine tuning policy.
39
+ const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'git', 'worktrees', 'spawn', 'send', 'tests', 'gates', 'docs', 'archive', 'leases', 'dev']
37
40
 
38
41
  const SECTION_KEYS = {
39
42
  init: ['autoGitInit', 'snapshotDirty', 'autoExclude'],
@@ -75,9 +78,8 @@ const SECTION_KEYS = {
75
78
  gates: ['requireChecks', 'requireTests', 'allowReviewExemption', 'maxReportBytes'],
76
79
  docs: ['board', 'boardLocation'],
77
80
  archive: ['enabled', 'afterDays', 'maxMissions'],
78
- tools: ['exposeOperational', 'exposeBatch', 'exposeImport', 'exposeLeases', 'exposeTests', 'exposeRecovery', 'exposeDoctor'],
79
81
  leases: ['ttlMs'],
80
- dev: ['hotReload', 'reloadDelayMs', 'reloadRequiredIds'],
82
+ dev: ['reloadDelayMs', 'reloadRequiredIds'],
81
83
  }
82
84
 
83
85
  const MODEL_KEYS = ['provider', 'model', 'reasoningEffort', 'maxTokens']
@@ -261,43 +263,62 @@ export const Config = Schema.object({
261
263
  })
262
264
  .default({})
263
265
  .description('Terminal-mission archival policy. Archived missions are moved, never deleted.'),
264
- tools: Schema.object({
265
- exposeOperational: Schema.boolean()
266
- .default(true)
267
- .description('Master switch for batch/resource/import/legacy/recover/test/doctor tools.'),
268
- exposeBatch: Schema.boolean().default(true).description('Register tower_batch.'),
269
- exposeImport: Schema.boolean().default(true).description('Register tower_import_legacy/tower_legacy_map.'),
270
- exposeLeases: Schema.boolean().default(true).description('Register tower_resource advisory leases.'),
271
- exposeTests: Schema.boolean().default(true).description('Register tower_test; gates.requireTests requires it.'),
272
- exposeRecovery: Schema.boolean().default(true).description('Register tower_recover (stale state-lock escape).'),
273
- exposeDoctor: Schema.boolean().default(true).description('Register tower_doctor (read-only health contract).'),
274
- })
275
- .default({})
276
- .description('Tool-surface policy.'),
277
266
  leases: Schema.object({
278
267
  ttlMs: Schema.natural().min(1000).max(86400000).default(900000).description('Default advisory lease lifetime; expiry never auto-releases.'),
279
268
  })
280
269
  .default({})
281
270
  .description('Advisory resource leases.'),
282
271
  dev: Schema.object({
283
- hotReload: Schema.boolean().default(true).description('Register tower_reload and /tower reload when the DSH HMR service is available.'),
284
272
  reloadDelayMs: Schema.natural().min(1).max(5000).default(50).description('Delay before a scheduled reload, so the tool result is delivered first.'),
285
273
  reloadRequiredIds: Schema.array(Schema.string())
286
- .default(['tower-bundle', 'tower', 'tower-skills', 'preset-tower-worker', 'preset-tower-reviewer'])
287
- .description('Loader entry ids passed to reconcileProfile as required.'),
274
+ .default(['tower', 'tower-host', 'preset-tower-worker'])
275
+ .description('Loader entry ids passed to reconcileProfile as required. Optional component rows are deliberately not required, so a row switched off is not a reload failure.'),
288
276
  })
289
277
  .default({})
290
278
  .description('Development and hot-reload behavior.'),
291
279
  }).description('DSH Tower configuration')
292
280
 
281
+ /**
282
+ * The user-facing config surface (#84): every leaf key one level deep plus the
283
+ * names of the object sections. The generated config reference, `tower_doctor`
284
+ * and the surface baseline all count this same shape, so "how many concepts"
285
+ * is one number rather than three near-identical ones.
286
+ *
287
+ * @param {any} [config] the Schemastery root (defaults to the live `Config`)
288
+ * @returns {{ keys: string[], sections: string[], count: number }}
289
+ */
290
+ export function configSurface(config = Config) {
291
+ const keys = []
292
+ const sections = []
293
+ for (const [key, schema] of Object.entries(config?.dict ?? {})) {
294
+ const dict = schema?.dict
295
+ if (dict && typeof dict === 'object') {
296
+ sections.push(key)
297
+ for (const child of Object.keys(dict)) keys.push(`${key}.${child}`)
298
+ } else {
299
+ keys.push(key)
300
+ }
301
+ }
302
+ return { keys, sections, count: keys.length }
303
+ }
304
+
305
+ /** @param {any} value */
293
306
  export function clone(value) {
294
307
  return value === undefined ? undefined : JSON.parse(JSON.stringify(value))
295
308
  }
296
309
 
310
+ /**
311
+ * @param {any} value
312
+ * @returns {boolean}
313
+ */
297
314
  export function isPlainObject(value) {
298
315
  return value !== null && typeof value === 'object' && !Array.isArray(value)
299
316
  }
300
317
 
318
+ /**
319
+ * @param {any} base
320
+ * @param {any} override
321
+ */
301
322
  export function deepMerge(base, override) {
302
323
  if (override === undefined) return clone(base) ?? {}
303
324
  if (!isPlainObject(base) || !isPlainObject(override)) return clone(override)
@@ -326,6 +347,10 @@ export function normalizeLegacy(raw = {}) {
326
347
  return out
327
348
  }
328
349
 
350
+ /**
351
+ * @param {any} value
352
+ * @param {string} label
353
+ */
329
354
  function assertModelKeys(value, label) {
330
355
  const bad = Object.keys(value).filter((key) => !MODEL_KEYS.includes(key))
331
356
  if (bad.length > 0) {
@@ -333,12 +358,16 @@ function assertModelKeys(value, label) {
333
358
  }
334
359
  }
335
360
 
361
+ /**
362
+ * @param {any} raw
363
+ * @param {string} label
364
+ */
336
365
  export function assertKnownKeys(raw, label) {
337
366
  const unknown = Object.keys(raw).filter((key) => !LEGACY_KEYS.includes(key) && !GROUP_KEYS.includes(key))
338
367
  if (unknown.length > 0) {
339
368
  throw new TowerToolError(
340
369
  'config-unknown-key',
341
- `${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, git?, worktrees?, spawn?, send?, tests?, gates?, docs?, archive?, leases?, dev?, tools? }`,
370
+ `${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, git?, worktrees?, spawn?, send?, tests?, gates?, docs?, archive?, leases?, dev? }`,
342
371
  )
343
372
  }
344
373
  for (const [group, allowed] of Object.entries(SECTION_KEYS)) {
@@ -363,7 +392,11 @@ export function assertKnownKeys(raw, label) {
363
392
 
364
393
  const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
365
394
 
366
- /** Reject prototype-pollution style keys anywhere in a repository config. */
395
+ /**
396
+ * Reject prototype-pollution style keys anywhere in a repository config.
397
+ * @param {any} value
398
+ * @param {string} label
399
+ */
367
400
  export function assertSafeKeys(value, label) {
368
401
  if (Array.isArray(value)) {
369
402
  for (const item of value) assertSafeKeys(item, label)
@@ -378,6 +411,7 @@ export function assertSafeKeys(value, label) {
378
411
  }
379
412
  }
380
413
 
414
+ /** @param {any} [rawConfig] */
381
415
  export function resolveTowerConfig(rawConfig) {
382
416
  const raw = rawConfig ?? {}
383
417
  if (raw !== null && typeof raw !== 'object') throw new TowerToolError('config-invalid', 'Tower config must be an object')
@@ -389,11 +423,12 @@ export function resolveTowerConfig(rawConfig) {
389
423
  throw new TowerToolError('config-invalid', `Tower config is invalid: ${String(error?.message ?? error)}`)
390
424
  }
391
425
  const mode = clone(parsed.mode ?? {})
392
- if (parsed.gates?.requireTests === true) {
393
- const testsAvailable = parsed.tests?.enabled !== false && parsed.tools?.exposeOperational !== false && parsed.tools?.exposeTests !== false
394
- if (!testsAvailable) {
395
- throw new TowerToolError('config-invalid', 'Tower config is invalid: gates.requireTests is true but test discovery is disabled (tests.enabled/tools.exposeTests)')
396
- }
426
+ if (parsed.gates?.requireTests === true && parsed.tests?.enabled === false) {
427
+ // #121: the tower-tests *component row* is the capability switch now; a
428
+ // repository that turns the row off cannot satisfy gates.requireTests. The
429
+ // component-level half of this invariant is enforced at plan/mode entry,
430
+ // where the mounted rows are known (host/presets.js).
431
+ throw new TowerToolError('config-invalid', 'Tower config is invalid: gates.requireTests is true but test discovery is disabled (tests.enabled:false)')
397
432
  }
398
433
  // Canonical keys win; the legacy top-level aliases only fill gaps.
399
434
  const rawMode = raw.mode ?? {}
@@ -420,7 +455,6 @@ export function resolveTowerConfig(rawConfig) {
420
455
  gates: clone(parsed.gates ?? {}),
421
456
  docs: clone(parsed.docs ?? {}),
422
457
  archive: clone(parsed.archive ?? {}),
423
- tools: clone(parsed.tools ?? {}),
424
458
  leases: clone(parsed.leases ?? {}),
425
459
  dev: clone(parsed.dev ?? {}),
426
460
  }
@@ -4,7 +4,12 @@ const S = {
4
4
  str: { type: 'string' },
5
5
  int: { type: 'integer' },
6
6
  bool: { type: 'boolean' },
7
+ /** @param {any} items */
7
8
  arr: (items) => ({ type: 'array', items }),
9
+ /**
10
+ * @param {Record<string, any>} [properties]
11
+ * @param {string[]} [required]
12
+ */
8
13
  obj: (properties = {}, required = []) => ({ type: 'object', properties, required, additionalProperties: true }),
9
14
  any: {},
10
15
  }
@@ -79,6 +84,7 @@ const SCHEMAS = {
79
84
  recentFindings: S.arr(S.obj()),
80
85
  missions: S.arr(S.obj()),
81
86
  activity: S.arr(S.obj()),
87
+ gitLock: S.obj({ held: S.bool, pid: S.any, phase: S.any, heldMs: S.any }),
82
88
  mode: S.any,
83
89
  spawn: S.any,
84
90
  towerAgentId: S.any,
@@ -169,7 +175,7 @@ const SCHEMAS = {
169
175
  ['counts', 'total'],
170
176
  ),
171
177
  review: S.obj(
172
- { review: S.obj(), branchTip: S.str, missionStatus: S.str, round: S.int, reviewerRounds: S.int },
178
+ { review: S.obj(), branchTip: S.str, missionStatus: S.str, round: S.int, appended: S.bool, reviewerRounds: S.int },
173
179
  ['review', 'branchTip', 'missionStatus', 'round', 'reviewerRounds'],
174
180
  ),
175
181
  preflight: S.obj(
@@ -243,6 +249,8 @@ const SCHEMAS = {
243
249
  {
244
250
  ok: S.bool,
245
251
  checks: S.arr(S.obj()),
252
+ stateLock: S.any,
253
+ gitLock: S.any,
246
254
  stateSchema: S.obj({
247
255
  currentVersion: S.any,
248
256
  supportedVersion: S.int,
@@ -250,6 +258,7 @@ const SCHEMAS = {
250
258
  status: S.str,
251
259
  }),
252
260
  reports: S.any,
261
+ surface: S.any,
253
262
  archive: S.obj({
254
263
  missions: S.any,
255
264
  archivedMissions: S.int,
@@ -266,7 +275,7 @@ const SCHEMAS = {
266
275
  ['ok', 'checks'],
267
276
  ),
268
277
  recover: S.obj(
269
- { ok: S.bool, action: S.str, stateLock: S.any, cleared: S.bool, backup: S.any, error: S.str },
278
+ { ok: S.bool, action: S.str, stateLock: S.any, cleared: S.bool, backup: S.any, forced: S.bool, error: S.str },
270
279
  ['ok', 'action'],
271
280
  ),
272
281
  test: S.obj(
@@ -294,10 +303,15 @@ const SCHEMAS = {
294
303
  legacyMap: S.obj({ ok: S.bool, map: S.obj(), maps: S.arr(S.obj()) }, ['ok']),
295
304
  }
296
305
 
306
+ /**
307
+ * @param {any} _args
308
+ * @param {any} value
309
+ */
297
310
  function renderValue(_args, value) {
298
311
  return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
299
312
  }
300
313
 
314
+ /** @param {any} schema */
301
315
  function output(schema) {
302
316
  return { schema, render: renderValue }
303
317
  }
@@ -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,11 +3,33 @@
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 {
27
+ /**
28
+ * @param {string[]} args
29
+ * @param {string} detail
30
+ * @param {number | undefined} code the child-process errno, not a Tower code
31
+ * @param {{ timedOut?: boolean, bufferOverflow?: boolean, stdout?: string }} [meta]
32
+ */
11
33
  constructor(args, detail, code, meta = {}) {
12
34
  super(`git ${args.join(' ')} failed: ${detail}`)
13
35
  this.name = 'GitError'
@@ -18,74 +40,317 @@ export class GitError extends Error {
18
40
  }
19
41
  }
20
42
 
43
+ /**
44
+ * @typedef {object} ErrorCodeEntry
45
+ * @property {string} description
46
+ * @property {string} trigger
47
+ * @property {boolean} retryable
48
+ * @property {string | null} nextAction
49
+ */
50
+
51
+ /**
52
+ * One stable entry per failure family. `description` and `trigger` are the
53
+ * documented meaning; `retryable`/`nextAction` are the recovery contract the
54
+ * tool boundary renders.
55
+ * @type {Record<string, ErrorCodeEntry>}
56
+ */
21
57
  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.',
58
+ 'invalid-argument': {
59
+ description: 'A tool argument is missing, malformed, or not allowed.',
60
+ trigger: '`missionId`/`id` omitted; unknown status; empty report; bad scope glob; `timeoutMs < 1000`; `status: "all"` on an update.',
61
+ retryable: false,
62
+ nextAction: null,
63
+ },
64
+ 'repo-invalid': {
65
+ description: 'repoRoot is missing, relative, or not inside a Git repository.',
66
+ trigger: 'passing a path in a directory that is not a repo',
67
+ retryable: false,
68
+ nextAction: 'pass an absolute path to a directory inside a Git repository',
69
+ },
70
+ 'not-initialized': {
71
+ description: 'Tower is not initialized for this repository; run tower_init.',
72
+ trigger: 'a read tool before `tower_init`',
73
+ retryable: false,
74
+ nextAction: 'run tower_init for this repository',
75
+ },
76
+ 'mission-not-found': {
77
+ description: 'No mission with that id exists.',
78
+ trigger: 'a stale or mistyped `M9`',
79
+ retryable: false,
80
+ nextAction: 'list missions with tower_status',
81
+ },
82
+ 'finding-not-found': {
83
+ description: 'No finding with that id exists.',
84
+ trigger: '`tower_findings({id:"F99"})`',
85
+ retryable: false,
86
+ nextAction: 'list findings with tower_findings',
87
+ },
88
+ 'invalid-transition': {
89
+ description: 'The requested state transition is not allowed.',
90
+ 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.',
91
+ retryable: false,
92
+ nextAction: null,
93
+ },
94
+ 'stale-base': {
95
+ description: 'The repository state changed while the operation was being prepared; retry.',
96
+ trigger: 'two-phase mutation revalidation',
97
+ retryable: true,
98
+ nextAction: 'retry the tool; if it keeps failing, run tower_preflight and inspect tower_status',
99
+ },
100
+ 'state-locked': {
101
+ description: 'Another Tower writer holds the state lock.',
102
+ trigger: 'concurrent mutation after the lock timeout',
103
+ retryable: true,
104
+ nextAction: 'wait for the other Tower writer to finish, then retry',
105
+ },
106
+ 'state-corrupt': {
107
+ description: 'The Tower state file is unreadable or invalid.',
108
+ trigger: 'hand-edited `state.json`; invalid JSON; no usable `schemaVersion`',
109
+ retryable: false,
110
+ nextAction: 'restore state.json from a backup, or re-initialize after filing the corruption',
111
+ },
112
+ 'state-version-unsupported': {
113
+ description: 'The Tower state file uses a schema version this build cannot read or downgrade.',
114
+ trigger: 'opening a repository written by a newer plugin; an unknown older version; this build refuses to downgrade',
115
+ retryable: false,
116
+ nextAction: 'upgrade dsh-tower to a release that supports this state schemaVersion',
117
+ },
118
+ 'state-migration-failed': {
119
+ description: 'A Tower state migration failed; the original state file was not modified.',
120
+ trigger: 'a bug in a migration step; see the pre-migration `state.json.v<N>.bak`',
121
+ retryable: false,
122
+ nextAction: 'restore the pre-migration backup state.json.v<N>.bak and report the migration bug',
123
+ },
124
+ 'config-invalid': {
125
+ description: 'Tower configuration is invalid.',
126
+ trigger: 'malformed repo `config.json`, a value that violates the schema',
127
+ retryable: false,
128
+ nextAction: 'fix the repository config file, then retry',
129
+ },
130
+ 'config-unknown-key': {
131
+ description: 'Tower configuration has an unknown top-level key.',
132
+ trigger: 'a typo such as `allowAgentToggle`',
133
+ retryable: false,
134
+ nextAction: 'remove the unknown key from the repository config file, then retry',
135
+ },
136
+ 'git-failed': {
137
+ description: 'A git command failed.',
138
+ trigger: 'a rejected merge command, invalid ref',
139
+ retryable: false,
140
+ nextAction: null,
141
+ },
142
+ 'git-timeout': {
143
+ description: 'A git command timed out or exceeded its output buffer.',
144
+ trigger: 'slow pre-merge hook, huge diff',
145
+ retryable: true,
146
+ nextAction: 'retry; if it persists, raise git.timeoutMs or merge.timeoutMs',
147
+ },
148
+ 'worktree-dirty': {
149
+ description: 'The mission worktree has uncommitted changes and cannot complete.',
150
+ trigger: 'staged, modified, or untracked files left behind before `tower_complete`',
151
+ retryable: false,
152
+ nextAction: 'commit or remove the worktree changes, then retry tower_complete',
153
+ },
154
+ 'spawn-unavailable': {
155
+ description: 'The subagents service is unavailable.',
156
+ trigger: 'a Host without `subagents`',
157
+ retryable: false,
158
+ nextAction: 'enable the DSH subagents service, then retry',
159
+ },
160
+ 'provider-missing': {
161
+ description: 'No subagent provider is registered.',
162
+ trigger: 'an empty provider list',
163
+ retryable: false,
164
+ nextAction: 'register a subagent provider in the DSH profile, then retry',
165
+ },
166
+ 'provider-cooldown': {
167
+ description: 'The provider is throttled; spawn is in a short cooldown.',
168
+ trigger: 'repeated retryable provider failures; wait for `blockedUntil`',
169
+ retryable: true,
170
+ nextAction: 'wait for the cooldown to expire, then retry',
171
+ },
172
+ 'preset-missing': {
173
+ description: 'The configured agent preset is not registered.',
174
+ trigger: 'missing `tower-worker`/`tower-reviewer` preset rows, DSH not restarted',
175
+ retryable: false,
176
+ nextAction: 'install the tower-worker and tower-reviewer agent presets and restart DSH',
177
+ },
178
+ 'reviewer-prerequisite': {
179
+ description: 'The reviewer spawn prerequisite is not met.',
180
+ trigger: 'reviewer spawned before the mission is `completed`, or before a worktree exists',
181
+ retryable: false,
182
+ nextAction: 'complete the mission first, then spawn the reviewer',
183
+ },
184
+ 'not-authorized': {
185
+ description: 'The calling agent role may not perform this action.',
186
+ trigger: 'a worker calling a tower-only tool or changing mission status/scope; a reviewer mutating findings',
187
+ retryable: false,
188
+ nextAction: 'call the tool as the tower, or as the worker/reviewer it is assigned to',
189
+ },
190
+ 'workspace-busy': {
191
+ description: 'Another Tower session owns this repository workspace lock.',
192
+ trigger: '`mode.workspaceLock:true` and a second session calls a repo-scoped tool',
193
+ retryable: true,
194
+ nextAction: 'run /tower off in the other session or wait for its lock to become stale',
195
+ },
196
+ 'worker-limit': {
197
+ description: 'The repository reached spawn.maxLiveWorkers live workers.',
198
+ trigger: 'spawning a fifth worker with the default limit',
199
+ retryable: false,
200
+ nextAction: 'wait for a live worker to finish or raise spawn.maxLiveWorkers',
201
+ },
202
+ 'rate-limited': {
203
+ description: 'The tower_send per-minute rate limit was reached.',
204
+ trigger: '`send.maxPerMinute` exceeded',
205
+ retryable: true,
206
+ nextAction: 'wait for the next per-minute window or raise send.maxPerMinute',
207
+ },
208
+ 'takeover-refused': {
209
+ description: 'The mission already has a live worker that was not stopped first.',
210
+ trigger: '`tower_spawn({takeover:true})` before `tower_interrupt`',
211
+ retryable: false,
212
+ nextAction: 'run tower_interrupt for the live worker, then retry the takeover',
213
+ },
214
+ 'reload-unavailable': {
215
+ description: 'The DSH HMR service is not available in this profile.',
216
+ trigger: '`tower_reload` / `/tower reload` on a profile without HMR',
217
+ retryable: false,
218
+ nextAction: 'restart DSH instead of reloading',
219
+ },
220
+ 'reload-failed': {
221
+ description: 'The HMR reload failed; check the DSH log and restart if needed.',
222
+ trigger: '`reconcileProfile` threw',
223
+ retryable: false,
224
+ nextAction: 'check the DSH log and restart DSH',
225
+ },
226
+ 'tests-failed': {
227
+ description: 'Declared test discovery failed or did not meet its admission requirements.',
228
+ trigger: 'nonzero command, malformed output, minCount/required shortfall',
229
+ retryable: false,
230
+ nextAction: 'fix the failing tests, then retry tower_complete',
231
+ },
232
+ 'tests-timeout': {
233
+ description: 'Test discovery exceeded its time or output budget.',
234
+ trigger: '`tests.timeoutMs`, or stdout over `tests.maxBytes`',
235
+ retryable: false,
236
+ nextAction: 'raise tests.timeoutMs or tests.maxBytes (or speed up the suite), then retry',
237
+ },
238
+ 'lease-busy': {
239
+ description: 'Another holder owns the requested advisory lease.',
240
+ trigger: '`tower_resource acquire` while `heavy-ci` is held',
241
+ retryable: false,
242
+ nextAction: 'release the lease or wait for it to expire, then retry',
243
+ },
244
+ 'lease-token': {
245
+ description: 'The lease release token does not match the current holder.',
246
+ trigger: 'releasing with another session\'s token',
247
+ retryable: false,
248
+ nextAction: 'use the token returned when the lease was acquired',
249
+ },
250
+ 'batch-invalid': {
251
+ description: 'The batch request or its close evidence is invalid.',
252
+ trigger: 'unknown member, duplicate id, unsafe receipt path, missing receipt hash',
253
+ retryable: false,
254
+ nextAction: 'fix the batch request or the receipt hashes, then retry',
255
+ },
256
+ 'legacy-invalid': {
257
+ description: 'The legacy snapshot or mapping is invalid.',
258
+ trigger: 'unknown schema, source inside the Tower state dir, mapping rewrite',
259
+ retryable: false,
260
+ nextAction: 'check the legacy snapshot path, schema and mapping, then retry',
261
+ },
262
+ 'tool-internal': {
263
+ description: 'An unexpected failure; read the message and the activity log.',
264
+ trigger: 'a bug; read the message and the activity log',
265
+ retryable: false,
266
+ nextAction: null,
267
+ },
57
268
  }
58
269
 
59
270
  export const ERROR_CODE_VALUES = Object.freeze(Object.keys(ERROR_CODES))
271
+
272
+ /** @param {unknown} value */
60
273
  export function isErrorCode(value) {
61
274
  return typeof value === 'string' && Object.hasOwn(ERROR_CODES, value)
62
275
  }
63
276
 
64
- const RETRYABLE_CODES = new Set(['state-locked', 'rate-limited', 'provider-cooldown', 'git-timeout', 'workspace-busy', 'merge-intent-pending', 'stale-base'])
277
+ /** @param {string} code */
278
+ export function errorEntry(code) {
279
+ return isErrorCode(code) ? ERROR_CODES[code] : null
280
+ }
281
+
282
+ /** @param {string} code */
283
+ export function errorDescription(code) {
284
+ return errorEntry(code)?.description ?? ''
285
+ }
286
+
287
+ /** @param {string} code */
288
+ export function errorRetryable(code) {
289
+ return errorEntry(code)?.retryable === true
290
+ }
291
+
292
+ /** @param {string} code */
293
+ export function errorNextAction(code) {
294
+ return errorEntry(code)?.nextAction ?? null
295
+ }
296
+
297
+ /**
298
+ * The model-visible message. Exactly one trailing ` — next: …` segment is
299
+ * appended when there is a recovery step; a retryable code marks the same
300
+ * segment with ` (retryable)`. A message that already carries the action is
301
+ * never suffixed twice.
302
+ * @param {string} code
303
+ * @param {string} message
304
+ * @param {{ nextAction?: string | null, retryable?: boolean }} [options]
305
+ * @returns {string}
306
+ */
307
+ export function renderErrorMessage(code, message, options = {}) {
308
+ const text = String(message)
309
+ const base = `[tower:${code}] ${text}`
310
+ const nextAction = options.nextAction ?? errorNextAction(code)
311
+ if (nextAction === null || nextAction === undefined) return base
312
+ if (text.includes(nextAction) || text.includes('— next:')) return base
313
+ const retryable = options.retryable ?? errorRetryable(code)
314
+ return `${base} — next: ${nextAction}${retryable ? ' (retryable)' : ''}`
315
+ }
65
316
 
66
317
  export class TowerToolError extends Error {
318
+ /**
319
+ * @param {string} code
320
+ * @param {string} message
321
+ * @param {{ cause?: unknown, retryable?: boolean, nextAction?: string | null, details?: unknown }} [options]
322
+ */
67
323
  constructor(code, message, options = {}) {
68
- super(`[tower:${code}] ${message}`, options.cause ? { cause: options.cause } : undefined)
324
+ const entry = errorEntry(code)
325
+ const retryable = options.retryable ?? entry?.retryable === true
326
+ const nextAction = options.nextAction ?? entry?.nextAction ?? null
327
+ super(renderErrorMessage(code, message, { nextAction, retryable }), options.cause ? { cause: options.cause } : undefined)
69
328
  this.name = 'TowerToolError'
70
329
  this.code = code
71
- this.retryable = options.retryable ?? RETRYABLE_CODES.has(code)
72
- this.nextAction = options.nextAction ?? null
330
+ this.retryable = retryable
331
+ this.nextAction = nextAction
73
332
  this.details = options.details ?? null
74
333
  }
75
334
  }
76
335
 
336
+ /** @param {any} error */
77
337
  function originalMessage(error) {
78
338
  return String(error?.message ?? error)
79
339
  }
80
340
 
81
- /** Wrap any thrown value into a TowerToolError with a stable code. */
341
+ /**
342
+ * Wrap any thrown value into a TowerToolError with a stable code.
343
+ * @param {any} error
344
+ */
82
345
  export function classifyError(error) {
83
346
  if (error instanceof TowerToolError) return error
84
347
  if (error && typeof error.code === 'string' && ERROR_CODES[error.code]) {
85
348
  return new TowerToolError(error.code, originalMessage(error), {
86
349
  cause: error,
87
- retryable: error.retryable === true,
88
- nextAction: error.nextAction ?? null,
350
+ // Let the catalog supply a default when the thrown object does not
351
+ // explicitly override the recovery metadata.
352
+ retryable: typeof error.retryable === 'boolean' ? error.retryable : undefined,
353
+ nextAction: error.nextAction ?? undefined,
89
354
  details: error.details ?? null,
90
355
  })
91
356
  }