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
package/app/tower.js CHANGED
@@ -1,12 +1,14 @@
1
1
  // Tower domain: repo-scoped mission state machine, validation, and gates.
2
+ //
3
+ // #69: the app layer depends on the ports in ports/ and receives their
4
+ // implementations through the deps object assembled by the composition root.
5
+ // It imports no adapter and no infrastructure module.
2
6
  import path from 'node:path'
3
- import { fsp } from '../adapters/store-fs.js'
4
- import { defaultDeps } from '../adapters/default.js'
7
+ import { assertPorts } from '../ports/index.js'
5
8
  import { validateCreate as validateBatchCreate, validateClose as validateBatchClose, summarize as summarizeBatch } from '../domain/batch.js'
6
- import { assertOwnership } from '../lib/worktreeOwnership.js'
7
9
 
8
- import { runtimeConfig, resolveRepoConfig } from '../lib/config.js'
9
10
  import { rebaseWaived } from '../domain/gate.js'
11
+ import { DEFAULT_MERGE_TIMEOUT_MS, gitLockTimeoutMs } from '../domain/lockBudget.js'
10
12
  export { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
11
13
 
12
14
  export { fileInScope, scopesOverlap, validateTitle, normalizeScope } from '../domain/scope.js'
@@ -14,6 +16,8 @@ export { missionSummary } from '../domain/summary.js'
14
16
 
15
17
  export { MISSION_STATUSES, SCHEMA_VERSION, MERGE_VERDICTS, FINDING_TYPES, FINDING_SEVERITIES, FINDING_STATUSES, FINDING_TRANSITIONS } from '../domain/mission.js'
16
18
  import { TowerToolError } from '../domain/errors.js'
19
+ import { buildMissionEvent } from '../domain/events.js'
20
+ import { applyLandedMerge } from '../domain/integration/merge.js'
17
21
  import { plan } from './plan.js'
18
22
  import { updateMission } from './mission.js'
19
23
  import { checkCompletion, complete } from './checkCompletion.js'
@@ -26,12 +30,11 @@ import { recordTestRun, recordLegacySnapshot, recordLegacyMap } from './recordin
26
30
 
27
31
  export const ROLES = ['worker', 'reviewer']
28
32
  const MAX_REVIEW_ROUNDS = 5
29
- const DEFAULT_MERGE_TIMEOUT_MS = 15 * 60 * 1000
30
33
  const MERGE_MAX_BUFFER = 64 * 1024 * 1024
31
34
 
32
35
  function mergeTimeoutMs(value, config) {
33
36
  if (value === undefined || value === null) {
34
- const configured = (config ?? runtimeConfig()).merge.timeoutMs
37
+ const configured = (config ?? {}).merge?.timeoutMs
35
38
  return Number.isInteger(configured) && configured >= 1000 ? configured : DEFAULT_MERGE_TIMEOUT_MS
36
39
  }
37
40
  const ms = Number(value)
@@ -39,8 +42,8 @@ function mergeTimeoutMs(value, config) {
39
42
  return ms
40
43
  }
41
44
 
42
- async function commitParents(cwd, sha) {
43
- const line = await defaultDeps.vcs.tryGit(cwd, ['rev-list', '--parents', '-n', '1', sha])
45
+ async function commitParents(tower, cwd, sha) {
46
+ const line = await tower.deps.vcs.tryGit(cwd, ['rev-list', '--parents', '-n', '1', sha])
44
47
  return line ? line.trim().split(/\s+/).slice(1) : []
45
48
  }
46
49
 
@@ -49,10 +52,10 @@ async function commitParents(cwd, sha) {
49
52
  * `--no-ff` merges put the integrated branch on the second parent, so this is
50
53
  * the concrete evidence that a mission's work already landed in the base.
51
54
  */
52
- async function findLandedMerge(cwd, baseTip, tips) {
53
- const revs = (await defaultDeps.vcs.tryGit(cwd, ['rev-list', '--merges', baseTip])) ?? ''
55
+ async function findLandedMerge(tower, cwd, baseTip, tips) {
56
+ const revs = (await tower.deps.vcs.tryGit(cwd, ['rev-list', '--merges', baseTip])) ?? ''
54
57
  for (const sha of revs.split('\n').filter(Boolean)) {
55
- const parents = await commitParents(cwd, sha)
58
+ const parents = await commitParents(tower, cwd, sha)
56
59
  if (parents.length >= 2 && tips.has(parents[1])) return { commit: sha, parents }
57
60
  }
58
61
  return null
@@ -65,14 +68,14 @@ function describeGitFailure(error) {
65
68
 
66
69
  /** Remove only lock files that are provably old. A fresh lock may belong to a
67
70
  * concurrent Git process; deleting it would let two writers into the index. */
68
- async function removeStaleGitLocks(commonDir, maxAgeMs = 60000) {
71
+ async function removeStaleGitLocks(tower, commonDir, maxAgeMs = 60000) {
69
72
  const removed = []
70
73
  for (const name of ['index.lock', 'HEAD.lock', 'MERGE_HEAD.lock', 'ORIG_HEAD.lock']) {
71
74
  const file = path.join(commonDir, name)
72
75
  try {
73
- const stat = await fsp.stat(file)
76
+ const stat = await tower.deps.store.stat(file)
74
77
  if (Date.now() - stat.mtimeMs < maxAgeMs) continue
75
- await fsp.unlink(file)
78
+ await tower.deps.store.unlink(file)
76
79
  removed.push(name)
77
80
  } catch {
78
81
  // absent or in use; never force it
@@ -86,7 +89,6 @@ function fail(message, code = 'invalid-argument') {
86
89
  }
87
90
 
88
91
  import { validateState } from '../domain/state.js'
89
- import { loadStateFile } from '../lib/stateFile.js'
90
92
  import { boardMarkdown } from '../domain/report.js'
91
93
  import { diff, missionDetail, status } from './read.js'
92
94
  import { evaluateGate } from './gate.js'
@@ -106,28 +108,59 @@ export class Tower {
106
108
  /**
107
109
  * @param {{ root: string, commonDir: string, branch?: string | null }} repo
108
110
  * @param {import('../types/index.js').TowerConfig} [config]
111
+ * @param {Record<string, any>} [deps] ports assembled by the composition root
112
+ * (the host entry); required.
109
113
  */
110
- constructor(repo, config, deps = defaultDeps) {
114
+ constructor(repo, config, deps) {
115
+ assertPorts(deps)
111
116
  this.root = repo.root
112
117
  this.commonDir = repo.commonDir
113
118
  this.branch = repo.branch
114
- this.deps = deps
119
+ this.deps = /** @type {Record<string, any>} */ (deps)
115
120
  this.paths = this.deps.store.pathsFor(repo.commonDir)
116
121
  // Effective config for this repository: code defaults < profile config <
117
122
  // repo config file. Per-call tool arguments are applied by the caller.
118
- this.config = /** @type {import('../types/index.js').TowerConfig} */ (config ?? { ...runtimeConfig(), configPath: null, repoConfigPresent: false })
123
+ this.config = /** @type {import('../types/index.js').TowerConfig} */ (
124
+ config ?? { ...this.deps.config.runtimeConfig(), configPath: null, repoConfigPresent: false }
125
+ )
119
126
  }
120
127
 
121
- static async open(repoRoot) {
122
- const repo = await defaultDeps.vcs.resolveRepo(repoRoot)
123
- const config = await resolveRepoConfig(repo)
124
- return new Tower(repo, config)
128
+ /**
129
+ * Resolve the repository and config through the injected ports.
130
+ * @param {string} repoRoot
131
+ * @param {Record<string, any>} deps
132
+ */
133
+ static async open(repoRoot, deps) {
134
+ assertPorts(deps)
135
+ const repo = await deps.vcs.resolveRepo(repoRoot)
136
+ const config = await deps.config.resolveRepoConfig(repo)
137
+ return new Tower(repo, config, deps)
125
138
  }
126
139
 
127
140
  git(cwd, args, options = {}) {
128
141
  return this.deps.vcs.git(cwd, args, { timeoutMs: this.config.git?.timeoutMs, ...options })
129
142
  }
130
143
 
144
+ /**
145
+ * Options for one git-mutex section: the phase, the wait budget derived from
146
+ * the merge timeout, and the injected clock so waiting is testable in virtual
147
+ * time. The budget itself is the pure domain policy in ../domain/lockBudget.js
148
+ * (#93); this method only adds the Tower config and the injected clock.
149
+ * @param {{ phase?: string, mergeTimeoutMs?: number, timeoutMs?: number, now?: Function }} [options]
150
+ * @returns {Record<string, any>}
151
+ */
152
+ gitMutexOptions(options = {}) {
153
+ const { phase, mergeTimeoutMs, timeoutMs, ...rest } = options
154
+ const clock = this.deps.clock
155
+ const clockNow = clock && typeof clock.now === 'function' ? clock.now.bind(clock) : null
156
+ return {
157
+ ...rest,
158
+ ...(phase ? { phase } : {}),
159
+ timeoutMs: timeoutMs ?? gitLockTimeoutMs(this.config, mergeTimeoutMs),
160
+ ...(clockNow ? { now: clockNow } : {}),
161
+ }
162
+ }
163
+
131
164
  tryGit(cwd, args, options = {}) {
132
165
  return this.deps.vcs.tryGit(cwd, args, { timeoutMs: this.config.git?.timeoutMs, ...options })
133
166
  }
@@ -154,7 +187,7 @@ export class Tower {
154
187
  if (!(await this.deps.store.exists(this.paths.state))) {
155
188
  fail(`tower is not initialized for ${this.root} — run tower_init first`, 'not-initialized')
156
189
  }
157
- const loaded = await loadStateFile({
190
+ const loaded = await this.deps.state.loadStateFile({
158
191
  store: this.deps.store,
159
192
  paths: this.paths,
160
193
  now: this.deps.clock?.now ? this.deps.clock.now() : Date.now(),
@@ -169,6 +202,27 @@ export class Tower {
169
202
  return init(this, input)
170
203
  }
171
204
 
205
+ /**
206
+ * Persist-then-emit (#75): the caller has already written `state.json` and the
207
+ * activity log, so each domain transition descriptor is finalised with the
208
+ * persisted revision and emitted through the event port. The event channel is
209
+ * best effort — a listener (or an embedder without one) never fails the tool.
210
+ * @param {Array<{ type: string, missionId: string, commit?: string }>} descriptors
211
+ * @param {{ revision: number, at: number }} context
212
+ */
213
+ emitEvents(descriptors, context) {
214
+ const events = this.deps.events
215
+ if (!events || typeof events.emit !== 'function') return
216
+ for (const descriptor of descriptors ?? []) {
217
+ try {
218
+ const event = buildMissionEvent(/** @type {any} */ (descriptor), context)
219
+ events.emit(event.name, event.payload)
220
+ } catch {
221
+ // events describe a fact that is already durable; never fail the tool
222
+ }
223
+ }
224
+ }
225
+
172
226
  async plan(input = {}) {
173
227
  return plan(this, input)
174
228
  }
@@ -194,7 +248,7 @@ export class Tower {
194
248
 
195
249
  async assertWorktree(worktree) {
196
250
  if (!worktree || typeof worktree.path !== 'string') fail('mission has no worktree')
197
- const owned = await assertOwnership({
251
+ const owned = await this.deps.ownership.assertOwnership({
198
252
  storedPath: worktree.path,
199
253
  storedRealPath: worktree.realPath,
200
254
  branch: worktree.branch,
@@ -361,14 +415,14 @@ export class Tower {
361
415
  await this.deps.store.ensureDir(path.dirname(file))
362
416
  let text = ''
363
417
  try {
364
- text = await fsp.readFile(file, 'utf8')
418
+ text = await this.deps.store.readText(file)
365
419
  } catch {
366
420
  text = ''
367
421
  }
368
422
  const lines = text.split(/\r?\n/)
369
423
  if (lines.includes(pattern) || lines.includes(`${pattern}/`)) return { inside: true, excluded: true, pattern }
370
424
  const suffix = text === '' || text.endsWith('\n') ? '' : '\n'
371
- await fsp.appendFile(file, `${suffix}${pattern}\n`, 'utf8')
425
+ await this.deps.store.appendText(file, `${suffix}${pattern}\n`)
372
426
  return { inside: true, excluded: true, pattern }
373
427
  }
374
428
 
@@ -426,6 +480,7 @@ export class Tower {
426
480
  landed = null // no merge commit was created; the intent is safe to drop
427
481
  } else {
428
482
  landed = await findLandedMerge(
483
+ this,
429
484
  this.root,
430
485
  head,
431
486
  new Set([intent.branchTip, intent.baseTip].filter(Boolean)),
@@ -489,20 +544,18 @@ export class Tower {
489
544
  return null
490
545
  }
491
546
  const tips = new Set([branchTip, reviewedTip].filter(Boolean))
492
- const found = await findLandedMerge(this.root, baseTip, tips)
547
+ const found = await findLandedMerge(this, this.root, baseTip, tips)
493
548
  const mergedCommit = found ? found.commit : branchTip
494
549
  const now = Date.now()
495
550
  if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
496
- mission.status = 'merged'
497
- mission.mergedAt = now
498
- mission.mergedCommit = mergedCommit
499
- mission.blockers = []
500
- mission.notes.push(
501
- found
551
+ const applied = applyLandedMerge({
552
+ mission,
553
+ mergedCommit,
554
+ note: found
502
555
  ? `reconciled already-landed merge ${mergedCommit.slice(0, 7)} with parents [${found.parents.map((parent) => parent.slice(0, 7)).join(', ')}]`
503
556
  : `reconciled already-landed merge: ${mergedCommit.slice(0, 7)} is contained in ${state.base} (no merge commit found)`,
504
- )
505
- mission.updatedAt = now
557
+ now,
558
+ })
506
559
  state.revision += 1
507
560
  state.updatedAt = now
508
561
  validateState(state, this.paths.state)
@@ -513,6 +566,7 @@ export class Tower {
513
566
  missionId: mission.id,
514
567
  text: `reconciled already-landed ${mergedCommit.slice(0, 7)} on ${state.base}`,
515
568
  })
569
+ this.emitEvents(applied.events, { revision: state.revision, at: now })
516
570
  return {
517
571
  ok: true,
518
572
  reconciled: true,
package/cordis.patch.yml CHANGED
@@ -1,13 +1,25 @@
1
- # dsh-tower is an all-or-nothing bundle. The single group row is the only
2
- # user-visible component: disabling it disables every internal section (host,
3
- # skills, worker/reviewer presets) together, so no orphan preset or skill can
4
- # point at missing tower_* tools. See docs/architecture/overview.md.
1
+ # dsh-tower component rows (#121).
2
+ #
3
+ # The first top-level row is the mandatory host group: the Host plugin and its
4
+ # worker-preset dependency. Turning it off equals uninstalling Tower (no tools,
5
+ # no presets, no skills), which is the accepted all-off boundary; every other
6
+ # top-level row is an individually switchable optional capability whose off
7
+ # state leaves the rest of Tower working.
8
+ #
9
+ # Policy: principle 11 in PRINCIPLES.md + docs/adr/0005-component-surface.md.
10
+ # The per-key answer is docs/reference/component-surface.md, generated from
11
+ # scripts/component-surface.json. `npm run docs:check:components` refuses a new
12
+ # switch, a missing locale label, or a moved component count without a written
13
+ # decision. `tower_doctor` and `tower_recover` are part of the host and are never
14
+ # switches (#122); a capability row owns exactly one optional capability, and
15
+ # `tower_status` / `tower_doctor` name any row that is off.
5
16
  - insert:
6
- - id: tower-bundle
17
+ # Mandatory host anchor. Not an optional capability: off == uninstall.
18
+ - id: tower
7
19
  name: cordis:group
8
20
  group: true
9
21
  config:
10
- - id: tower
22
+ - id: tower-host
11
23
  name: 'dsh-tower'
12
24
  config:
13
25
  mode:
@@ -21,8 +33,6 @@
21
33
  interruptDefault: true
22
34
  spawn:
23
35
  staleMs: 15000
24
- - id: tower-skills
25
- name: 'dsh-tower/skills'
26
36
  - id: preset-tower-worker
27
37
  name: '@deepseek-ai/dsh-agent-preset'
28
38
  config:
@@ -41,23 +51,41 @@
41
51
  sampleOverCapGlobResults: false
42
52
  - id: tower-worker-pwsh
43
53
  name: '@deepseek-ai/dsh-tool-pwsh'
44
- - id: preset-tower-reviewer
45
- name: '@deepseek-ai/dsh-agent-preset'
46
- config:
47
- id: tower-reviewer
48
- name: Tower Reviewer
49
- description: Read-only independent reviewer for a completed Tower mission.
50
- order: 61
51
- plugins:
52
- - id: tower-reviewer-policy
53
- name: 'dsh-tower/reviewer'
54
- - id: tower-reviewer-fs
55
- name: '@deepseek-ai/dsh-tool-fs'
56
- config:
57
- write: false
58
- edit: false
59
- readImage: false
60
- - id: tower-reviewer-fs-search
61
- name: '@deepseek-ai/dsh-tool-fs-search'
62
- config:
63
- sampleOverCapGlobResults: false
54
+ # Optional capabilities: each row is the switch for exactly one capability.
55
+ - id: tower-skills
56
+ name: 'dsh-tower/skills'
57
+ - id: tower-batch
58
+ name: 'dsh-tower/batch'
59
+ - id: tower-import
60
+ name: 'dsh-tower/import'
61
+ - id: tower-leases
62
+ name: 'dsh-tower/leases'
63
+ - id: tower-tests
64
+ name: 'dsh-tower/tests'
65
+ # dev hot reload: shipped off. Enable the row for a linked dev profile.
66
+ - id: tower-hmr
67
+ name: 'dsh-tower/hmr'
68
+ disabled: true
69
+ # Conditional optional component: off is supported only while
70
+ # review.requireReview is false; with the gate on planning/mode entry refuse
71
+ # the combination with an explanatory error.
72
+ - id: preset-tower-reviewer
73
+ name: '@deepseek-ai/dsh-agent-preset'
74
+ config:
75
+ id: tower-reviewer
76
+ name: Tower Reviewer
77
+ description: Read-only independent reviewer for a completed Tower mission.
78
+ order: 61
79
+ plugins:
80
+ - id: tower-reviewer-policy
81
+ name: 'dsh-tower/reviewer'
82
+ - id: tower-reviewer-fs
83
+ name: '@deepseek-ai/dsh-tool-fs'
84
+ config:
85
+ write: false
86
+ edit: false
87
+ readImage: false
88
+ - id: tower-reviewer-fs-search
89
+ name: '@deepseek-ai/dsh-tool-fs-search'
90
+ config:
91
+ sampleOverCapGlobResults: false
package/docs/README.md CHANGED
@@ -8,8 +8,8 @@ Choose a path:
8
8
  |---|---|
9
9
  | New user | [When to use Tower](guide/scenarios-and-philosophy.md) → [Quick start](guide/quickstart.md) → [Tools](reference/tools.md) → [Example](examples/first-mission.md) |
10
10
  | Choosing a workflow | [When to use Tower, and how it differs from Agent Teams, Ralph, Workflow and parallel subagents](guide/scenarios-and-philosophy.md) |
11
- | Operator | [Runbook](operations/runbook.md) → [Blockers](reference/blockers.md) → [Error codes](reference/errors.md) → [Events](reference/events.md) → [Doctor](reference/doctor.md) → [Testing](reference/testing.md) → [Batches/leases/legacy](reference/operations.md) → [Security](operations/security.md) |
12
- | Maintainer | [Architecture](architecture/overview.md) → [Config](reference/config.md) → [Hot reload](guide/hot-reload.md) → [Release checklist](guide/release.md) → [Tracker board](board.md) → [Contributing](../CONTRIBUTING.md) |
11
+ | Operator | [Runbook](operations/runbook.md) → [Blockers](reference/blockers.md) → [Error codes](reference/errors.md) → [Events](reference/events.md) → [Doctor](reference/doctor.md) → [Testing](reference/testing.md) → [Batches/leases/legacy](reference/operations.md) → [Security](operations/security.md) → [Incidents and regressions](operations/incident.md) |
12
+ | Maintainer | [Engineering principles](../PRINCIPLES.md) → [ADRs](adr/README.md) → [Architecture](architecture/overview.md) → [Surface budget](reference/surface-budget.md) → [Component surface](reference/component-surface.md) → [Config](reference/config.md) → [Hot reload](guide/hot-reload.md) → [Release checklist](guide/release.md) → [Tracker board](board.md) → [Contributing](../CONTRIBUTING.md) |
13
13
 
14
14
  Reference pages are the source of truth for the current release; the [tools reference](reference/tools.md),
15
15
  [blocker list](reference/blockers.md) and [error codes](reference/errors.md) are checked against the
@@ -36,13 +36,18 @@ Other useful pages: [When to use Tower](guide/scenarios-and-philosophy.md), [Ins
36
36
  - [Testing and evidence](reference/testing.md)
37
37
  - [Batches, leases and legacy import](reference/operations.md)
38
38
  - [Doctor](reference/doctor.md)
39
+ - [Surface budget and classification](reference/surface-budget.md)
40
+ - [Component surface: who may be switched off](reference/component-surface.md)
39
41
 
40
42
  **Operations and background**
41
43
 
42
44
  - [Operations runbook](operations/runbook.md)
45
+ - [Incidents and regressions](operations/incident.md)
43
46
  - [Security model](operations/security.md)
44
47
  - [Release checklist](guide/release.md)
45
48
  - [Tracker board](board.md)
49
+ - [Engineering principles](../PRINCIPLES.md)
50
+ - [Architecture decision records](adr/README.md)
46
51
  - [Architecture and state machine](architecture/overview.md)
47
52
  - [First mission example](examples/first-mission.md)
48
53
  - [FAQ](faq.md)
@@ -0,0 +1,49 @@
1
+ # 0001 — Ports and adapters are data, not a DI container
2
+
3
+ - Date: 2026-10-23
4
+ - Status: Accepted
5
+ - Deciders: maintainer
6
+ - Supersedes: none
7
+
8
+ ## Context
9
+
10
+ Tower needs a real seam between the pure app/domain logic and the effectful
11
+ world (filesystem, Git, clocks, ids). The seam has to survive a test that replaces
12
+ every adapter with an in-memory double, and it has to be obvious to a reader why
13
+ a tool can run without touching a repository. A conventional dependency-injection
14
+ container would add a framework, decorators, and reflection to a plugin that must
15
+ load inside an arbitrary DSH host version.
16
+
17
+ ## Decision
18
+
19
+ `ports/` declares a frozen `{ name, methods }` runtime contract per capability and
20
+ an `assert*Port` helper; `ports/index.js` exports the `PORTS` table and
21
+ `assertPorts(deps)`. The composition root builds one plain object of adapter
22
+ implementations (`createDefaultDeps`) and passes it explicitly. `app/**` imports
23
+ only app siblings, `domain/**`, `ports/**` and `node:path`. There is no service
24
+ locator and no runtime reflection; a missing method fails at the seam with a
25
+ readable error.
26
+
27
+ ## Consequences
28
+
29
+ - Every effectful call is visible as `deps.<port>.<method>`, so the contract test
30
+ can walk the source and assert each method is declared.
31
+ - Tests substitute the same plain object; the in-memory lifecycle is a real test
32
+ rather than a mock framework feature.
33
+ - Adding a capability means adding a port and an implementation, not registering
34
+ a class with a container.
35
+ - A downstream embedder that wants a different implementation passes a new
36
+ object to `registerTowerTools`; it cannot monkey-patch module state.
37
+
38
+ ## Rejected alternatives
39
+
40
+ - **A dependency-injection container** (`reflect-metadata`, decorators, a
41
+ string-keyed registry) — adds a load-time framework dependency, hides the
42
+ dependency graph behind reflection, and fails indistinguishably when a host
43
+ bundle is mounted twice.
44
+ - **Direct adapter imports from `app/**`** — simpler at first, but it makes the
45
+ pure app test need a filesystem and a Git binary, and the architecture rule
46
+ `app-ports-only` would have nothing to enforce.
47
+ - **Module-level singletons** — one adapter instance would be shared across
48
+ repositories and test cases, which is the ambient-state problem the host-scope
49
+ rule exists to prevent.
@@ -0,0 +1,50 @@
1
+ # 0002 — One state.json is the mission persistence contract
2
+
3
+ - Date: 2026-10-23
4
+ - Status: Accepted
5
+ - Deciders: maintainer
6
+ - Supersedes: none
7
+
8
+ ## Context
9
+
10
+ Mission state carries active missions, roster entries, reviews, findings, messages
11
+ and batches. The obvious alternative is one file per mission (or one directory
12
+ per mission), which would make every mission a small git-diffable record. Tower is
13
+ not a git-backed issue tracker: it mutates state from several tools on every
14
+ operation, under one process, and a crash between two file writes must not leave
15
+ missions half-valid.
16
+
17
+ ## Decision
18
+
19
+ One `<git-common-dir>/dsh-tower/state.json` holds the active document. It is
20
+ read/written through one token-checked lock and one atomic temp+rename write with
21
+ a heartbeat-based stale window. Terminal missions whose worktree is gone are moved
22
+ out to `archive/<year>.json` files under the archive policy; that keeps `state.json`
23
+ bounded without changing the live contract. `modes.json` is a separate per-agent
24
+ mode store and follows the same atomic-write primitives.
25
+
26
+ ## Consequences
27
+
28
+ - A mutation is one lock acquisition and one atomic write, so a reader never sees a
29
+ half-applied mission graph.
30
+ - Cross-mission invariants (unique mission ids, dependencies, roster ownership,
31
+ review binding) are checked in one place before the write.
32
+ - `state.json` is not line-diffable per mission. The board (`MISSIONS.md`), the
33
+ read tools and the archive year files are the human- and diff-facing views.
34
+ - Archiving is a move, never a delete: an unreadable archive pauses archiving
35
+ rather than losing the entries.
36
+ - Two writers are prevented by the lock, not by "please run one tower".
37
+
38
+ ## Rejected alternatives
39
+
40
+ - **One JSON file per mission** — makes each mission diffable, but every command
41
+ that touches several missions (plan, roster, batch, gate) must coordinate N
42
+ files with no cross-file atomicity; a crash leaves a partial graph.
43
+ - **A database (SQLite or a bundled store)** — adds a native dependency to a plugin
44
+ that must mount inside arbitrary host builds, and moves the schema migration
45
+ problem out of a file that a user can inspect and back up.
46
+ - **Append-only event log as the source of truth** — good for audit, bad for the
47
+ read path this plugin actually has: every status call would replay history, and
48
+ compaction would become a second correctness problem.
49
+ - **Git-committed mission records** — would make Tower write to the user's history
50
+ on every tool call, which the "never modify the main checkout" boundary refuses.
@@ -0,0 +1,48 @@
1
+ # 0003 — allowAgentControl defaults to true
2
+
3
+ - Date: 2026-10-23
4
+ - Status: Accepted
5
+ - Deciders: maintainer
6
+ - Supersedes: none
7
+
8
+ ## Context
9
+
10
+ Tower's product claim is a human-owned orchestration mode: the user decides when
11
+ to enter it, and the merge gate is the final authority. The config key
12
+ `mode.allowAgentControl` decides whether the model may call `tower_control` to
13
+ enter or leave the mode. Making it `false` by default would sound safer, but it
14
+ would also mean the feature does not work until the user discovers and flips a
15
+ key, and the model would have no way to leave a mode the user asked it to enter.
16
+
17
+ ## Decision
18
+
19
+ `mode.allowAgentControl` defaults to `true`, and the legacy top-level
20
+ `allowAgentControl` alias keeps the shipped patch behavior. The default is paired
21
+ with independent checks rather than with trust: entering records a reason, a
22
+ user-paused goal is never auto-resumed without an authorization reference, the
23
+ first entry can require a human `/tower ack`, one Tower session per repository can
24
+ hold the workspace lock, and `tower_merge` still runs the preflight gate.
25
+
26
+ ## Consequences
27
+
28
+ - The default profile works: a user can say "use Tower" and the model can comply.
29
+ - Human ownership is enforced by refusal paths (ack, authorization, workspace
30
+ lock, preflight) instead of by a disabled-by-default switch that users would
31
+ turn on anyway.
32
+ - A deployment that wants human-only mode control pins
33
+ `mode.allowAgentControl:false`; doctor warns when a profile still relies on a
34
+ reversed default instead of pinning it.
35
+ - The default is a product decision with a written rationale, so it can be
36
+ revisited on evidence rather than by citing "secure by default".
37
+
38
+ ## Rejected alternatives
39
+
40
+ - **Default `false`** — the plugin would appear broken on first use, and the
41
+ existing reversal would be dropped only to look conservative.
42
+ - **No config key at all** — the model could always toggle the mode, with no way
43
+ for a human-only deployment to refuse it.
44
+ - **Require an approval prompt for every entry** — the prompt is an environment
45
+ behavior, not a plugin guarantee; the ack and authorization fields are the
46
+ guarantees this plugin can actually enforce.
47
+ - **Let the model resume a paused goal on exit** — exit is not consent; resuming
48
+ stays a human decision.
@@ -0,0 +1,70 @@
1
+ # 0004 — The concept surface is frozen by a committed baseline
2
+
3
+ - Date: 2026-10-23
4
+ - Status: Accepted
5
+ - Deciders: maintainer
6
+ - Supersedes: none
7
+
8
+ ## Context
9
+
10
+ Four days after the first publish the plugin carried 60+ config keys and 28 tools.
11
+ The fitness suite measures file shape (line ceilings, pure-domain share) but not
12
+ how many concepts a user has to hold. Line counts can stay green while the real
13
+ surface grows, and every config key is four promises: a reference row, a validator
14
+ branch, a default that must keep working, and an answer to "why did this change my
15
+ behaviour". The same is true of every tool.
16
+
17
+ ## Decision
18
+
19
+ Count three surfaces — config leaf keys, config sections and registered tools — in
20
+ one pure module (`domain/surface.js`). Commit the counts in
21
+ `scripts/surface-baseline.json` with a date. `npm run surface:check` fails when a
22
+ live count differs from the baseline; a decision that raises a count must name an
23
+ existing ADR under `docs/adr/` or deletions that are provably absent from the live
24
+ surface. `tower_doctor` prints the counts, the generated config/tools references
25
+ print the same counts plus the baseline date, and every key/tool is classified
26
+ `core`, `tuning` or `niche` in `docs/reference/surface-budget.md`, with a removal
27
+ note for each niche key.
28
+
29
+ ## Consequences
30
+
31
+ - Growth is visible in a diff instead of arriving one reasonable-looking key at a
32
+ time.
33
+ - A reduction is cheap: the baseline can be ratcheted down with no ADR.
34
+ - The classification table is auditable, not decorative: the check fails when a
35
+ live key or tool is missing from it.
36
+ - The numbers are three lines in `tower_doctor` output, not a new dashboard.
37
+ - This does not remove any key in this release; it is a brake, not a clean-up.
38
+
39
+ ## Measurement correction (2026-10-23)
40
+
41
+ The first baseline counted 27 tools because it recorded only the `define()`
42
+ registration path. `tower_mode` and `tower_control` are registered directly by
43
+ `index.js`, so the shipped surface was always 29 (#118). The baseline now carries
44
+ an explicit correction decision (27 → 29) with a reason, a date and a reference
45
+ to this ADR, and the recorder in `host/tool-registry.js` covers both registration
46
+ paths. This is a measurement correction, not surface growth: a later count change
47
+ still needs its own ADR or a provably matching deletion.
48
+
49
+ ## Amended by ADR 0005 (2026-10-23)
50
+
51
+ The decision above counts three surfaces. [ADR 0005](0005-component-surface.md)
52
+ extends the budget with a fourth — the number of user-visible component rows in
53
+ `cordis.patch.yml` — and records it as its own `components` baseline entry with a
54
+ two-way decision rule (a contraction needs a decision just like growth). The
55
+ three original counts are still measured by `npm run surface:check`; the
56
+ component count is validated by `npm run docs:check:components`. The historical
57
+ decision above is unchanged: "three surfaces" describes the 2.3.0 rule.
58
+
59
+ ## Rejected alternatives
60
+
61
+ - **A line-count or file-size rule** — the project already has those and the
62
+ surface grew while they stayed green; they measure the wrong thing.
63
+ - **A hard cap with no escape hatch** — a real capability would be blocked, and the
64
+ first exception would be added as an unexplained waiver.
65
+ - **A per-key count in each generated reference** — two references can disagree;
66
+ one shared baseline plus one check keeps the counts equal.
67
+ - **Only an ADR requirement** — a pure deletion would need paperwork even though it
68
+ shrinks the surface; the matching-deletion path keeps reductions cheap.
69
+ - **A GitHub issue label instead of a repo file** — the brake must run offline in
70
+ `npm test` and in CI without a token.