dsh-tower 2.1.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +78 -5
  4. package/PRINCIPLES.md +163 -0
  5. package/README.en.md +35 -19
  6. package/README.md +28 -18
  7. package/SECURITY.md +6 -0
  8. package/{lib → adapters}/archive.js +1 -1
  9. package/{lib → adapters}/caseSensitivity.js +1 -1
  10. package/adapters/default.js +51 -10
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +1 -1
  13. package/{lib → adapters}/legacy.js +1 -1
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/store-fs.js +28 -1
  16. package/adapters/store.js +204 -0
  17. package/{lib → adapters}/tests.js +9 -5
  18. package/adapters/vcs-git.js +15 -1
  19. package/{lib → adapters}/workspaceLock.js +1 -1
  20. package/{lib → adapters}/worktreeOwnership.js +1 -1
  21. package/app/archive.js +4 -6
  22. package/app/batch.js +9 -10
  23. package/app/board.js +2 -4
  24. package/app/checkCompletion.js +9 -10
  25. package/app/enrichedMissions.js +2 -4
  26. package/app/findings.js +6 -7
  27. package/app/gate.js +1 -2
  28. package/app/init.js +24 -29
  29. package/app/merge.js +58 -51
  30. package/app/messages.js +7 -8
  31. package/app/mission.js +5 -5
  32. package/app/plan.js +8 -5
  33. package/app/provision.js +12 -14
  34. package/app/read.js +6 -3
  35. package/app/rebase.js +14 -16
  36. package/app/recording.js +7 -8
  37. package/app/review.js +15 -8
  38. package/app/roster.js +14 -15
  39. package/{lib → app}/spawn-prechecks.js +4 -3
  40. package/app/spawn.js +12 -13
  41. package/app/teardown.js +8 -9
  42. package/app/tower.js +89 -35
  43. package/cordis.patch.yml +56 -28
  44. package/docs/README.md +7 -2
  45. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  46. package/docs/adr/0002-single-state-json.md +50 -0
  47. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  48. package/docs/adr/0004-surface-budget.md +70 -0
  49. package/docs/adr/0005-component-surface.md +130 -0
  50. package/docs/adr/README.md +52 -0
  51. package/docs/agents/workflow.md +51 -0
  52. package/docs/architecture/overview.md +192 -43
  53. package/docs/guide/hot-reload.md +5 -5
  54. package/docs/guide/install-upgrade-migration.md +13 -0
  55. package/docs/guide/release.md +1 -1
  56. package/docs/operations/incident.md +82 -0
  57. package/docs/operations/runbook.md +12 -3
  58. package/docs/operations/security.md +1 -1
  59. package/docs/reference/blockers.md +2 -2
  60. package/docs/reference/component-surface.md +135 -0
  61. package/docs/reference/config.generated.md +2 -9
  62. package/docs/reference/config.md +11 -9
  63. package/docs/reference/doctor.md +53 -2
  64. package/docs/reference/errors.generated.md +41 -37
  65. package/docs/reference/errors.md +53 -41
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +157 -0
  68. package/docs/reference/tools.generated.md +8 -5
  69. package/docs/reference/tools.md +10 -7
  70. package/docs/zh/README.md +1 -1
  71. package/docs/zh/guide/hot-reload.md +4 -4
  72. package/docs/zh/guide/install-upgrade-migration.md +10 -0
  73. package/docs/zh/operations/security.md +1 -1
  74. package/docs/zh/reference/blockers.md +2 -2
  75. package/docs/zh/reference/config.md +9 -9
  76. package/docs/zh/reference/doctor.md +29 -1
  77. package/docs/zh/reference/tools.md +10 -8
  78. package/domain/batch.js +2 -2
  79. package/domain/components.js +641 -0
  80. package/domain/config/schema.js +62 -28
  81. package/domain/contracts/outputs.js +16 -2
  82. package/domain/contracts/tool-inputs/ops.js +1 -0
  83. package/domain/errors.js +308 -43
  84. package/domain/events.js +207 -0
  85. package/domain/integration/gate.js +17 -2
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +12 -1
  89. package/domain/missions/completion.js +14 -0
  90. package/domain/missions/lifecycle.js +9 -3
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/read/views.js +3 -0
  94. package/domain/state/archive.js +1 -1
  95. package/domain/state.js +1 -1
  96. package/domain/surface.js +347 -0
  97. package/host/capability-batch.js +15 -0
  98. package/host/capability-hmr.js +30 -0
  99. package/host/capability-import.js +11 -0
  100. package/host/capability-leases.js +11 -0
  101. package/host/capability-registry.js +149 -0
  102. package/host/capability-tests.js +16 -0
  103. package/{lib → host}/doctor.js +157 -18
  104. package/{lib → host}/hmr.js +2 -4
  105. package/{lib → host}/modeStore.js +1 -1
  106. package/host/presets.js +77 -0
  107. package/{lib → host}/tool-registry.js +235 -44
  108. package/host/tower.js +32 -0
  109. package/index.js +40 -25
  110. package/locale/en.json +31 -11
  111. package/locale/zh.json +30 -10
  112. package/package.json +24 -4
  113. package/ports/archive.js +18 -0
  114. package/ports/caseSensitivity.js +15 -0
  115. package/ports/clock.js +10 -2
  116. package/ports/config.js +19 -0
  117. package/ports/contract.js +38 -0
  118. package/ports/events.js +24 -0
  119. package/ports/ids.js +10 -2
  120. package/ports/index.js +54 -2
  121. package/ports/ownership.js +17 -0
  122. package/ports/state.js +16 -0
  123. package/ports/store.js +52 -3
  124. package/ports/vcs.js +25 -4
  125. package/scripts/architecture-waivers.json +19 -0
  126. package/scripts/architecture.mjs +634 -23
  127. package/scripts/check-components.mjs +235 -0
  128. package/scripts/check-docs.mjs +8 -35
  129. package/scripts/check-pack.mjs +68 -39
  130. package/scripts/check-philosophy.mjs +266 -0
  131. package/scripts/component-surface.json +395 -0
  132. package/scripts/domain-metrics.mjs +13 -4
  133. package/scripts/generate-component-surface.mjs +59 -0
  134. package/scripts/generate-config-reference.mjs +14 -1
  135. package/scripts/generate-error-reference.mjs +68 -14
  136. package/scripts/generate-events-reference.mjs +83 -0
  137. package/scripts/generate-tools-reference.mjs +18 -25
  138. package/scripts/generate-types.mjs +127 -0
  139. package/scripts/issue-audit.mjs +2 -2
  140. package/scripts/lib/surface.mjs +87 -0
  141. package/scripts/lib/tool-surface.mjs +85 -0
  142. package/scripts/mount-smoke/fixtures.mjs +3 -3
  143. package/scripts/mount-smoke/surface.mjs +96 -27
  144. package/scripts/mount-smoke.mjs +3 -1
  145. package/scripts/pack-manifest.txt +75 -22
  146. package/scripts/regression-audit.mjs +164 -0
  147. package/scripts/release-check.mjs +1 -1
  148. package/scripts/surface-baseline.json +52 -0
  149. package/scripts/surface-baseline.mjs +118 -0
  150. package/scripts/types-coverage.mjs +184 -0
  151. package/scripts/types-strict-manifest.txt +10 -0
  152. package/scripts/types-strict.mjs +141 -0
  153. package/skills.js +8 -1
  154. package/tools/adopt.js +2 -3
  155. package/tools/batch.js +2 -3
  156. package/tools/complete.js +2 -3
  157. package/tools/diff.js +3 -3
  158. package/tools/doctor.js +7 -10
  159. package/tools/finding.js +2 -3
  160. package/tools/findings.js +3 -3
  161. package/tools/import-legacy.js +3 -5
  162. package/tools/inbox.js +3 -3
  163. package/tools/index.js +9 -8
  164. package/tools/init.js +3 -6
  165. package/tools/interrupt.js +2 -3
  166. package/tools/legacy-map.js +3 -5
  167. package/tools/merge.js +3 -3
  168. package/tools/mission.js +2 -3
  169. package/tools/plan.js +6 -5
  170. package/tools/preflight.js +2 -3
  171. package/tools/rebase.js +3 -3
  172. package/tools/recover.js +38 -13
  173. package/tools/reload.js +36 -40
  174. package/tools/resource.js +3 -5
  175. package/tools/review.js +3 -4
  176. package/tools/roster.js +2 -3
  177. package/tools/send.js +2 -2
  178. package/tools/spawn.js +8 -5
  179. package/tools/status.js +11 -4
  180. package/tools/teardown.js +2 -3
  181. package/tools/test.js +2 -2
  182. package/types/index.d.ts +29 -13
  183. package/ui-bundle/README.md +37 -3
  184. package/ui-bundle/client.cards.js +356 -0
  185. package/ui-bundle/client.denied.js +142 -0
  186. package/ui-bundle/client.dock.js +333 -0
  187. package/ui-bundle/client.helpers.js +271 -0
  188. package/ui-bundle/client.js +79 -1747
  189. package/ui-bundle/client.locales.js +247 -0
  190. package/ui-bundle/client.mode.js +206 -0
  191. package/ui-bundle/client.overlay.js +64 -0
  192. package/ui-bundle/client.styles.js +151 -0
  193. package/ui-bundle/client.toolview.js +82 -0
  194. package/ui-bundle/package.json +1 -0
  195. package/lib/persistence.js +0 -356
  196. package/lib/presets.js +0 -35
  197. package/lib/store.js +0 -133
  198. /package/{lib → adapters}/stateFile.js +0 -0
  199. /package/{lib → app}/briefing.js +0 -0
  200. /package/{lib → app}/spawn-support.js +0 -0
  201. /package/{lib → host}/compatibility.js +0 -0
  202. /package/{lib → host}/config.js +0 -0
  203. /package/{lib → host}/modeState.js +0 -0
@@ -0,0 +1,347 @@
1
+ // Concept budget: surface metrics, the committed baseline and its classifications (#84).
2
+ //
3
+ // The repository does not measure "how many concepts a user has to hold" by
4
+ // counting lines. It measures the surface — config leaf keys, config sections and
5
+ // registered tools — and keeps a committed baseline. Growth is refused by
6
+ // default: a baseline decision that raises a count must name either an ADR (why
7
+ // the surface has to grow) or deletions that are provably gone from the live
8
+ // surface (the added concept replaced a removed one).
9
+ //
10
+ // This module is pure: no fs, no git. `scripts/surface-baseline.mjs` supplies
11
+ // the live counts and the file reads; `scripts/lib/surface.mjs` wraps them.
12
+ //
13
+ // Since #120 `components` is a fourth surface: the number of user-visible
14
+ // component rows in `cordis.patch.yml`. That is what makes a contraction (the
15
+ // 2.3.0 4 -> 1 group row) and the coming #121 growth require a written decision
16
+ // instead of arriving quietly.
17
+ export const SURFACE_BASELINE_FILE = 'scripts/surface-baseline.json'
18
+ export const SURFACE_CLASSIFICATION_FILE = 'docs/reference/surface-budget.md'
19
+ export const SURFACE_KEYS = ['configKeys', 'sections', 'tools', 'components']
20
+ // A count in this list is two-way: a contraction needs a decision record just
21
+ // like growth does. The component surface is where the failure actually
22
+ // happened (4 top-level rows -> 1 group row), so "a reduction is cheap" is
23
+ // exactly the policy hole #120 closes.
24
+ export const SURFACE_TWO_WAY_KEYS = ['components']
25
+ export const CONFIG_CLASSES = ['core', 'tuning', 'niche']
26
+ export const TOOL_CLASSES = ['core', 'optional']
27
+ export const SURFACE_SCHEMA_VERSION = 1
28
+
29
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
30
+
31
+ /**
32
+ * The human-readable count line shared by `tower_doctor` and the generated
33
+ * references: `config keys: N · sections: N · tools: N`.
34
+ * @param {{ configKeys?: number, sections?: number, tools?: number }} counts
35
+ */
36
+ export function surfaceLine(counts = {}) {
37
+ const value = (key) => (Number.isInteger(counts[key]) ? String(counts[key]) : '?')
38
+ return `config keys: ${value('configKeys')} · sections: ${value('sections')} · tools: ${value('tools')}`
39
+ }
40
+
41
+ function isPlainObject(value) {
42
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
43
+ }
44
+
45
+ function surfaceError(message) {
46
+ return new Error(message)
47
+ }
48
+
49
+ /**
50
+ * Parse and validate `scripts/surface-baseline.json`. Throws with an actionable
51
+ * message when the file is malformed; returns a normalized copy otherwise.
52
+ * @param {string | object} source
53
+ */
54
+ export function parseBaseline(source) {
55
+ let value
56
+ if (typeof source === 'string') {
57
+ try {
58
+ value = JSON.parse(source)
59
+ } catch (error) {
60
+ throw surfaceError(`surface baseline is not valid JSON: ${error?.message ?? error}`)
61
+ }
62
+ } else {
63
+ value = source
64
+ }
65
+ if (!isPlainObject(value)) throw surfaceError('surface baseline must be a JSON object')
66
+ if (value.schema !== undefined && value.schema !== SURFACE_SCHEMA_VERSION) {
67
+ throw surfaceError(`surface baseline schema ${value.schema} is not supported (expected ${SURFACE_SCHEMA_VERSION})`)
68
+ }
69
+ if (typeof value.date !== 'string' || !ISO_DATE.test(value.date)) {
70
+ throw surfaceError(`surface baseline needs a date (YYYY-MM-DD), got ${JSON.stringify(value.date)}`)
71
+ }
72
+ if (!isPlainObject(value.surfaces)) throw surfaceError('surface baseline needs a "surfaces" object')
73
+ const surfaces = {}
74
+ for (const key of SURFACE_KEYS) {
75
+ const entry = value.surfaces[key]
76
+ if (!isPlainObject(entry)) throw surfaceError(`surface baseline misses the "${key}" entry`)
77
+ if (!Number.isInteger(entry.seed) || entry.seed < 0) {
78
+ throw surfaceError(`surface baseline ${key}.seed must be a non-negative integer`)
79
+ }
80
+ if (!Number.isInteger(entry.value) || entry.value < 0) {
81
+ throw surfaceError(`surface baseline ${key}.value must be a non-negative integer`)
82
+ }
83
+ if (!Array.isArray(entry.decisions)) throw surfaceError(`surface baseline ${key}.decisions must be an array`)
84
+ surfaces[key] = {
85
+ seed: entry.seed,
86
+ value: entry.value,
87
+ decisions: entry.decisions.map((decision, index) => {
88
+ if (!isPlainObject(decision)) {
89
+ throw surfaceError(`surface baseline ${key}.decisions[${index}] must be an object`)
90
+ }
91
+ if (!Number.isInteger(decision.from) || decision.from < 0) {
92
+ throw surfaceError(`surface baseline ${key}.decisions[${index}].from must be a non-negative integer`)
93
+ }
94
+ if (!Number.isInteger(decision.to) || decision.to < 0) {
95
+ throw surfaceError(`surface baseline ${key}.decisions[${index}].to must be a non-negative integer`)
96
+ }
97
+ return { ...decision }
98
+ }),
99
+ }
100
+ }
101
+ return { schema: SURFACE_SCHEMA_VERSION, date: value.date, surfaces }
102
+ }
103
+
104
+ /**
105
+ * The rows of the classification table, already parsed. `kind` is `config` or
106
+ * `tools`, `name` is the key/tool name, `className` is the declared class.
107
+ * @param {string} markdown
108
+ */
109
+ export function parseClassification(markdown) {
110
+ const text = String(markdown ?? '')
111
+ const dateMatch = /^\s*(?:[-*]\s*)?Decision date:\s*(\d{4}-\d{2}-\d{2})\s*$/im.exec(text)
112
+ const entries = []
113
+ let kind = null
114
+ for (const rawLine of text.split(/\r?\n/)) {
115
+ const heading = /^#{1,6}\s+(.+?)\s*$/.exec(rawLine)
116
+ if (heading) {
117
+ const title = heading[1].toLowerCase()
118
+ kind = title.includes('config') ? 'config' : title.includes('tool') ? 'tools' : null
119
+ continue
120
+ }
121
+ if (!kind) continue
122
+ const match = /^\|\s*`([^`]+)`\s*\|\s*([a-z-]+)\s*\|\s*(.*?)\s*\|\s*$/.exec(rawLine)
123
+ if (!match) continue
124
+ entries.push({ kind, name: match[1].trim(), className: match[2].trim().toLowerCase(), note: match[3].trim() })
125
+ }
126
+ return { date: dateMatch ? dateMatch[1] : null, entries }
127
+ }
128
+
129
+ /**
130
+ * Validate the classification table against the live surface. Every live key and
131
+ * tool must appear exactly once with a legal class; every `niche` row must carry
132
+ * a removal note, so "we will remove it later" is a written promise rather than
133
+ * an implication.
134
+ * @param {string} markdown
135
+ * @param {{ configKeys: string[], sections: string[], tools: string[] }} liveNames
136
+ * @param {{ baselineDate?: string }} [options]
137
+ * @returns {{ date: string | null, entries: Array<{ kind: string, name: string, className: string, note: string }>, findings: Array<{ code: string, level: string, message: string }> }}
138
+ */
139
+ export function validateClassification(markdown, liveNames, options = {}) {
140
+ const parsed = parseClassification(markdown)
141
+ const findings = []
142
+ const text = String(markdown ?? '')
143
+ const record = (code, message) => findings.push({ code, level: 'error', message })
144
+ if (!parsed.date) {
145
+ record('classification-date', 'classification table needs a "Decision date: YYYY-MM-DD" line')
146
+ } else if (options.baselineDate && parsed.date !== options.baselineDate) {
147
+ record(
148
+ 'classification-date',
149
+ `classification date ${parsed.date} does not match the baseline date ${options.baselineDate}`,
150
+ )
151
+ }
152
+
153
+ const checkRows = (kind, expected, legal) => {
154
+ const rows = parsed.entries.filter((entry) => entry.kind === kind)
155
+ const seen = new Map()
156
+ for (const row of rows) {
157
+ if (seen.has(row.name)) record('classification-duplicate', `${kind === 'config' ? 'config key' : 'tool'} ${row.name} is classified twice`)
158
+ seen.set(row.name, row)
159
+ if (!legal.includes(row.className)) {
160
+ record(
161
+ 'classification-class',
162
+ `${kind === 'config' ? 'config key' : 'tool'} ${row.name} has class "${row.className}" (expected ${legal.join('/')})`,
163
+ )
164
+ }
165
+ }
166
+ for (const name of expected) {
167
+ if (!seen.has(name)) record('classification-missing', `${kind === 'config' ? 'config key' : 'tool'} ${name} is not classified`)
168
+ }
169
+ const expectedSet = new Set(expected)
170
+ for (const name of seen.keys()) {
171
+ if (!expectedSet.has(name)) record('classification-unknown', `${kind === 'config' ? 'config key' : 'tool'} ${name} is not in the live surface`)
172
+ }
173
+ return rows
174
+ }
175
+
176
+ const configRows = checkRows('config', liveNames.configKeys, CONFIG_CLASSES)
177
+ checkRows('tools', liveNames.tools, TOOL_CLASSES)
178
+ const nicheRows = configRows.filter((row) => row.className === 'niche')
179
+ for (const row of nicheRows) {
180
+ if (!/removal note:/i.test(row.note)) {
181
+ record('niche-removal-note', `niche config key ${row.name} needs a "Removal note:" in its table row`)
182
+ }
183
+ }
184
+ if (nicheRows.length === 0 && !/no key qualifies as niche/i.test(text)) {
185
+ record('niche-removal-note', 'no niche config key has a removal note and the table does not say "no key qualifies as niche"')
186
+ }
187
+ return { date: parsed.date, entries: parsed.entries, findings }
188
+ }
189
+
190
+ /**
191
+ * Validate an explicit measurement-correction decision (#118).
192
+ *
193
+ * A correction says the live surface did not grow: the earlier measurement
194
+ * missed a registration path. It is deliberately narrower than an ADR — it only
195
+ * authorizes the written `from`/`to` step it carries, needs a reason and a date,
196
+ * and (when it names a `ref`) that reference must resolve. A later count change
197
+ * still needs its own decision, so the brake stays strict.
198
+ *
199
+ * @returns {boolean} true when the correction is valid evidence for the growth
200
+ */
201
+ function correctionEvidence(decision, where, record, exists) {
202
+ if (decision.correction === undefined) return false
203
+ if (!isPlainObject(decision.correction)) {
204
+ record('correction-invalid', `${where}.correction must be an object with a reason and a date`)
205
+ return false
206
+ }
207
+ const correction = decision.correction
208
+ const reason = typeof correction.reason === 'string' ? correction.reason.trim() : ''
209
+ if (!reason) {
210
+ record('correction-invalid', `${where}.correction needs a non-empty reason explaining why the measurement changed`)
211
+ }
212
+ const date = correction.date ?? decision.date
213
+ const dateOk = typeof date === 'string' && ISO_DATE.test(date)
214
+ if (!dateOk) {
215
+ record('correction-invalid', `${where}.correction needs a date (YYYY-MM-DD)`)
216
+ } else if (typeof decision.date === 'string' && date !== decision.date) {
217
+ record('correction-invalid', `${where}.correction.date ${date} does not match the decision date ${decision.date}`)
218
+ }
219
+ let refOk = true
220
+ if (correction.ref !== undefined) {
221
+ const ref = typeof correction.ref === 'string' ? correction.ref.trim().replace(/\\/g, '/') : ''
222
+ if (!ref) {
223
+ record('correction-invalid', `${where}.correction.ref must be a repository path when present`)
224
+ refOk = false
225
+ } else if (!ref.startsWith('docs/') || !ref.endsWith('.md')) {
226
+ record('correction-invalid', `${where}.correction.ref must name a docs/*.md record, got ${ref}`)
227
+ refOk = false
228
+ } else if (!exists(ref)) {
229
+ record('correction-invalid', `${where}.correction.ref ${ref} does not exist`)
230
+ refOk = false
231
+ }
232
+ }
233
+ return reason.length > 0 && dateOk && refOk
234
+ }
235
+
236
+ /**
237
+ * Validate one surface entry against its live count and its decision chain. This
238
+ * is the brake: `live !== entry.value` fails, and every decision that grows a
239
+ * count must name an existing ADR, deletions that are provably absent from the
240
+ * live surface, or a valid measurement-correction record.
241
+ *
242
+ * A two-way surface (see SURFACE_TWO_WAY_KEYS) also refuses a contraction whose
243
+ * new count is not reached by a decision record; the other surfaces keep the
244
+ * "a reduction is cheap" rule from ADR 0004.
245
+ *
246
+ * Extracted from `validateBaseline` so a narrower gate (`docs:check:components`,
247
+ * #120) can validate just the component count without re-measuring the others.
248
+ *
249
+ * @param {string} key one of SURFACE_KEYS
250
+ * @param {{ seed: number, value: number, decisions: any[] }} entry
251
+ * @param {number} live
252
+ * @param {string[]} liveNames
253
+ * @param {{ exists?: (relativePath: string) => boolean }} [options]
254
+ * @returns {Array<{ code: string, level: string, message: string }>}
255
+ */
256
+ export function validateSurfaceEntry(key, entry, live, liveNames, options = {}) {
257
+ const exists = options.exists ?? (() => false)
258
+ const findings = []
259
+ const record = (code, message) => findings.push({ code, level: 'error', message })
260
+ if (!Number.isInteger(live)) {
261
+ record('surface-missing', `live ${key} count is unavailable`)
262
+ return findings
263
+ }
264
+ if (live !== entry.value) {
265
+ const direction = live > entry.value ? 'grew' : 'shrank'
266
+ record(
267
+ 'stale-baseline',
268
+ `${key} ${direction} from the committed baseline ${entry.value} to ${live}; update ${SURFACE_BASELINE_FILE} with an ADR or a deletion record before landing`,
269
+ )
270
+ }
271
+ let running = entry.seed
272
+ entry.decisions.forEach((decision, index) => {
273
+ const where = `${key}.decisions[${index}]`
274
+ if (decision.from !== running) {
275
+ record('broken-chain', `${where}.from ${decision.from} does not continue from ${running}`)
276
+ }
277
+ if (decision.date !== undefined && !ISO_DATE.test(String(decision.date))) {
278
+ record('broken-chain', `${where}.date is not YYYY-MM-DD`)
279
+ }
280
+ const growth = decision.to - decision.from
281
+ if (growth > 0) {
282
+ const deletions = Array.isArray(decision.deletions)
283
+ ? decision.deletions.filter((name) => typeof name === 'string' && name.trim()).map((name) => name.trim())
284
+ : []
285
+ const adr = typeof decision.adr === 'string' && decision.adr.trim() ? decision.adr.trim().replace(/\\/g, '/') : null
286
+ const adrExists = Boolean(adr) && adr.startsWith('docs/adr/') && adr.endsWith('.md') && exists(adr)
287
+ const liveSet = new Set(liveNames ?? [])
288
+ const stillLive = deletions.filter((name) => liveSet.has(name))
289
+ const deletionEvidence = deletions.length >= growth && stillLive.length === 0
290
+ const correction = correctionEvidence(decision, where, record, exists)
291
+ if (!adrExists && !deletionEvidence && !correction) {
292
+ const reasons = []
293
+ if (adr && !adrExists) reasons.push(`ADR ${adr} does not exist under docs/adr/`)
294
+ if (deletions.length === 0) reasons.push('no deletions recorded')
295
+ else if (stillLive.length > 0) reasons.push(`deleted name(s) are still in the live surface: ${stillLive.join(', ')}`)
296
+ else if (deletions.length < growth) reasons.push(`only ${deletions.length} deletion(s) for a growth of ${growth}`)
297
+ if (decision.correction !== undefined && !correction) reasons.push('the correction record is invalid')
298
+ record('growth-without-evidence', `${where} grows ${key} by ${growth} without an ADR, a matching deletion or a valid correction (${reasons.join('; ')})`)
299
+ }
300
+ }
301
+ running = decision.to
302
+ })
303
+ const twoWay = SURFACE_TWO_WAY_KEYS.includes(key)
304
+ if (running !== entry.value && !(entry.decisions.length === 0 && entry.value < running && !twoWay)) {
305
+ record(
306
+ 'broken-chain',
307
+ `${key} value ${entry.value} does not match the last decision (${running}); every count change needs a decision record`,
308
+ )
309
+ }
310
+ return findings
311
+ }
312
+
313
+ /**
314
+ * Validate the whole committed baseline. Every surface in SURFACE_KEYS is
315
+ * checked, so a missing `current` entry is a `surface-missing` finding.
316
+ *
317
+ * @param {ReturnType<typeof parseBaseline>} baseline
318
+ * @param {{ configKeys: number, sections: number, tools: number, components: number, liveNames: { configKeys: string[], sections: string[], tools: string[], components: string[] } }} current
319
+ * @param {{ exists?: (relativePath: string) => boolean }} [options]
320
+ * @returns {Array<{ code: string, level: string, message: string }>}
321
+ */
322
+ export function validateBaseline(baseline, current, options = {}) {
323
+ const findings = []
324
+ for (const key of SURFACE_KEYS) {
325
+ findings.push(...validateSurfaceEntry(key, baseline.surfaces[key], current[key], current.liveNames?.[key], options))
326
+ }
327
+ return findings
328
+ }
329
+
330
+ /**
331
+ * Validate one surface entry of the committed baseline (the narrower form used
332
+ * by `docs:check:components`).
333
+ *
334
+ * @param {ReturnType<typeof parseBaseline>} baseline
335
+ * @param {string} key
336
+ * @param {Record<string, any>} current
337
+ * @param {{ exists?: (relativePath: string) => boolean }} [options]
338
+ * @returns {Array<{ code: string, level: string, message: string }>}
339
+ */
340
+ export function validateSurfaceBaselineKey(baseline, key, current, options = {}) {
341
+ if (!SURFACE_KEYS.includes(key)) {
342
+ throw surfaceError(`surface ${key} is not one of ${SURFACE_KEYS.join('/')}`)
343
+ }
344
+ const entry = baseline.surfaces[key]
345
+ if (!entry) throw surfaceError(`surface baseline has no "${key}" entry`)
346
+ return validateSurfaceEntry(key, entry, current[key], current.liveNames?.[key], options)
347
+ }
@@ -0,0 +1,15 @@
1
+ // tower-batch component entry (#121): evidence batches only.
2
+ //
3
+ // Each optional capability is its own loader row, so switching this component
4
+ // off removes exactly `tower_batch` and leaves the rest of Tower working. The
5
+ // row is declared in cordis.patch.yml; the package export is `./batch`.
6
+ import { holdComponent } from './capability-registry.js'
7
+ import { registerBatchCapability } from './tool-registry.js'
8
+
9
+ export const name = 'dsh-tower/batch'
10
+ export const inject = ['tools', 'tower']
11
+
12
+ export function apply(ctx) {
13
+ holdComponent(ctx, 'tower-batch')
14
+ registerBatchCapability(ctx)
15
+ }
@@ -0,0 +1,30 @@
1
+ // tower-hmr component entry (#121): dev hot reload, shipped off.
2
+ //
3
+ // The row defaults to `disabled: true` in cordis.patch.yml, because a profile
4
+ // without HMR is the normal case and the capability only makes sense for linked
5
+ // development installs. Switching the row on registers `tower_reload` and the
6
+ // `/tower reload` branch; switching it off leaves both absent while
7
+ // `tower_status` reports "disabled by component tower-hmr".
8
+ import { createHmrBridge } from './hmr.js'
9
+ import { runtimeConfig } from './config.js'
10
+ import { clearHmrBridge, holdComponent, setHmrBridge } from './capability-registry.js'
11
+ import { registerHmrCapability } from './tool-registry.js'
12
+
13
+ export const name = 'dsh-tower/hmr'
14
+ export const inject = ['tools', 'tower']
15
+
16
+ export function apply(ctx) {
17
+ holdComponent(ctx, 'tower-hmr')
18
+ const dev = runtimeConfig().dev ?? {}
19
+ const bridge = createHmrBridge(ctx, {
20
+ requiredIds: dev.reloadRequiredIds,
21
+ delayMs: dev.reloadDelayMs,
22
+ logger: ctx.logger ?? console,
23
+ })
24
+ setHmrBridge(bridge)
25
+ ctx.effect(() => () => {
26
+ clearHmrBridge(bridge)
27
+ bridge.dispose()
28
+ })
29
+ registerHmrCapability(ctx, { bridge })
30
+ }
@@ -0,0 +1,11 @@
1
+ // tower-import component entry (#121): legacy import and its audit map only.
2
+ import { holdComponent } from './capability-registry.js'
3
+ import { registerImportCapability } from './tool-registry.js'
4
+
5
+ export const name = 'dsh-tower/import'
6
+ export const inject = ['tools', 'tower']
7
+
8
+ export function apply(ctx) {
9
+ holdComponent(ctx, 'tower-import')
10
+ registerImportCapability(ctx)
11
+ }
@@ -0,0 +1,11 @@
1
+ // tower-leases component entry (#121): advisory resource leases only.
2
+ import { holdComponent } from './capability-registry.js'
3
+ import { registerLeasesCapability } from './tool-registry.js'
4
+
5
+ export const name = 'dsh-tower/leases'
6
+ export const inject = ['tools', 'tower']
7
+
8
+ export function apply(ctx) {
9
+ holdComponent(ctx, 'tower-leases')
10
+ registerLeasesCapability(ctx)
11
+ }
@@ -0,0 +1,149 @@
1
+ // Runtime view of the optional component rows (#121).
2
+ //
3
+ // The component rows in `cordis.patch.yml` are separate loader entries. A
4
+ // capability row that is off simply never runs `apply()`, so the host cannot
5
+ // ask a disabled plugin to report itself. This module is the small shared
6
+ // registry the capability entry points write into and the read-only reporters
7
+ // (`tower_status`, `tower_doctor`) read back. An absent entry is a disabled
8
+ // component, reported with the exact "disabled by component <id>" wording the
9
+ // acceptance contract asks for, never a silent absence.
10
+ //
11
+ // The registry is process-local and reference-counted: a capability entry point
12
+ // registers on apply and unregisters through its `ctx.effect` disposer, so an
13
+ // HMR reload cannot leave a stale "enabled" claim behind. It is coordination,
14
+ // not state: no file is written and no tool result depends on it except the
15
+ // capability report itself.
16
+
17
+ /** The visible optional component rows, in `cordis.patch.yml` order. */
18
+ export const TOWER_COMPONENT_IDS = Object.freeze([
19
+ 'tower-skills',
20
+ 'tower-batch',
21
+ 'tower-import',
22
+ 'tower-leases',
23
+ 'tower-tests',
24
+ 'tower-hmr',
25
+ 'preset-tower-reviewer',
26
+ ])
27
+
28
+ /** Tool ids each code component owns; the preset row owns no tool. */
29
+ export const COMPONENT_TOOLS = Object.freeze({
30
+ 'tower-skills': Object.freeze([]),
31
+ 'tower-batch': Object.freeze(['tower_batch']),
32
+ 'tower-import': Object.freeze(['tower_import_legacy', 'tower_legacy_map']),
33
+ 'tower-leases': Object.freeze(['tower_resource']),
34
+ 'tower-tests': Object.freeze(['tower_test']),
35
+ 'tower-hmr': Object.freeze(['tower_reload']),
36
+ 'preset-tower-reviewer': Object.freeze([]),
37
+ })
38
+
39
+ /** id -> number of live registrations (redelivery/HMR can overlap briefly). */
40
+ const active = new Map()
41
+
42
+ /**
43
+ * Mark one component as mounted. IDEMPOTENT per call site: the reference count
44
+ * lets a brief double-mount during HMR stay "enabled" until the last disposer
45
+ * runs.
46
+ * @param {string} id
47
+ */
48
+ export function registerComponent(id) {
49
+ if (!TOWER_COMPONENT_IDS.includes(id)) throw new TypeError(`unknown Tower component: ${id}`)
50
+ active.set(id, (active.get(id) ?? 0) + 1)
51
+ }
52
+
53
+ /** Drop one registration; a component is disabled when its count reaches 0. */
54
+ export function unregisterComponent(id) {
55
+ const count = active.get(id) ?? 0
56
+ if (count <= 1) active.delete(id)
57
+ else active.set(id, count - 1)
58
+ }
59
+
60
+ /** True when a code component entry point is currently mounted. */
61
+ export function componentEnabled(id) {
62
+ return (active.get(id) ?? 0) > 0
63
+ }
64
+
65
+ /** Register/unregister a component around one plugin's lifetime. */
66
+ export function holdComponent(ctx, id) {
67
+ registerComponent(id)
68
+ if (typeof ctx?.effect === 'function') {
69
+ ctx.effect(() => () => unregisterComponent(id))
70
+ }
71
+ return () => unregisterComponent(id)
72
+ }
73
+
74
+ /** Test hook: process-local registry state must be reset per fixture. */
75
+ export function __testResetComponentRegistry() {
76
+ active.clear()
77
+ sharedHmrBridge = null
78
+ }
79
+
80
+ /** The exact wording the acceptance contract uses for a missing capability. */
81
+ export function disabledReason(id) {
82
+ return `disabled by component ${id}`
83
+ }
84
+
85
+ /** Reviewer-preset availability from the optional agent-presets service. */
86
+ async function reviewerPresetAvailable(ctx) {
87
+ const service = ctx?.get?.('agentPresets')
88
+ if (!service || typeof service.list !== 'function') return null
89
+ try {
90
+ const presets = await service.list()
91
+ return Array.isArray(presets) && presets.some((entry) => String(entry?.id) === 'tower-reviewer')
92
+ } catch {
93
+ return null
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Report every optional component as enabled or disabled. The reviewer preset
99
+ * is not a code entry point: its row mounts an agent preset, so availability is
100
+ * read from `agentPresets` and reported as unknown (omitted) when that service
101
+ * is absent, exactly as the doctor's preset check does.
102
+ *
103
+ * @param {any} ctx
104
+ * @returns {Promise<{ enabled: string[], disabled: Array<{ id: string, reason: string }> }>}
105
+ */
106
+ export async function componentReport(ctx) {
107
+ const enabled = []
108
+ const disabled = []
109
+ for (const id of TOWER_COMPONENT_IDS) {
110
+ if (id === 'preset-tower-reviewer') {
111
+ const available = await reviewerPresetAvailable(ctx)
112
+ if (available === false) disabled.push({ id, reason: disabledReason(id) })
113
+ else if (available === true) enabled.push(id)
114
+ continue
115
+ }
116
+ if (componentEnabled(id)) enabled.push(id)
117
+ else disabled.push({ id, reason: disabledReason(id) })
118
+ }
119
+ return { enabled, disabled }
120
+ }
121
+
122
+ // A capability plugin's `ctx.provide()` is visible to its descendants, not to
123
+ // the parent host context that owns `/tower` and `tower_mode`. The hot-reload
124
+ // bridge is a process singleton, so it is shared here rather than through a
125
+ // service lookup the host could not see.
126
+ let sharedHmrBridge = null
127
+
128
+ /** Publish the mounted tower-hmr bridge for the host entry to read. */
129
+ export function setHmrBridge(bridge) {
130
+ sharedHmrBridge = bridge
131
+ }
132
+
133
+ /** Clear the publication when the component unloads; never clear another bridge. */
134
+ export function clearHmrBridge(bridge) {
135
+ if (sharedHmrBridge === bridge) sharedHmrBridge = null
136
+ }
137
+
138
+ /** The mounted tower-hmr bridge, or null when the component row is off. */
139
+ export function currentHmrBridge() {
140
+ return sharedHmrBridge
141
+ }
142
+
143
+ /** One-line form used by `tower_status` and the doctor's `components` check. */
144
+ export function componentSummary(report) {
145
+ if (!report || report.disabled.length === 0) {
146
+ return `${report?.enabled?.length ?? 0} optional component(s) enabled`
147
+ }
148
+ return report.disabled.map((entry) => entry.reason).join('; ')
149
+ }
@@ -0,0 +1,16 @@
1
+ // tower-tests component entry (#121): declared test discovery only.
2
+ //
3
+ // With this row off there is no `tower_test` tool and no receipt to satisfy
4
+ // `gates.requireTests`; planning/mode entry refuse that combination with an
5
+ // explanatory error (host/presets.js), and the merge gate still reports
6
+ // `tests-missing` because no receipt can exist.
7
+ import { holdComponent } from './capability-registry.js'
8
+ import { registerTestsCapability } from './tool-registry.js'
9
+
10
+ export const name = 'dsh-tower/tests'
11
+ export const inject = ['tools', 'tower']
12
+
13
+ export function apply(ctx) {
14
+ holdComponent(ctx, 'tower-tests')
15
+ registerTestsCapability(ctx)
16
+ }