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
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/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) → [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,17 @@ 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)
39
40
 
40
41
  **Operations and background**
41
42
 
42
43
  - [Operations runbook](operations/runbook.md)
44
+ - [Incidents and regressions](operations/incident.md)
43
45
  - [Security model](operations/security.md)
44
46
  - [Release checklist](guide/release.md)
45
47
  - [Tracker board](board.md)
48
+ - [Engineering principles](../PRINCIPLES.md)
49
+ - [Architecture decision records](adr/README.md)
46
50
  - [Architecture and state machine](architecture/overview.md)
47
51
  - [First mission example](examples/first-mission.md)
48
52
  - [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,50 @@
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
+ ## Rejected alternatives
40
+
41
+ - **A line-count or file-size rule** — the project already has those and the
42
+ surface grew while they stayed green; they measure the wrong thing.
43
+ - **A hard cap with no escape hatch** — a real capability would be blocked, and the
44
+ first exception would be added as an unexplained waiver.
45
+ - **A per-key count in each generated reference** — two references can disagree;
46
+ one shared baseline plus one check keeps the counts equal.
47
+ - **Only an ADR requirement** — a pure deletion would need paperwork even though it
48
+ shrinks the surface; the matching-deletion path keeps reductions cheap.
49
+ - **A GitHub issue label instead of a repo file** — the brake must run offline in
50
+ `npm test` and in CI without a token.
@@ -0,0 +1,51 @@
1
+ # Architecture decision records
2
+
3
+ An ADR is a dated record of a decision that is already load-bearing. It is not a
4
+ proposal: the status says whether the decision is accepted, and the rejected
5
+ alternatives exist so a future reader can see what was actually weighed.
6
+
7
+ The engineering principles that these records serve are in
8
+ [../PRINCIPLES.md](../PRINCIPLES.md). `npm run docs:check:philosophy` requires at
9
+ least three numbered ADRs, each with Context, Decision, Consequences, Rejected
10
+ alternatives, Date and Status.
11
+
12
+ | ADR | Decision | Date |
13
+ |---|---|---|
14
+ | [0001](0001-ports-adapters-as-data.md) | Ports and adapters are data, not a dependency-injection container | 2026-10-23 |
15
+ | [0002](0002-single-state-json.md) | One `state.json` is the mission persistence contract | 2026-10-23 |
16
+ | [0003](0003-allow-agent-control-default-true.md) | `allowAgentControl` defaults to `true` | 2026-10-23 |
17
+ | [0004](0004-surface-budget.md) | The concept surface is frozen by a committed baseline | 2026-10-23 |
18
+
19
+ ## Template
20
+
21
+ ```markdown
22
+ # NNNN — Decision title
23
+
24
+ - Date: YYYY-MM-DD
25
+ - Status: Proposed | Accepted | Superseded by NNNN
26
+ - Deciders: <who>
27
+
28
+ ## Context
29
+
30
+ What forced the decision.
31
+
32
+ ## Decision
33
+
34
+ What was decided, in one paragraph.
35
+
36
+ ## Consequences
37
+
38
+ What becomes easier, what becomes harder, and what is now refused.
39
+
40
+ ## Rejected alternatives
41
+
42
+ - **Alternative** — why it was not chosen.
43
+ ```
44
+
45
+ ## Non-goals
46
+
47
+ These records do not form an RFC queue, an architecture review board, or a
48
+ process for approving ordinary changes.
49
+
50
+ Unowned. This is a documentation convention, not a gate; nothing in CI blocks a
51
+ new ADR or rewards one.
@@ -0,0 +1,51 @@
1
+ # Execution workflow: Matt Pocock skills × Tower
2
+
3
+ This repository uses two workflows together. They are not alternatives: the **Matt Pocock skills** own thinking, planning, review discipline and retros; **Tower** owns delivery — isolated worktrees, scope fences, review gates, merges and durable mission state. **GitHub Issues** is the single source of truth for what is planned, in progress, blocked and shipped.
4
+
5
+ ## Who owns what
6
+
7
+ | Stage | Owner | Artifact |
8
+ |---|---|---|
9
+ | Intake | Matt Pocock `/triage` for raw external reports; milestones + `P*`/`area/*` labels for self-authored findings | issue with owner-facing state |
10
+ | Plan | `/to-spec` → `/to-tickets` (or an existing issue whose acceptance criteria are already ticket-shaped) | spec issue, tickets with native `blocked_by` edges |
11
+ | Execute | `/implement-spec` subagents for one-session waves; **Tower missions** for parallel or long-lived work | integration branch + PRs, or mission worktrees + merge gate |
12
+ | Build each unit | `/tdd` inside the ticket/mission | failing test first, then green |
13
+ | Verify | gates + required CI checks + `/code-review` (Standards + Spec) | green PR with two-axis review |
14
+ | Land | protected `main`, four-line PR body, `verified-by:`/`commit:` closure | merged PR, closed issues |
15
+ | Release | `release:check`, CHANGELOG, COMPATIBILITY, tag; publish only when planned | tag-only or published release |
16
+ | Learn | `/retro`; mechanical findings become checks, judgement becomes standards | CONTRIBUTING/AGENTS/scanner updates |
17
+
18
+ ## The flow
19
+
20
+ 1. **Intake.** Raw external bug reports and feature requests go through `/triage` (they are not ours). Findings we file ourselves (audits, retros) skip triage and get a priority, an area and a milestone; they are already acceptance-criteria-shaped.
21
+ 2. **Plan.** Multi-session work goes `/to-spec` → `/to-tickets`. Tickets are tracer bullets with native blocking edges; the frontier is whatever has no open blockers. Small work may skip the spec when the issue already states acceptance criteria (e.g. a patch ticket).
22
+ 3. **Choose the orchestrator.**
23
+ - **One-session wave (≤3 tickets, mostly independent):** `/implement-spec`-style: an integration branch, one implementer subagent per ticket in its own worktree, merger merges to the integration branch, one PR to `main`.
24
+ - **Parallel or long-lived wave (architecture epics, >3 tickets, review needed):** **Tower**. `tower_init` → `tower_plan` with one mission per ticket, `scope` = the ticket's write fence, `deps` = the ticket's blocking edges → `tower_spawn` a worker per mission → optional reviewer (`tower-reviewer`) → `tower_merge` (gate checks scope, branch freshness, review, tasks) → `tower_teardown`. Tower's persistent state makes the wave resumable after a restart.
25
+ - Never run both orchestrators over the same files at once.
26
+ 4. **Build.** Each unit starts with `/tdd`: one failing test, then the implementation, then the next slice. Commit messages carry `Refs #n`; the completing commit uses `Fixes #n`. Workers do not merge; they hand off a branch.
27
+ 5. **Verify before landing.** Run `npm test`, `npm run docs:check`, all four `docs:check:generated*`, `npm run pack:check`, `npm run architecture:check`, `npm run types:check`, `npm run domain:check`. Bundle changes also run `npm run smoke:mount`. CI must be green on the required checks (`test (22)`, `test (24)`, `test (22, windows)`, `mount`). Then `/code-review` runs both axes on the diff (Standards against CONTRIBUTING/architecture docs, Spec against the ticket); every finding is fixed in the same PR or explicitly deferred with a reason.
28
+ 6. **Land.** PR body follows the four-line contract (failure mode → change → test that fails without it → blast radius). `main` is protected: PR + required checks, admins included, no force-push. A closure comment carries `verified-by: <test file>` and `commit: <sha>`; `npm run audit:issues` flags closures whose evidence is missing.
29
+ 7. **Release.** `release:check` compares the version, the previous tag and the CHANGELOG; a tag push fails when the version did not move or the tag does not match. Releases are either **tag-only** (version + changelog + compatibility row + annotated tag; no npm publish, no GitHub Release) or **published** (adds the human `npm publish --tag latest` with 2FA, `dist-tags` verification, then the GitHub Release). The mode is recorded in the milestone/plan.
30
+ 8. **Learn.** After a release (or a wave that went sideways) run `/retro`: mechanical mistakes become deterministic checks (scripts, CI jobs, preflight), judgement calls become written standards. Periodically run `/improve-codebase-architecture` for deepening opportunities and `/domain-modeling` to keep the vocabulary and ADRs (2.3.0) honest.
31
+
32
+ ## Decision table
33
+
34
+ | Situation | Flow |
35
+ |---|---|
36
+ | Raw external issue | `/triage` → then plan |
37
+ | Design question you cannot settle on paper | `/grill-with-docs`, detour through `/prototype` if it needs a runnable answer |
38
+ | Multi-session feature | `/to-spec` → `/to-tickets` → `/implement-spec` |
39
+ | Small fix with acceptance criteria | ticket → `/implement` (+ `/tdd`) |
40
+ | Architecture epic / parallel missions | Tower plan → missions → review gate → merge |
41
+ | Something broken, cause unknown | `/diagnosing-bugs` (tight red loop first) |
42
+ | Repeated mistakes | `/retro` → checks/standards |
43
+ | Release | release plan: tag-only or publish |
44
+
45
+ ## Invariants
46
+
47
+ - One ticket per PR series; a branch never touches two tickets.
48
+ - Tracked blocking edges, not prose: work only the frontier.
49
+ - Tower missions: `scope` is the write fence, a mission that outgrows it is re-scoped, not silently widened; the workspace lock keeps one Tower session per repository.
50
+ - Human-only gates: npm publish/2FA, branch-protection changes, anything behind a third-party credential. Stop and hand over rather than inventing a credential path.
51
+ - Context hygiene: keep planning in one window until tickets exist; start each implementation unit from the ticket, not from the previous unit's context.