dsh-tower 2.4.1 → 2.5.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 (82) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +11 -5
  4. package/GLOSSARY.md +67 -0
  5. package/README.md +6 -0
  6. package/app/archive.js +16 -1
  7. package/app/board.js +16 -1
  8. package/app/gate.js +14 -0
  9. package/app/spawn-prechecks.js +18 -3
  10. package/cordis.patch.yml +27 -8
  11. package/docs/adr/0004-surface-budget.md +10 -4
  12. package/docs/adr/0005-component-surface.md +166 -0
  13. package/docs/architecture/overview.md +47 -20
  14. package/docs/guide/install-upgrade-migration.md +21 -0
  15. package/docs/guide/quickstart.md +71 -0
  16. package/docs/guide/release.md +60 -19
  17. package/docs/reference/component-surface.md +29 -17
  18. package/docs/reference/config.generated.md +4 -7
  19. package/docs/reference/config.md +7 -5
  20. package/docs/reference/doctor.md +11 -5
  21. package/docs/reference/surface-budget.md +29 -11
  22. package/docs/reference/tools.generated.md +1 -1
  23. package/docs/reference/tools.md +2 -2
  24. package/docs/zh/guide/install-upgrade-migration.md +1 -1
  25. package/docs/zh/reference/config.md +7 -5
  26. package/docs/zh/reference/doctor.md +7 -2
  27. package/docs/zh/reference/tools.md +3 -2
  28. package/domain/component-rules.js +216 -0
  29. package/domain/components.js +169 -49
  30. package/domain/config/schema.js +46 -23
  31. package/domain/gate.js +17 -2
  32. package/domain/scope.js +176 -5
  33. package/domain/surface.js +21 -7
  34. package/host/capability-archive.js +18 -0
  35. package/host/capability-board.js +18 -0
  36. package/host/capability-registry.js +4 -0
  37. package/host/doctor.js +50 -5
  38. package/host/preset-reviewer.js +27 -0
  39. package/host/preset-worker.js +32 -0
  40. package/host/presets.js +17 -0
  41. package/host/tool-registry.js +21 -4
  42. package/index.js +17 -6
  43. package/locale/archive/en.json +6 -0
  44. package/locale/archive/zh.json +6 -0
  45. package/locale/batch/en.json +1 -1
  46. package/locale/batch/zh.json +1 -1
  47. package/locale/board/en.json +6 -0
  48. package/locale/board/zh.json +6 -0
  49. package/locale/en.json +41 -21
  50. package/locale/hmr/en.json +2 -2
  51. package/locale/hmr/zh.json +1 -1
  52. package/locale/import/en.json +1 -1
  53. package/locale/import/zh.json +1 -1
  54. package/locale/leases/en.json +2 -2
  55. package/locale/leases/zh.json +2 -2
  56. package/locale/preset-reviewer/en.json +6 -0
  57. package/locale/preset-reviewer/zh.json +6 -0
  58. package/locale/preset-worker/en.json +6 -0
  59. package/locale/preset-worker/zh.json +6 -0
  60. package/locale/skills/en.json +1 -1
  61. package/locale/skills/zh.json +1 -1
  62. package/locale/tests/en.json +2 -2
  63. package/locale/tests/zh.json +2 -2
  64. package/locale/zh.json +40 -20
  65. package/package.json +15 -2
  66. package/scripts/check-components.mjs +642 -42
  67. package/scripts/check-pack.mjs +7 -3
  68. package/scripts/component-surface.json +87 -29
  69. package/scripts/generate-capability-locale.mjs +100 -21
  70. package/scripts/mount-smoke/probe.mjs +33 -3
  71. package/scripts/mount-smoke/surface.mjs +144 -1
  72. package/scripts/mount-smoke.mjs +24 -4
  73. package/scripts/pack-manifest.txt +15 -0
  74. package/scripts/release-notes.mjs +256 -0
  75. package/scripts/surface-baseline.json +32 -3
  76. package/scripts/surface-baseline.mjs +15 -5
  77. package/skills.js +4 -1
  78. package/tools/complete.js +3 -1
  79. package/tools/spawn.js +5 -5
  80. package/tools/test.js +7 -7
  81. package/ui-bundle/locale/en.json +1 -1
  82. package/ui-bundle/locale/zh.json +1 -1
@@ -29,7 +29,9 @@ export const BUNDLE_CHILD_IDS = ['preset-tower-worker', 'tower-host']
29
29
  /** The optional capability rows that sit at the top level beside the host. */
30
30
  export const OPTIONAL_ROW_IDS = [
31
31
  'preset-tower-reviewer',
32
+ 'tower-archive',
32
33
  'tower-batch',
34
+ 'tower-board',
33
35
  'tower-hmr',
34
36
  'tower-import',
35
37
  'tower-leases',
@@ -55,11 +57,30 @@ export function defaultOffToolIds() {
55
57
  return ids
56
58
  }
57
59
 
60
+ /**
61
+ * The two preset rows the bundle declares. Each row mounts a Tower-owned
62
+ * declaration module (`dsh-tower/preset-*`) whose config the agent-preset
63
+ * registry registers; the label and the read-only reviewer surface are part of
64
+ * the contract, so the smoke compares the loaded `agentPresets.list()` entries
65
+ * against this table (#121).
66
+ */
67
+ export const PRESET_ROWS = Object.freeze([
68
+ Object.freeze({ id: 'tower-worker', specifier: 'preset-worker', name: 'Tower Worker', order: 60 }),
69
+ Object.freeze({ id: 'tower-reviewer', specifier: 'preset-reviewer', name: 'Tower Reviewer', order: 61 }),
70
+ ])
71
+
58
72
  /** Presets the bundle declares for its worker and reviewer roles. */
59
- export const PRESET_IDS = ['tower-reviewer', 'tower-worker']
73
+ export const PRESET_IDS = PRESET_ROWS.map((row) => row.id).sort()
74
+
75
+ /** Module specifiers the two preset rows mount; each owns its Plugins-page label. */
76
+ export const PRESET_SPECIFIERS = PRESET_ROWS.map((row) => `dsh-tower/${row.specifier}`)
60
77
 
61
78
  /** Cordis fiber state 2: mounted and active. */
62
79
  const FIBER_ACTIVE = 2
80
+ /** Cordis fiber state 3: the plugin failed to activate. */
81
+ const FIBER_FAILED = 3
82
+ /** Cordis fiber state 1: activation is still in flight (the boot has not settled). */
83
+ const FIBER_PENDING = 1
63
84
 
64
85
  const OWNED_ROW_IDS = new Set([BUNDLE_ID, ...BUNDLE_CHILD_IDS, ...OPTIONAL_ROW_IDS])
65
86
 
@@ -128,6 +149,128 @@ export function compareIds(label, expectedIds, loadedIds) {
128
149
  return failures
129
150
  }
130
151
 
152
+ /** Read one localized field: DSH reports `{ en, zh }` maps, a bare string, or nothing. */
153
+ function localizedField(value, lang) {
154
+ if (typeof value === 'string') return value.trim() || undefined
155
+ if (value && typeof value === 'object' && typeof value[lang] === 'string') return value[lang].trim() || undefined
156
+ return undefined
157
+ }
158
+
159
+ /**
160
+ * Assert the two preset rows resolve distinct Plugins-page labels. The defect
161
+ * this mission fixes was two rows sharing `@deepseek-ai/dsh-agent-preset`, so
162
+ * both rendered that module's single title; the smoke now reads the same
163
+ * `readPluginMeta` the page uses and requires a distinct title and description
164
+ * per specifier (#121).
165
+ * @param {Record<string, { title?: unknown, description?: unknown, error?: string } | null>} labels
166
+ * module specifier -> label metadata from `readPluginMeta`.
167
+ * @returns {string[]} one message per missing, failed or indistinguishable label.
168
+ */
169
+ export function checkPresetLabels(labels) {
170
+ const failures = []
171
+ const byLanguage = { en: new Map(), zh: new Map() }
172
+ for (const row of PRESET_ROWS) {
173
+ const specifier = `dsh-tower/${row.specifier}`
174
+ const label = labels?.[specifier]
175
+ if (label === undefined) {
176
+ failures.push(`label: no label reported for ${specifier}`)
177
+ continue
178
+ }
179
+ if (label === null) {
180
+ failures.push(`label: ${specifier} resolves no locale metadata`)
181
+ continue
182
+ }
183
+ if (label.error) {
184
+ failures.push(`label: ${specifier}: ${label.error}`)
185
+ continue
186
+ }
187
+ for (const lang of ['en', 'zh']) {
188
+ const title = localizedField(label.title, lang)
189
+ const description = localizedField(label.description, lang)
190
+ if (!title) {
191
+ failures.push(`label: ${specifier} has no ${lang} title`)
192
+ continue
193
+ }
194
+ if (!description) failures.push(`label: ${specifier} has no ${lang} description`)
195
+ if (title === specifier) failures.push(`label: ${specifier} falls back to the module specifier as its ${lang} title`)
196
+ if (!title.includes(row.name)) failures.push(`label: ${specifier} ${lang} title does not name ${row.name}`)
197
+ byLanguage[lang].set(specifier, { title, description })
198
+ }
199
+ }
200
+ for (const lang of ['en', 'zh']) {
201
+ const entries = [...byLanguage[lang].entries()]
202
+ if (entries.length < 2) continue
203
+ const [first] = entries
204
+ for (const [specifier, entry] of entries.slice(1)) {
205
+ if (entry.title === first[1].title) {
206
+ failures.push(`label: ${first[0]} and ${specifier} share the same ${lang} title`)
207
+ }
208
+ if (entry.description !== undefined && entry.description === first[1].description) {
209
+ failures.push(`label: ${first[0]} and ${specifier} share the same ${lang} description`)
210
+ }
211
+ }
212
+ }
213
+ return failures
214
+ }
215
+
216
+ /**
217
+ * Compare the loaded preset definitions against {@link PRESET_ROWS}. A missing
218
+ * preset, a changed display name/order, or an activation diagnostic (`broken`)
219
+ * is a failed claim: a preset row that mounts but never activates is exactly the
220
+ * regression the smoke exists to catch.
221
+ * @param {Array<{ id?: string, name?: string, order?: number, broken?: string }>} entries
222
+ * @returns {string[]} one message per violated preset claim.
223
+ */
224
+ export function checkPresetRows(entries) {
225
+ const list = Array.isArray(entries) ? entries : []
226
+ const byId = new Map(list.map((entry) => [String(entry?.id), entry]))
227
+ const failures = []
228
+ for (const row of PRESET_ROWS) {
229
+ const entry = byId.get(row.id)
230
+ if (!entry) {
231
+ failures.push(`preset: missing ${row.id}`)
232
+ continue
233
+ }
234
+ if (entry.name !== row.name) {
235
+ failures.push(`preset: ${row.id} name ${JSON.stringify(entry.name)}, expected ${JSON.stringify(row.name)}`)
236
+ }
237
+ if (entry.order !== row.order) {
238
+ failures.push(`preset: ${row.id} order ${JSON.stringify(entry.order)}, expected ${row.order}`)
239
+ }
240
+ if (entry.broken) failures.push(`preset: ${row.id} failed to mount: ${entry.broken}`)
241
+ }
242
+ return failures
243
+ }
244
+
245
+ /** Entry ids whose fiber reported an activation error (state 3). */
246
+ export function failedEntryStates(entryStates = {}) {
247
+ return Object.entries(entryStates)
248
+ .filter(([, state]) => state === FIBER_FAILED)
249
+ .map(([id]) => id)
250
+ .sort()
251
+ }
252
+
253
+ /** Entry ids still loading when the boot returned (state 1). */
254
+ export function pendingEntryStates(entryStates = {}) {
255
+ return Object.entries(entryStates)
256
+ .filter(([, state]) => state === FIBER_PENDING)
257
+ .map(([id]) => id)
258
+ .sort()
259
+ }
260
+
261
+ /**
262
+ * No row may fail or stay pending in a green smoke: the bundle is supposed to
263
+ * compose a working profile, not merely register the ids it claims.
264
+ * @param {Record<string, number | null | undefined>} entryStates
265
+ * @returns {string[]} one message per failed or pending row.
266
+ */
267
+ export function checkRowStates(entryStates = {}) {
268
+ const failures = []
269
+ for (const id of failedEntryStates(entryStates)) failures.push(`row: ${id} failed to mount (entry state 3)`)
270
+ for (const id of pendingEntryStates(entryStates)) failures.push(`row: ${id} is still pending (entry state 1)`)
271
+ return failures
272
+ }
273
+
131
274
  /** Effective loader id for a nested row (`group:child`), falling back to the raw id. */
132
275
  function stateKey(id, entryStates) {
133
276
  if (entryStates[id] !== undefined) return id
@@ -25,6 +25,9 @@ import {
25
25
  INDEX_TOOL_IDS,
26
26
  PRESET_IDS,
27
27
  checkBundleRow,
28
+ checkPresetLabels,
29
+ checkPresetRows,
30
+ checkRowStates,
28
31
  compareIds,
29
32
  expectedSkillIds,
30
33
  expectedToolIds,
@@ -163,13 +166,29 @@ function verifySurface(surface, root) {
163
166
  if (loadedSkills) failures.push(...compareIds('skill', expectedSkills, loadedSkills.tower))
164
167
 
165
168
  const loadedPresets = area(surface, 'presets', 'preset', failures)
166
- if (loadedPresets) failures.push(...compareIds('preset', PRESET_IDS, loadedPresets))
169
+ if (loadedPresets) {
170
+ failures.push(...compareIds('preset', PRESET_IDS, loadedPresets.ids ?? []))
171
+ // Both preset rows must still activate: ids, display names/orders and no
172
+ // registry activation diagnostic (#121).
173
+ failures.push(...checkPresetRows(loadedPresets.entries ?? []))
174
+ }
175
+
176
+ // The Plugins page reads the label through `readPluginMeta`; the two preset
177
+ // rows used to share one module and rendered the same title. Prove the
178
+ // installed package now resolves a distinct label per row (#121).
179
+ const loadedLabels = area(surface, 'labels', 'label', failures)
180
+ if (loadedLabels) failures.push(...checkPresetLabels(loadedLabels))
167
181
 
168
182
  const loadedCommands = area(surface, 'commands', 'command', failures)
169
183
  if (loadedCommands && !loadedCommands.includes('tower')) failures.push('command: /tower is not registered in the loaded command registry')
170
184
 
171
185
  const tree = area(surface, 'tree', 'bundle', failures)
172
- if (tree) failures.push(...checkBundleRow(tree.top, tree.states))
186
+ if (tree) {
187
+ failures.push(...checkBundleRow(tree.top, tree.states))
188
+ // The bundle was replaced row-by-row (#121); prove no row failed to activate
189
+ // or is still waiting, not only that the expected rows are active.
190
+ failures.push(...checkRowStates(tree.states))
191
+ }
173
192
 
174
193
  return failures
175
194
  }
@@ -179,10 +198,11 @@ function summarize(surface, root) {
179
198
  const expected = expectedToolIds(toolModuleNames(root)).length
180
199
  const tools = Array.isArray(surface?.tools?.value) ? surface.tools.value.filter((name) => name.startsWith('tower_')).length : '?'
181
200
  const skills = Array.isArray(surface?.skills?.value?.tower) ? surface.skills.value.tower.length : '?'
182
- const presets = Array.isArray(surface?.presets?.value) ? surface.presets.value.length : '?'
201
+ const presets = Array.isArray(surface?.presets?.value?.ids) ? surface.presets.value.ids.length : '?'
202
+ const labels = surface?.labels?.value && typeof surface.labels.value === 'object' ? Object.keys(surface.labels.value).length : '?'
183
203
  const commands = Array.isArray(surface?.commands?.value) ? surface.commands.value.filter((name) => name === 'tower').length : '?'
184
204
  const rows = Array.isArray(surface?.tree?.value?.top) ? surface.tree.value.top.filter((row) => row.id === BUNDLE_ID).length : '?'
185
- return `${tools} of ${expected} tower tools (${modules} modules + ${INDEX_TOOL_IDS.length} entry tools; ${DEFAULT_OFF_ROW_IDS.join(', ')} off), ${skills} skills, ${presets} presets, /tower ${commands === 1 ? 'present' : 'missing'}, ${rows} top-level bundle row`
205
+ return `${tools} of ${expected} tower tools (${modules} modules + ${INDEX_TOOL_IDS.length} entry tools; ${DEFAULT_OFF_ROW_IDS.join(', ')} off), ${skills} skills, ${presets} presets (${labels} distinct labels), /tower ${commands === 1 ? 'present' : 'missing'}, ${rows} top-level bundle row`
186
206
  }
187
207
 
188
208
  function reportFailure(state, pin, options, error) {
@@ -1,6 +1,7 @@
1
1
  CHANGELOG.md
2
2
  COMPATIBILITY.md
3
3
  CONTRIBUTING.md
4
+ GLOSSARY.md
4
5
  LICENSE
5
6
  PRINCIPLES.md
6
7
  README.en.md
@@ -102,6 +103,7 @@ domain/collab/batch.js
102
103
  domain/collab/messages.js
103
104
  domain/collab/roster.js
104
105
  domain/collab/spawn.js
106
+ domain/component-rules.js
105
107
  domain/components.js
106
108
  domain/config/schema.js
107
109
  domain/contracts/outputs.js
@@ -145,7 +147,9 @@ domain/state/semantics.js
145
147
  domain/summary.js
146
148
  domain/surface.js
147
149
  domain/vocabulary.js
150
+ host/capability-archive.js
148
151
  host/capability-batch.js
152
+ host/capability-board.js
149
153
  host/capability-hmr.js
150
154
  host/capability-import.js
151
155
  host/capability-leases.js
@@ -157,13 +161,19 @@ host/doctor.js
157
161
  host/hmr.js
158
162
  host/modeState.js
159
163
  host/modeStore.js
164
+ host/preset-reviewer.js
165
+ host/preset-worker.js
160
166
  host/presets.js
161
167
  host/tool-registry.js
162
168
  host/tower.js
163
169
  icon.svg
164
170
  index.js
171
+ locale/archive/en.json
172
+ locale/archive/zh.json
165
173
  locale/batch/en.json
166
174
  locale/batch/zh.json
175
+ locale/board/en.json
176
+ locale/board/zh.json
167
177
  locale/en.json
168
178
  locale/hmr/en.json
169
179
  locale/hmr/zh.json
@@ -171,6 +181,10 @@ locale/import/en.json
171
181
  locale/import/zh.json
172
182
  locale/leases/en.json
173
183
  locale/leases/zh.json
184
+ locale/preset-reviewer/en.json
185
+ locale/preset-reviewer/zh.json
186
+ locale/preset-worker/en.json
187
+ locale/preset-worker/zh.json
174
188
  locale/skills/en.json
175
189
  locale/skills/zh.json
176
190
  locale/tests/en.json
@@ -219,6 +233,7 @@ scripts/mount-smoke/surface.mjs
219
233
  scripts/pack-manifest.txt
220
234
  scripts/regression-audit.mjs
221
235
  scripts/release-check.mjs
236
+ scripts/release-notes.mjs
222
237
  scripts/surface-baseline.json
223
238
  scripts/surface-baseline.mjs
224
239
  scripts/types-coverage.mjs
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+ // Cumulative Release notes.
3
+ //
4
+ // Policy: every GitHub Release aggregates every CHANGELOG section since the
5
+ // previous GitHub Release, not just the section that names its own version.
6
+ // Tag-only versions have no Release, so their sections roll into the next
7
+ // Release. Example: when 2.1.0 was the previous Release, the 2.3.0 Release
8
+ // includes 2.1.1, 2.1.2, 2.2.0 and 2.3.0; 2.1.0-beta.1 is older than 2.1.0 and
9
+ // stays out of the window.
10
+ //
11
+ // The script is the authority for a Release body: it reads `<root>/CHANGELOG.md`
12
+ // and emits
13
+ //
14
+ // # dsh-tower <version>
15
+ //
16
+ // > <provenance line naming the previous Release>
17
+ //
18
+ // ---
19
+ //
20
+ // <CHANGELOG section> --- <CHANGELOG section> ...
21
+ //
22
+ // in ascending SemVer order (`1.0.0-beta.2 < 1.0.0-beta.10 < 1.0.0`). The
23
+ // window is `since < v <= version`. `--check <file>` compares an existing notes
24
+ // file byte-for-byte with the generated text so a published Release body can be
25
+ // re-verified.
26
+ //
27
+ // Usage:
28
+ // node scripts/release-notes.mjs --version <semver> --since <semver> \
29
+ // [--output <file>] [--check <file>] [--root <dir>]
30
+ //
31
+ // Exit codes: 0 success or matching --check; 1 --check mismatch; 2 bad
32
+ // arguments, an unreadable CHANGELOG, `since >= version`, or an empty window.
33
+ import fs from 'node:fs'
34
+ import path from 'node:path'
35
+ import { compareVersions } from '../host/compatibility.js'
36
+ import { parseArgs, runMain, UsageError } from './lib/cli.mjs'
37
+
38
+ const USAGE = `usage: node scripts/release-notes.mjs --version <semver> --since <semver>
39
+ [--output <file>] [--check <file>] [--root <dir>] [--help]
40
+
41
+ --version release version to generate notes for (required, e.g. 2.3.0)
42
+ --since previous GitHub Release version, exclusive (required, e.g. 2.1.0)
43
+ --output write the notes to this file instead of stdout
44
+ --check compare the generated notes with this file byte-for-byte
45
+ --root repository root that holds CHANGELOG.md (default: current directory)
46
+ --help print this text
47
+
48
+ A version may be written with or without its "v" tag prefix. The window
49
+ (since, version] is emitted in ascending SemVer order, so tag-only versions
50
+ between the two Releases are included.`
51
+
52
+ // SemVer 2.0.0 without build metadata: X.Y.Z with an optional pre-release.
53
+ // Numeric core and numeric pre-release identifiers have no leading zeros, so
54
+ // "2.3.0" is a version and "2.3" or "2.3.0-01" are not.
55
+ const SEMVER =
56
+ /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?$/
57
+
58
+ // A level-2 heading whose first token is a version. The loose token keeps the
59
+ // pre-release suffix intact (`2.1.0-beta.1`, not `2.1.0`); SEMVER then rejects
60
+ // a heading such as `## Unreleased`. `### Added` never matches: the third `#`
61
+ // is not whitespace.
62
+ const HEADING = /^##[ \t]+(?:\[[ \t]*)?v?([0-9A-Za-z.-]+)/
63
+
64
+ function usageError(message) {
65
+ const error = new UsageError(message)
66
+ error.showUsage = true
67
+ return error
68
+ }
69
+
70
+ function normalizeVersion(value, flag) {
71
+ if (typeof value !== 'string' || value.trim() === '') {
72
+ throw usageError(`${flag} <semver> is required`)
73
+ }
74
+ const text = value.trim().replace(/^v/, '')
75
+ if (!SEMVER.test(text)) {
76
+ throw usageError(`${flag} ${JSON.stringify(value)} is not a SemVer version (expected X.Y.Z or X.Y.Z-pre)`)
77
+ }
78
+ return text
79
+ }
80
+
81
+ /**
82
+ * Read every `## <version>` section from CHANGELOG text, in file order.
83
+ *
84
+ * The body ends at the next level-2 heading. Leading and trailing blank lines
85
+ * are dropped, so a section touched by the next heading without an intervening
86
+ * blank line (the real CHANGELOG has a few) still produces stable output.
87
+ * Duplicate version headings are a malformed changelog, not a choice.
88
+ */
89
+ export function extractSections(changelog) {
90
+ const lines = String(changelog ?? '').split(/\r?\n/)
91
+ const sections = []
92
+ for (let index = 0; index < lines.length; index += 1) {
93
+ const match = HEADING.exec(lines[index])
94
+ if (!match || !SEMVER.test(match[1])) continue
95
+ const body = []
96
+ let cursor = index + 1
97
+ while (cursor < lines.length && !/^##[ \t]/.test(lines[cursor])) {
98
+ body.push(lines[cursor])
99
+ cursor += 1
100
+ }
101
+ while (body.length > 0 && body[0].trim() === '') body.shift()
102
+ while (body.length > 0 && body[body.length - 1].trim() === '') body.pop()
103
+ const heading = lines[index].trimEnd()
104
+ sections.push({
105
+ version: match[1],
106
+ heading,
107
+ body: body.join('\n'),
108
+ text: body.length > 0 ? `${heading}\n\n${body.join('\n')}` : heading,
109
+ })
110
+ index = cursor - 1
111
+ }
112
+ const seen = new Set()
113
+ for (const section of sections) {
114
+ if (seen.has(section.version)) {
115
+ throw new Error(`CHANGELOG.md has more than one section for version ${section.version}`)
116
+ }
117
+ seen.add(section.version)
118
+ }
119
+ return sections
120
+ }
121
+
122
+ /**
123
+ * Build the cumulative Release body for `version`, containing every CHANGELOG
124
+ * section with `since < v <= version` in ascending SemVer order.
125
+ */
126
+ export function generateReleaseNotes({ changelog, version, since }) {
127
+ const target = normalizeVersion(version, '--version')
128
+ const previous = normalizeVersion(since, '--since')
129
+ const relation = compareVersions(target, previous)
130
+ if (relation !== null && relation <= 0) {
131
+ throw usageError(`--since ${previous} must be older than --version ${target}`)
132
+ }
133
+ const sections = extractSections(changelog)
134
+ .filter((section) => {
135
+ const afterSince = compareVersions(section.version, previous)
136
+ const atOrBeforeTarget = compareVersions(section.version, target)
137
+ return afterSince > 0 && atOrBeforeTarget <= 0
138
+ })
139
+ .sort((left, right) => compareVersions(left.version, right.version))
140
+
141
+ if (sections.length === 0) {
142
+ throw usageError(
143
+ `no CHANGELOG section falls in the window (${previous}, ${target}]; ` +
144
+ 'bump the changelog or check --since (it is the previous GitHub Release, not the previous tag)',
145
+ )
146
+ }
147
+
148
+ const versions = sections.map((section) => section.version)
149
+ const quote =
150
+ versions.length === 1
151
+ ? `> 自上一个 Release \`v${previous}\` 起的变更。`
152
+ : `> 本 Release 包含自上一个 Release \`v${previous}\` 以来的全部变更:${versions.join('、')}。`
153
+ const text = `# dsh-tower ${target}\n\n${quote}\n\n---\n\n${sections
154
+ .map((section) => section.text)
155
+ .join('\n\n---\n\n')}\n`
156
+ return { version: target, since: previous, versions, sections, text }
157
+ }
158
+
159
+ function firstDifference(expected, actual) {
160
+ const expectedLines = expected.split('\n')
161
+ const actualLines = actual.split('\n')
162
+ const limit = Math.max(expectedLines.length, actualLines.length)
163
+ for (let index = 0; index < limit; index += 1) {
164
+ if (expectedLines[index] !== actualLines[index]) {
165
+ return {
166
+ line: index + 1,
167
+ expected: expectedLines[index] ?? '<end of file>',
168
+ actual: actualLines[index] ?? '<end of file>',
169
+ }
170
+ }
171
+ }
172
+ return null
173
+ }
174
+
175
+ function compareWithFile(file, result) {
176
+ const expected = Buffer.from(result.text, 'utf8')
177
+ let existing
178
+ try {
179
+ existing = fs.readFileSync(file)
180
+ } catch (error) {
181
+ console.error(`release-notes: cannot read ${file}: ${error?.message ?? error}`)
182
+ return 2
183
+ }
184
+ if (existing.equals(expected)) {
185
+ console.log(
186
+ `release-notes: ${file} matches the generated notes for ${result.version} ` +
187
+ `(${result.versions.length} section(s): ${result.versions.join(', ')})`,
188
+ )
189
+ return 0
190
+ }
191
+ const found = existing.toString('utf8')
192
+ const difference = firstDifference(result.text, found)
193
+ console.error(`release-notes: ${file} does not match the generated notes for ${result.version}`)
194
+ if (difference) {
195
+ console.error(` first difference at line ${difference.line}: expected ${JSON.stringify(difference.expected)}`)
196
+ console.error(` found ${JSON.stringify(difference.actual)}`)
197
+ }
198
+ console.error(` expected ${expected.length} bytes, found ${existing.length} bytes`)
199
+ return 1
200
+ }
201
+
202
+ function cli(argv) {
203
+ const options = parseArgs(argv, {
204
+ initial: { version: null, since: null, output: null, check: null, root: null, help: false },
205
+ values: {
206
+ '--version': 'version',
207
+ '--since': 'since',
208
+ '--output': 'output',
209
+ '--check': 'check',
210
+ '--root': 'root',
211
+ },
212
+ flags: { '--help': 'help', '-h': 'help' },
213
+ })
214
+ if (options.help) {
215
+ console.log(USAGE)
216
+ return 0
217
+ }
218
+ if (options.output && options.check) {
219
+ throw usageError('--output and --check are mutually exclusive')
220
+ }
221
+
222
+ const root = path.resolve(options.root ?? process.cwd())
223
+ const changelogPath = path.join(root, 'CHANGELOG.md')
224
+ let changelog
225
+ try {
226
+ changelog = fs.readFileSync(changelogPath, 'utf8')
227
+ } catch (error) {
228
+ console.error(`release-notes: cannot read ${changelogPath}: ${error?.message ?? error}`)
229
+ return 2
230
+ }
231
+
232
+ let result
233
+ try {
234
+ result = generateReleaseNotes({ changelog, version: options.version, since: options.since })
235
+ } catch (error) {
236
+ // UsageError messages already name the offending flag; file/data errors are
237
+ // reported with the same prefix and exit 2 (never a partial Release).
238
+ console.error(`release-notes: ${error?.message ?? error}`)
239
+ if (error?.showUsage) console.error(USAGE)
240
+ return 2
241
+ }
242
+
243
+ if (options.check) return compareWithFile(path.resolve(options.check), result)
244
+ if (options.output) {
245
+ const outputPath = path.resolve(options.output)
246
+ fs.writeFileSync(outputPath, result.text, 'utf8')
247
+ console.log(
248
+ `release-notes: wrote ${outputPath} (${result.versions.length} section(s): ${result.versions.join(', ')})`,
249
+ )
250
+ return 0
251
+ }
252
+ process.stdout.write(result.text)
253
+ return 0
254
+ }
255
+
256
+ await runMain(import.meta.url, () => cli(process.argv.slice(2)), { errorPrefix: 'release-notes', usage: USAGE })
@@ -4,8 +4,16 @@
4
4
  "surfaces": {
5
5
  "configKeys": {
6
6
  "seed": 64,
7
- "value": 56,
8
- "decisions": []
7
+ "value": 53,
8
+ "decisions": [
9
+ {
10
+ "from": 64,
11
+ "to": 53,
12
+ "date": "2026-11-03",
13
+ "adr": "docs/adr/0005-component-surface.md",
14
+ "note": "Recorded contraction, not a free ratchet (#138). #121 removed the seven tools.expose* keys plus dev.hotReload, #136 replaced tests.enabled with the tower-tests row, and #133/#135 replaced docs.board/archive.enabled with the tower-board/tower-archive rows. The chain now continues here: the config-key surface is two-way like components, so any later increase or decrease needs its own dated decision."
15
+ }
16
+ ]
9
17
  },
10
18
  "sections": {
11
19
  "seed": 15,
@@ -30,7 +38,7 @@
30
38
  },
31
39
  "components": {
32
40
  "seed": 4,
33
- "value": 8,
41
+ "value": 11,
34
42
  "decisions": [
35
43
  {
36
44
  "from": 4,
@@ -45,6 +53,27 @@
45
53
  "date": "2026-10-23",
46
54
  "adr": "docs/adr/0005-component-surface.md",
47
55
  "note": "#121 restores the #65 policy from ADR 0005: the one tower-bundle group becomes a mandatory tower host anchor plus seven individually switchable optional rows (tower-skills, tower-batch, tower-import, tower-leases, tower-tests, tower-hmr shipped off, preset-tower-reviewer conditional). The host anchor and preset-tower-worker stay mandatory; tower_doctor and tower_recover stay mandatory with the host (#122), so they are not rows. configKeys and sections shrank in the same wave because the seven tools.expose* keys and dev.hotReload became rows."
56
+ },
57
+ {
58
+ "from": 8,
59
+ "to": 9,
60
+ "date": "2026-11-02",
61
+ "adr": "docs/adr/0005-component-surface.md",
62
+ "note": "#133: `docs.board` was a capability wearing a tuning-key costume. The visible tower-board row owns the MISSIONS.md board now; `docs.boardLocation` stays tuning. Off writes no board."
63
+ },
64
+ {
65
+ "from": 9,
66
+ "to": 10,
67
+ "date": "2026-11-02",
68
+ "adr": "docs/adr/0005-component-surface.md",
69
+ "note": "#135: `archive.enabled` was the same costume. The visible tower-archive row owns archival now; `afterDays`/`maxMissions` stay tuning. Off keeps terminal missions hot, so state keeps growing."
70
+ },
71
+ {
72
+ "from": 10,
73
+ "to": 11,
74
+ "date": "2026-11-02",
75
+ "adr": "docs/adr/0005-component-surface.md",
76
+ "note": "#134: the gate reads every shipped cordis.patch.yml, so the tower-ui row in ui-bundle/cordis.patch.yml joins the counted surface. The row was always shipped and unclassified; counting it is not new surface, it is the existing surface becoming visible to the policy."
48
77
  }
49
78
  ]
50
79
  }
@@ -8,8 +8,9 @@
8
8
  // tools every tool the Host entry registers, including the
9
9
  // tower_mode/tower_control pair that index.js registers
10
10
  // directly rather than through define() (#118)
11
- // components user-visible rows in cordis.patch.yml (#120), so the 2.3.0
12
- // 4 -> 1 contraction and any future move need a written decision
11
+ // components user-visible rows in every shipped cordis.patch.yml (#120,
12
+ // #134), so the 2.3.0 4 -> 1 contraction and any future move
13
+ // need a written decision
13
14
  //
14
15
  // `scripts/surface-baseline.json` freezes those counts. A PR that grows a count
15
16
  // fails here until the baseline carries a decision with either an ADR under
@@ -24,7 +25,7 @@ import fs from 'node:fs'
24
25
  import path from 'node:path'
25
26
  import { fileURLToPath } from 'node:url'
26
27
  import { configSurface } from '../domain/config/schema.js'
27
- import { parseCordisPatch } from '../domain/components.js'
28
+ import { COMPONENT_PATCH_FILES, mergeCordisPatches, parseCordisPatch } from '../domain/components.js'
28
29
  import { SURFACE_CLASSIFICATION_FILE, SURFACE_BASELINE_FILE, surfaceLine } from '../domain/surface.js'
29
30
  import { loadShippedToolSurface } from './lib/tool-surface.mjs'
30
31
  import { checkSurfaceBudget } from './lib/surface.mjs'
@@ -42,7 +43,8 @@ with a decision that names an existing docs/adr/ ADR, deletions that are no
42
43
  longer in the live surface, or a measurement-correction record with a reason and
43
44
  date. The classification table in
44
45
  ${SURFACE_CLASSIFICATION_FILE} must cover every key and tool exactly once, and
45
- the component count comes from the visible rows in cordis.patch.yml.`
46
+ the component count comes from the visible rows in every shipped
47
+ cordis.patch.yml.`
46
48
 
47
49
  /**
48
50
  * The live tool names: apply the shipped Host entry so every registration path
@@ -65,7 +67,15 @@ function liveToolNames() {
65
67
  export function liveSurface(root = PACKAGE_ROOT) {
66
68
  const config = configSurface()
67
69
  const tools = liveToolNames()
68
- const patch = parseCordisPatch(fs.readFileSync(path.join(root, 'cordis.patch.yml'), 'utf8'))
70
+ // #134: the counted component surface is every shipped patch, not only the
71
+ // root one; `ui-bundle/cordis.patch.yml` ships the tower-ui row.
72
+ const patches = {}
73
+ for (const file of COMPONENT_PATCH_FILES) {
74
+ const patchFile = path.join(root, file)
75
+ if (!fs.existsSync(patchFile)) continue
76
+ patches[file] = parseCordisPatch(fs.readFileSync(patchFile, 'utf8'))
77
+ }
78
+ const patch = mergeCordisPatches(patches)
69
79
  return {
70
80
  configKeys: config.count,
71
81
  sections: config.sections.length,
package/skills.js CHANGED
@@ -66,5 +66,8 @@ export function apply(ctx) {
66
66
  // lets tower_status/tower_doctor report "disabled by component tower-skills"
67
67
  // when the row is off instead of leaving the missing catalog unexplained.
68
68
  holdComponent(ctx, 'tower-skills')
69
- ctx.skills.registerProvider(() => provider)
69
+ // #139: the provider must be unregistered with the plugin, so the catalog stops
70
+ // serving after unload/reload instead of keeping a dead provider alive. Host
71
+ // registries may or may not return a disposer; `ctx.effect` accepts both.
72
+ ctx.effect(() => ctx.skills.registerProvider(() => provider))
70
73
  }
package/tools/complete.js CHANGED
@@ -31,7 +31,9 @@ export function registerCompleteTool(ctx, deps) {
31
31
  const context = await tower.missionContext(args.missionId)
32
32
  const checks = [...(Array.isArray(args.checks) ? args.checks : [])]
33
33
  const testsConfig = /** @type {any} */ (tower.config.tests ?? {})
34
- if (testsConfig.enabled !== false && isCompleteAdmission(testsConfig.admission)) {
34
+ // #136: the `tower-tests` component row is the only switch; admission
35
+ // decides when discovery runs, not whether it may run.
36
+ if (isCompleteAdmission(testsConfig.admission)) {
35
37
  const discovery = await runMissionDiscovery(tower, { id: args.missionId, worktree: context.mission.worktree })
36
38
  if (discovery.configured && discovery.receipt.ok !== true) {
37
39
  throw new TowerToolError('tests-failed', `test discovery failed for ${args.missionId}: ${discovery.receipt.error}`)