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.
- package/CHANGELOG.md +154 -0
- package/COMPATIBILITY.md +6 -1
- package/CONTRIBUTING.md +71 -5
- package/PRINCIPLES.md +145 -0
- package/README.en.md +14 -5
- package/README.md +14 -5
- package/SECURITY.md +6 -0
- package/adapters/archive.js +279 -0
- package/adapters/caseSensitivity.js +66 -0
- package/adapters/default.js +51 -8
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +15 -6
- package/{lib → adapters}/legacy.js +20 -6
- package/adapters/persistence.js +543 -0
- package/adapters/stateFile.js +99 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +15 -7
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +18 -7
- package/app/archive.js +188 -0
- package/app/batch.js +9 -10
- package/app/board.js +4 -4
- package/app/checkCompletion.js +23 -11
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +32 -15
- package/app/init.js +59 -22
- package/app/merge.js +64 -51
- package/app/messages.js +7 -8
- package/app/mission.js +7 -5
- package/app/plan.js +20 -9
- package/app/provision.js +12 -14
- package/app/read.js +65 -6
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +17 -8
- package/app/roster.js +14 -15
- package/{lib → app}/spawn-prechecks.js +4 -3
- package/app/spawn.js +12 -13
- package/app/teardown.js +28 -8
- package/app/tower.js +121 -39
- package/docs/README.md +6 -2
- package/docs/adr/0001-ports-adapters-as-data.md +49 -0
- package/docs/adr/0002-single-state-json.md +50 -0
- package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
- package/docs/adr/0004-surface-budget.md +50 -0
- package/docs/adr/README.md +51 -0
- package/docs/agents/domain.md +38 -0
- package/docs/agents/issue-tracker.md +46 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/agents/workflow.md +51 -0
- package/docs/architecture/overview.md +170 -37
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +18 -1
- package/docs/guide/release.md +1 -1
- package/docs/operations/incident.md +82 -0
- package/docs/operations/runbook.md +12 -3
- package/docs/reference/blockers.md +3 -3
- package/docs/reference/config.generated.md +5 -0
- package/docs/reference/config.md +4 -0
- package/docs/reference/doctor.md +104 -8
- package/docs/reference/errors.generated.md +41 -35
- package/docs/reference/errors.md +54 -40
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +128 -0
- package/docs/reference/tools.generated.md +9 -8
- package/docs/reference/tools.md +9 -8
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +13 -1
- package/docs/zh/reference/blockers.md +3 -3
- package/docs/zh/reference/config.md +4 -0
- package/docs/zh/reference/doctor.md +63 -2
- package/docs/zh/reference/errors.md +4 -2
- package/docs/zh/reference/tools.md +6 -6
- package/domain/batch.js +2 -2
- package/domain/config/schema.js +51 -4
- package/domain/contracts/outputs.js +33 -3
- package/domain/contracts/tool-inputs/missions.js +2 -1
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +297 -40
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +41 -12
- package/domain/integration/init.js +40 -4
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/mission.js +1 -1
- package/domain/missions/completion.js +72 -5
- package/domain/missions/lifecycle.js +11 -4
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/planning.js +6 -3
- package/domain/read/views.js +17 -1
- package/domain/report.js +9 -1
- package/domain/scope.js +83 -11
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +6 -0
- package/domain/state.js +154 -4
- package/domain/summary.js +2 -0
- package/domain/surface.js +248 -0
- package/host/doctor.js +555 -0
- package/{lib → host}/modeStore.js +1 -1
- package/{lib → host}/tool-registry.js +106 -31
- package/host/tower.js +32 -0
- package/index.js +12 -9
- package/package.json +10 -3
- package/ports/archive.js +18 -0
- package/ports/caseSensitivity.js +15 -0
- package/ports/clock.js +10 -2
- package/ports/config.js +19 -0
- package/ports/contract.js +38 -0
- package/ports/events.js +24 -0
- package/ports/ids.js +10 -2
- package/ports/index.js +54 -2
- package/ports/ownership.js +17 -0
- package/ports/state.js +16 -0
- package/ports/store.js +52 -3
- package/ports/vcs.js +25 -4
- package/scripts/architecture-waivers.json +19 -0
- package/scripts/architecture.mjs +634 -23
- package/scripts/check-docs.mjs +8 -35
- package/scripts/check-pack.mjs +68 -39
- package/scripts/check-philosophy.mjs +266 -0
- package/scripts/domain-metrics.mjs +13 -4
- package/scripts/generate-config-reference.mjs +14 -1
- package/scripts/generate-error-reference.mjs +68 -14
- package/scripts/generate-events-reference.mjs +83 -0
- package/scripts/generate-tools-reference.mjs +11 -0
- package/scripts/issue-audit.mjs +2 -2
- package/scripts/lib/surface.mjs +87 -0
- package/scripts/pack-manifest.txt +64 -19
- package/scripts/regression-audit.mjs +164 -0
- package/scripts/release-check.mjs +1 -1
- package/scripts/surface-baseline.json +21 -0
- package/scripts/surface-baseline.mjs +120 -0
- package/tools/adopt.js +2 -3
- package/tools/batch.js +2 -3
- package/tools/complete.js +3 -4
- package/tools/diff.js +3 -3
- package/tools/doctor.js +7 -10
- package/tools/finding.js +2 -3
- package/tools/findings.js +3 -3
- package/tools/import-legacy.js +3 -5
- package/tools/inbox.js +3 -3
- package/tools/index.js +7 -7
- package/tools/init.js +4 -7
- package/tools/interrupt.js +2 -3
- package/tools/legacy-map.js +3 -5
- package/tools/merge.js +3 -3
- package/tools/mission.js +2 -3
- package/tools/plan.js +7 -6
- package/tools/preflight.js +3 -3
- package/tools/rebase.js +3 -3
- package/tools/recover.js +38 -13
- package/tools/reload.js +3 -6
- package/tools/resource.js +3 -5
- package/tools/review.js +3 -4
- package/tools/roster.js +2 -3
- package/tools/send.js +2 -2
- package/tools/spawn.js +8 -5
- package/tools/status.js +16 -8
- package/tools/teardown.js +3 -4
- package/tools/test.js +2 -2
- package/types/index.d.ts +35 -1
- package/ui-bundle/README.md +37 -3
- package/ui-bundle/client.cards.js +356 -0
- package/ui-bundle/client.denied.js +142 -0
- package/ui-bundle/client.dock.js +333 -0
- package/ui-bundle/client.helpers.js +271 -0
- package/ui-bundle/client.js +79 -1747
- package/ui-bundle/client.locales.js +247 -0
- package/ui-bundle/client.mode.js +206 -0
- package/ui-bundle/client.overlay.js +64 -0
- package/ui-bundle/client.styles.js +151 -0
- package/ui-bundle/client.toolview.js +82 -0
- package/ui-bundle/package.json +1 -0
- package/lib/doctor.js +0 -270
- package/lib/persistence.js +0 -349
- package/lib/store.js +0 -118
- /package/{lib → app}/briefing.js +0 -0
- /package/{lib → app}/spawn-support.js +0 -0
- /package/{lib → host}/compatibility.js +0 -0
- /package/{lib → host}/config.js +0 -0
- /package/{lib → host}/hmr.js +0 -0
- /package/{lib → host}/modeState.js +0 -0
- /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 {
|
|
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 ??
|
|
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
|
|
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
|
|
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
|
|
76
|
+
const stat = await tower.deps.store.stat(file)
|
|
73
77
|
if (Date.now() - stat.mtimeMs < maxAgeMs) continue
|
|
74
|
-
await
|
|
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
|
|
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} */ (
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
150
|
-
|
|
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
|
-
/**
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
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…_
|