dsh-tower 2.1.0-beta.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +71 -5
  4. package/PRINCIPLES.md +145 -0
  5. package/README.en.md +14 -5
  6. package/README.md +14 -5
  7. package/SECURITY.md +6 -0
  8. package/adapters/archive.js +279 -0
  9. package/adapters/caseSensitivity.js +66 -0
  10. package/adapters/default.js +51 -8
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +15 -6
  13. package/{lib → adapters}/legacy.js +20 -6
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/stateFile.js +99 -0
  16. package/adapters/store-fs.js +28 -1
  17. package/adapters/store.js +204 -0
  18. package/{lib → adapters}/tests.js +15 -7
  19. package/adapters/vcs-git.js +15 -1
  20. package/{lib → adapters}/workspaceLock.js +1 -1
  21. package/{lib → adapters}/worktreeOwnership.js +18 -7
  22. package/app/archive.js +188 -0
  23. package/app/batch.js +9 -10
  24. package/app/board.js +4 -4
  25. package/app/checkCompletion.js +23 -11
  26. package/app/enrichedMissions.js +2 -4
  27. package/app/findings.js +6 -7
  28. package/app/gate.js +32 -15
  29. package/app/init.js +59 -22
  30. package/app/merge.js +64 -51
  31. package/app/messages.js +7 -8
  32. package/app/mission.js +7 -5
  33. package/app/plan.js +20 -9
  34. package/app/provision.js +12 -14
  35. package/app/read.js +65 -6
  36. package/app/rebase.js +14 -16
  37. package/app/recording.js +7 -8
  38. package/app/review.js +17 -8
  39. package/app/roster.js +14 -15
  40. package/{lib → app}/spawn-prechecks.js +4 -3
  41. package/app/spawn.js +12 -13
  42. package/app/teardown.js +28 -8
  43. package/app/tower.js +121 -39
  44. package/docs/README.md +6 -2
  45. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  46. package/docs/adr/0002-single-state-json.md +50 -0
  47. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  48. package/docs/adr/0004-surface-budget.md +50 -0
  49. package/docs/adr/README.md +51 -0
  50. package/docs/agents/domain.md +38 -0
  51. package/docs/agents/issue-tracker.md +46 -0
  52. package/docs/agents/triage-labels.md +15 -0
  53. package/docs/agents/workflow.md +51 -0
  54. package/docs/architecture/overview.md +170 -37
  55. package/docs/guide/hot-reload.md +1 -1
  56. package/docs/guide/install-upgrade-migration.md +18 -1
  57. package/docs/guide/release.md +1 -1
  58. package/docs/operations/incident.md +82 -0
  59. package/docs/operations/runbook.md +12 -3
  60. package/docs/reference/blockers.md +3 -3
  61. package/docs/reference/config.generated.md +5 -0
  62. package/docs/reference/config.md +4 -0
  63. package/docs/reference/doctor.md +104 -8
  64. package/docs/reference/errors.generated.md +41 -35
  65. package/docs/reference/errors.md +54 -40
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +128 -0
  68. package/docs/reference/tools.generated.md +9 -8
  69. package/docs/reference/tools.md +9 -8
  70. package/docs/zh/guide/hot-reload.md +1 -1
  71. package/docs/zh/guide/install-upgrade-migration.md +13 -1
  72. package/docs/zh/reference/blockers.md +3 -3
  73. package/docs/zh/reference/config.md +4 -0
  74. package/docs/zh/reference/doctor.md +63 -2
  75. package/docs/zh/reference/errors.md +4 -2
  76. package/docs/zh/reference/tools.md +6 -6
  77. package/domain/batch.js +2 -2
  78. package/domain/config/schema.js +51 -4
  79. package/domain/contracts/outputs.js +33 -3
  80. package/domain/contracts/tool-inputs/missions.js +2 -1
  81. package/domain/contracts/tool-inputs/ops.js +1 -0
  82. package/domain/errors.js +297 -40
  83. package/domain/events.js +207 -0
  84. package/domain/integration/gate.js +41 -12
  85. package/domain/integration/init.js +40 -4
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +1 -1
  89. package/domain/missions/completion.js +72 -5
  90. package/domain/missions/lifecycle.js +11 -4
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/planning.js +6 -3
  94. package/domain/read/views.js +17 -1
  95. package/domain/report.js +9 -1
  96. package/domain/scope.js +83 -11
  97. package/domain/state/archive.js +257 -0
  98. package/domain/state/migrations.js +145 -0
  99. package/domain/state/schema.js +6 -0
  100. package/domain/state.js +154 -4
  101. package/domain/summary.js +2 -0
  102. package/domain/surface.js +248 -0
  103. package/host/doctor.js +555 -0
  104. package/{lib → host}/modeStore.js +1 -1
  105. package/{lib → host}/tool-registry.js +106 -31
  106. package/host/tower.js +32 -0
  107. package/index.js +12 -9
  108. package/package.json +10 -3
  109. package/ports/archive.js +18 -0
  110. package/ports/caseSensitivity.js +15 -0
  111. package/ports/clock.js +10 -2
  112. package/ports/config.js +19 -0
  113. package/ports/contract.js +38 -0
  114. package/ports/events.js +24 -0
  115. package/ports/ids.js +10 -2
  116. package/ports/index.js +54 -2
  117. package/ports/ownership.js +17 -0
  118. package/ports/state.js +16 -0
  119. package/ports/store.js +52 -3
  120. package/ports/vcs.js +25 -4
  121. package/scripts/architecture-waivers.json +19 -0
  122. package/scripts/architecture.mjs +634 -23
  123. package/scripts/check-docs.mjs +8 -35
  124. package/scripts/check-pack.mjs +68 -39
  125. package/scripts/check-philosophy.mjs +266 -0
  126. package/scripts/domain-metrics.mjs +13 -4
  127. package/scripts/generate-config-reference.mjs +14 -1
  128. package/scripts/generate-error-reference.mjs +68 -14
  129. package/scripts/generate-events-reference.mjs +83 -0
  130. package/scripts/generate-tools-reference.mjs +11 -0
  131. package/scripts/issue-audit.mjs +2 -2
  132. package/scripts/lib/surface.mjs +87 -0
  133. package/scripts/pack-manifest.txt +64 -19
  134. package/scripts/regression-audit.mjs +164 -0
  135. package/scripts/release-check.mjs +1 -1
  136. package/scripts/surface-baseline.json +21 -0
  137. package/scripts/surface-baseline.mjs +120 -0
  138. package/tools/adopt.js +2 -3
  139. package/tools/batch.js +2 -3
  140. package/tools/complete.js +3 -4
  141. package/tools/diff.js +3 -3
  142. package/tools/doctor.js +7 -10
  143. package/tools/finding.js +2 -3
  144. package/tools/findings.js +3 -3
  145. package/tools/import-legacy.js +3 -5
  146. package/tools/inbox.js +3 -3
  147. package/tools/index.js +7 -7
  148. package/tools/init.js +4 -7
  149. package/tools/interrupt.js +2 -3
  150. package/tools/legacy-map.js +3 -5
  151. package/tools/merge.js +3 -3
  152. package/tools/mission.js +2 -3
  153. package/tools/plan.js +7 -6
  154. package/tools/preflight.js +3 -3
  155. package/tools/rebase.js +3 -3
  156. package/tools/recover.js +38 -13
  157. package/tools/reload.js +3 -6
  158. package/tools/resource.js +3 -5
  159. package/tools/review.js +3 -4
  160. package/tools/roster.js +2 -3
  161. package/tools/send.js +2 -2
  162. package/tools/spawn.js +8 -5
  163. package/tools/status.js +16 -8
  164. package/tools/teardown.js +3 -4
  165. package/tools/test.js +2 -2
  166. package/types/index.d.ts +35 -1
  167. package/ui-bundle/README.md +37 -3
  168. package/ui-bundle/client.cards.js +356 -0
  169. package/ui-bundle/client.denied.js +142 -0
  170. package/ui-bundle/client.dock.js +333 -0
  171. package/ui-bundle/client.helpers.js +271 -0
  172. package/ui-bundle/client.js +79 -1747
  173. package/ui-bundle/client.locales.js +247 -0
  174. package/ui-bundle/client.mode.js +206 -0
  175. package/ui-bundle/client.overlay.js +64 -0
  176. package/ui-bundle/client.styles.js +151 -0
  177. package/ui-bundle/client.toolview.js +82 -0
  178. package/ui-bundle/package.json +1 -0
  179. package/lib/doctor.js +0 -270
  180. package/lib/persistence.js +0 -349
  181. package/lib/store.js +0 -118
  182. /package/{lib → app}/briefing.js +0 -0
  183. /package/{lib → app}/spawn-support.js +0 -0
  184. /package/{lib → host}/compatibility.js +0 -0
  185. /package/{lib → host}/config.js +0 -0
  186. /package/{lib → host}/hmr.js +0 -0
  187. /package/{lib → host}/modeState.js +0 -0
  188. /package/{lib → host}/presets.js +0 -0
package/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,23 +16,25 @@ 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'
20
24
  import { missionContext } from './missionContext.js'
21
25
  import { enrichedMissions } from './enrichedMissions.js'
22
26
  import { writeBoard, boardFile } from './board.js'
27
+ import { archiveIndex, archivedMission } from './archive.js'
23
28
  import { recordSpawnOutcome, recordInterrupt, reserveRoster, recordSpawn, rosterView } from './spawn.js'
24
29
  import { recordTestRun, recordLegacySnapshot, recordLegacyMap } from './recording.js'
25
30
 
26
31
  export const ROLES = ['worker', 'reviewer']
27
32
  const MAX_REVIEW_ROUNDS = 5
28
- const DEFAULT_MERGE_TIMEOUT_MS = 15 * 60 * 1000
29
33
  const MERGE_MAX_BUFFER = 64 * 1024 * 1024
30
34
 
31
35
  function mergeTimeoutMs(value, config) {
32
36
  if (value === undefined || value === null) {
33
- const configured = (config ?? runtimeConfig()).merge.timeoutMs
37
+ const configured = (config ?? {}).merge?.timeoutMs
34
38
  return Number.isInteger(configured) && configured >= 1000 ? configured : DEFAULT_MERGE_TIMEOUT_MS
35
39
  }
36
40
  const ms = Number(value)
@@ -38,8 +42,8 @@ function mergeTimeoutMs(value, config) {
38
42
  return ms
39
43
  }
40
44
 
41
- async function commitParents(cwd, sha) {
42
- 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])
43
47
  return line ? line.trim().split(/\s+/).slice(1) : []
44
48
  }
45
49
 
@@ -48,10 +52,10 @@ async function commitParents(cwd, sha) {
48
52
  * `--no-ff` merges put the integrated branch on the second parent, so this is
49
53
  * the concrete evidence that a mission's work already landed in the base.
50
54
  */
51
- async function findLandedMerge(cwd, baseTip, tips) {
52
- 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])) ?? ''
53
57
  for (const sha of revs.split('\n').filter(Boolean)) {
54
- const parents = await commitParents(cwd, sha)
58
+ const parents = await commitParents(tower, cwd, sha)
55
59
  if (parents.length >= 2 && tips.has(parents[1])) return { commit: sha, parents }
56
60
  }
57
61
  return null
@@ -64,14 +68,14 @@ function describeGitFailure(error) {
64
68
 
65
69
  /** Remove only lock files that are provably old. A fresh lock may belong to a
66
70
  * concurrent Git process; deleting it would let two writers into the index. */
67
- async function removeStaleGitLocks(commonDir, maxAgeMs = 60000) {
71
+ async function removeStaleGitLocks(tower, commonDir, maxAgeMs = 60000) {
68
72
  const removed = []
69
73
  for (const name of ['index.lock', 'HEAD.lock', 'MERGE_HEAD.lock', 'ORIG_HEAD.lock']) {
70
74
  const file = path.join(commonDir, name)
71
75
  try {
72
- const stat = await fsp.stat(file)
76
+ const stat = await tower.deps.store.stat(file)
73
77
  if (Date.now() - stat.mtimeMs < maxAgeMs) continue
74
- await fsp.unlink(file)
78
+ await tower.deps.store.unlink(file)
75
79
  removed.push(name)
76
80
  } catch {
77
81
  // absent or in use; never force it
@@ -104,28 +108,59 @@ export class Tower {
104
108
  /**
105
109
  * @param {{ root: string, commonDir: string, branch?: string | null }} repo
106
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.
107
113
  */
108
- constructor(repo, config, deps = defaultDeps) {
114
+ constructor(repo, config, deps) {
115
+ assertPorts(deps)
109
116
  this.root = repo.root
110
117
  this.commonDir = repo.commonDir
111
118
  this.branch = repo.branch
112
- this.deps = deps
119
+ this.deps = /** @type {Record<string, any>} */ (deps)
113
120
  this.paths = this.deps.store.pathsFor(repo.commonDir)
114
121
  // Effective config for this repository: code defaults < profile config <
115
122
  // repo config file. Per-call tool arguments are applied by the caller.
116
- 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
+ )
117
126
  }
118
127
 
119
- static async open(repoRoot) {
120
- const repo = await defaultDeps.vcs.resolveRepo(repoRoot)
121
- const config = await resolveRepoConfig(repo)
122
- 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)
123
138
  }
124
139
 
125
140
  git(cwd, args, options = {}) {
126
141
  return this.deps.vcs.git(cwd, args, { timeoutMs: this.config.git?.timeoutMs, ...options })
127
142
  }
128
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
+
129
164
  tryGit(cwd, args, options = {}) {
130
165
  return this.deps.vcs.tryGit(cwd, args, { timeoutMs: this.config.git?.timeoutMs, ...options })
131
166
  }
@@ -142,12 +177,24 @@ export class Tower {
142
177
  return (await this.tryGit(cwd, ['merge-base', '--is-ancestor', ancestor, descendant].filter(Boolean))) !== null
143
178
  }
144
179
 
145
- async load() {
180
+ /**
181
+ * Read the state document, migrating an older schema in memory and (while the
182
+ * caller holds the state lock) persisting that migration with one revision
183
+ * bump and a `state.migrated` activity entry (#72).
184
+ * @param {{ persist?: boolean }} [options]
185
+ */
186
+ async load(options = {}) {
146
187
  if (!(await this.deps.store.exists(this.paths.state))) {
147
188
  fail(`tower is not initialized for ${this.root} — run tower_init first`, 'not-initialized')
148
189
  }
149
- const state = await this.deps.store.readJson(this.paths.state)
150
- return validateState(state, this.paths.state)
190
+ const loaded = await this.deps.state.loadStateFile({
191
+ store: this.deps.store,
192
+ paths: this.paths,
193
+ now: this.deps.clock?.now ? this.deps.clock.now() : Date.now(),
194
+ persist: options.persist,
195
+ })
196
+ this.stateLoad = { ...loaded.version, migration: loaded.migration }
197
+ return loaded.state
151
198
  }
152
199
 
153
200
  /** #51 two-phase init: see the prepare/act/commit helpers in `./tower/init.js`. */
@@ -155,6 +202,27 @@ export class Tower {
155
202
  return init(this, input)
156
203
  }
157
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
+
158
226
  async plan(input = {}) {
159
227
  return plan(this, input)
160
228
  }
@@ -180,7 +248,7 @@ export class Tower {
180
248
 
181
249
  async assertWorktree(worktree) {
182
250
  if (!worktree || typeof worktree.path !== 'string') fail('mission has no worktree')
183
- const owned = await assertOwnership({
251
+ const owned = await this.deps.ownership.assertOwnership({
184
252
  storedPath: worktree.path,
185
253
  storedRealPath: worktree.realPath,
186
254
  branch: worktree.branch,
@@ -316,9 +384,23 @@ export class Tower {
316
384
  return missionDetail(this, missionId)
317
385
  }
318
386
 
319
- /** Human-readable mission board (markdown) for the current state. */
320
- boardMarkdown(state) {
321
- return boardMarkdown(state)
387
+ /**
388
+ * Human-readable mission board (markdown) for the current state.
389
+ * @param {import('../types/index.js').TowerState} state
390
+ * @param {{ count?: number, unreadable?: boolean, message?: string } | null} [archive]
391
+ */
392
+ boardMarkdown(state, archive = null) {
393
+ return boardMarkdown(state, archive)
394
+ }
395
+
396
+ /** Read `archive/index.json`; `ok:false` carries a diagnostic instead of throwing. */
397
+ archiveIndex() {
398
+ return archiveIndex(this)
399
+ }
400
+
401
+ /** Load one archived mission (full record) with its linked reviews and findings. */
402
+ archivedMission(missionId) {
403
+ return archivedMission(this, missionId)
322
404
  }
323
405
 
324
406
  async ensureExcluded(target) {
@@ -333,14 +415,14 @@ export class Tower {
333
415
  await this.deps.store.ensureDir(path.dirname(file))
334
416
  let text = ''
335
417
  try {
336
- text = await fsp.readFile(file, 'utf8')
418
+ text = await this.deps.store.readText(file)
337
419
  } catch {
338
420
  text = ''
339
421
  }
340
422
  const lines = text.split(/\r?\n/)
341
423
  if (lines.includes(pattern) || lines.includes(`${pattern}/`)) return { inside: true, excluded: true, pattern }
342
424
  const suffix = text === '' || text.endsWith('\n') ? '' : '\n'
343
- await fsp.appendFile(file, `${suffix}${pattern}\n`, 'utf8')
425
+ await this.deps.store.appendText(file, `${suffix}${pattern}\n`)
344
426
  return { inside: true, excluded: true, pattern }
345
427
  }
346
428
 
@@ -398,6 +480,7 @@ export class Tower {
398
480
  landed = null // no merge commit was created; the intent is safe to drop
399
481
  } else {
400
482
  landed = await findLandedMerge(
483
+ this,
401
484
  this.root,
402
485
  head,
403
486
  new Set([intent.branchTip, intent.baseTip].filter(Boolean)),
@@ -461,20 +544,18 @@ export class Tower {
461
544
  return null
462
545
  }
463
546
  const tips = new Set([branchTip, reviewedTip].filter(Boolean))
464
- const found = await findLandedMerge(this.root, baseTip, tips)
547
+ const found = await findLandedMerge(this, this.root, baseTip, tips)
465
548
  const mergedCommit = found ? found.commit : branchTip
466
549
  const now = Date.now()
467
550
  if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
468
- mission.status = 'merged'
469
- mission.mergedAt = now
470
- mission.mergedCommit = mergedCommit
471
- mission.blockers = []
472
- mission.notes.push(
473
- found
551
+ const applied = applyLandedMerge({
552
+ mission,
553
+ mergedCommit,
554
+ note: found
474
555
  ? `reconciled already-landed merge ${mergedCommit.slice(0, 7)} with parents [${found.parents.map((parent) => parent.slice(0, 7)).join(', ')}]`
475
556
  : `reconciled already-landed merge: ${mergedCommit.slice(0, 7)} is contained in ${state.base} (no merge commit found)`,
476
- )
477
- mission.updatedAt = now
557
+ now,
558
+ })
478
559
  state.revision += 1
479
560
  state.updatedAt = now
480
561
  validateState(state, this.paths.state)
@@ -485,6 +566,7 @@ export class Tower {
485
566
  missionId: mission.id,
486
567
  text: `reconciled already-landed ${mergedCommit.slice(0, 7)} on ${state.base}`,
487
568
  })
569
+ this.emitEvents(applied.events, { revision: state.revision, at: now })
488
570
  return {
489
571
  ok: true,
490
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,38 @@
1
+ # Domain Docs
2
+
3
+ How the engineering skills should consume this repo's domain documentation when exploring the codebase.
4
+
5
+ ## Before exploring, read these
6
+
7
+ - **`GLOSSARY.md`** at the repo root, or
8
+ - **`GLOSSARY-MAP.md`** at the repo root if it exists: it points at one `GLOSSARY.md` per context. Read each one relevant to the topic.
9
+ - **`docs/adr/`**: read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
10
+
11
+ If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
12
+
13
+ ## File structure
14
+
15
+ This repo is **single-context**: one `GLOSSARY.md` and one `docs/adr/` at the repo root.
16
+
17
+ ```
18
+ /
19
+ ├── GLOSSARY.md
20
+ ├── docs/adr/
21
+ │ ├── 0001-event-sourced-orders.md
22
+ │ └── 0002-postgres-for-write-model.md
23
+ └── src/
24
+ ```
25
+
26
+ (If the repo ever grows per-package contexts, switch to the multi-context layout: a root `GLOSSARY-MAP.md` pointing at per-context `GLOSSARY.md` files, with system-wide decisions in `docs/adr/` and context decisions in `src/<context>/docs/adr/`.)
27
+
28
+ ## Use the glossary's vocabulary
29
+
30
+ When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `GLOSSARY.md`. Don't drift to synonyms the glossary explicitly avoids.
31
+
32
+ If the concept you need isn't in the glossary yet, that's a signal: either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
33
+
34
+ ## Flag ADR conflicts
35
+
36
+ If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
37
+
38
+ > _Contradicts ADR-0007 (event-sourced orders), but worth reopening because…_