dsh-tower 2.4.2 → 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 (76) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/COMPATIBILITY.md +1 -0
  3. package/CONTRIBUTING.md +5 -3
  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 +21 -6
  11. package/docs/adr/0004-surface-budget.md +10 -4
  12. package/docs/adr/0005-component-surface.md +166 -0
  13. package/docs/guide/install-upgrade-migration.md +21 -0
  14. package/docs/guide/quickstart.md +71 -0
  15. package/docs/guide/release.md +60 -19
  16. package/docs/reference/component-surface.md +27 -15
  17. package/docs/reference/config.generated.md +4 -7
  18. package/docs/reference/config.md +7 -5
  19. package/docs/reference/doctor.md +11 -5
  20. package/docs/reference/surface-budget.md +29 -11
  21. package/docs/reference/tools.generated.md +1 -1
  22. package/docs/reference/tools.md +2 -2
  23. package/docs/zh/guide/install-upgrade-migration.md +1 -1
  24. package/docs/zh/reference/config.md +7 -5
  25. package/docs/zh/reference/doctor.md +7 -2
  26. package/docs/zh/reference/tools.md +3 -2
  27. package/domain/component-rules.js +216 -0
  28. package/domain/components.js +169 -49
  29. package/domain/config/schema.js +46 -23
  30. package/domain/gate.js +17 -2
  31. package/domain/scope.js +176 -5
  32. package/domain/surface.js +21 -7
  33. package/host/capability-archive.js +18 -0
  34. package/host/capability-board.js +18 -0
  35. package/host/capability-registry.js +4 -0
  36. package/host/doctor.js +50 -5
  37. package/host/presets.js +17 -0
  38. package/host/tool-registry.js +21 -4
  39. package/index.js +17 -6
  40. package/locale/archive/en.json +6 -0
  41. package/locale/archive/zh.json +6 -0
  42. package/locale/batch/en.json +1 -1
  43. package/locale/batch/zh.json +1 -1
  44. package/locale/board/en.json +6 -0
  45. package/locale/board/zh.json +6 -0
  46. package/locale/en.json +37 -22
  47. package/locale/hmr/en.json +2 -2
  48. package/locale/hmr/zh.json +1 -1
  49. package/locale/import/en.json +1 -1
  50. package/locale/import/zh.json +1 -1
  51. package/locale/leases/en.json +2 -2
  52. package/locale/leases/zh.json +2 -2
  53. package/locale/preset-reviewer/en.json +1 -1
  54. package/locale/preset-reviewer/zh.json +1 -1
  55. package/locale/preset-worker/en.json +1 -1
  56. package/locale/preset-worker/zh.json +1 -1
  57. package/locale/skills/en.json +1 -1
  58. package/locale/skills/zh.json +1 -1
  59. package/locale/tests/en.json +2 -2
  60. package/locale/tests/zh.json +2 -2
  61. package/locale/zh.json +36 -21
  62. package/package.json +11 -2
  63. package/scripts/check-components.mjs +582 -42
  64. package/scripts/component-surface.json +85 -27
  65. package/scripts/generate-capability-locale.mjs +3 -0
  66. package/scripts/mount-smoke/surface.mjs +2 -0
  67. package/scripts/pack-manifest.txt +9 -0
  68. package/scripts/release-notes.mjs +256 -0
  69. package/scripts/surface-baseline.json +32 -3
  70. package/scripts/surface-baseline.mjs +15 -5
  71. package/skills.js +4 -1
  72. package/tools/complete.js +3 -1
  73. package/tools/spawn.js +5 -5
  74. package/tools/test.js +7 -7
  75. package/ui-bundle/locale/en.json +1 -1
  76. package/ui-bundle/locale/zh.json +1 -1
@@ -19,7 +19,16 @@
19
19
  "id": "tower-host",
20
20
  "scope": "internal",
21
21
  "kind": "row",
22
- "fallback": "The Host plugin itself, nested under the mandatory tower anchor. A mandatory dependency, never its own switch."
22
+ "fallback": "The Host plugin itself, nested under the mandatory tower anchor. A mandatory dependency, never its own switch.",
23
+ "allowedOverrides": [
24
+ "mode.allowAgentControl",
25
+ "mode.workspaceLock",
26
+ "mode.workspaceLockScope",
27
+ "mode.planModePolicy",
28
+ "init.autoGitInit",
29
+ "send.interruptDefault",
30
+ "spawn.staleMs"
31
+ ]
23
32
  },
24
33
  {
25
34
  "id": "tower-skills",
@@ -43,7 +52,7 @@
43
52
  "id": "tower-leases",
44
53
  "scope": "visible",
45
54
  "kind": "row",
46
- "fallback": "tower_resource is not registered; advisory leases are coordination only, so nothing else depends on them."
55
+ "fallback": "tower_resource is not registered; advisory leases are coordination only, but spawn.requireLease still needs the tool to acquire one, so a config that sets spawn.requireLease is rejected at runtime preconditions instead of failing every spawn at lease-busy."
47
56
  },
48
57
  {
49
58
  "id": "tower-tests",
@@ -51,11 +60,23 @@
51
60
  "kind": "row",
52
61
  "fallback": "tower_test is not registered and no test receipt can be recorded. With gates.requireTests:true planning/mode entry is rejected with an explanation, and the merge gate still reports tests-missing."
53
62
  },
63
+ {
64
+ "id": "tower-board",
65
+ "scope": "visible",
66
+ "kind": "row",
67
+ "fallback": "MISSIONS.md is not written or refreshed, so an existing board goes stale. Mission state tools, gates and every other capability keep working; docs.boardLocation still chooses where a board would be written."
68
+ },
69
+ {
70
+ "id": "tower-archive",
71
+ "scope": "visible",
72
+ "kind": "row",
73
+ "fallback": "Terminal missions are not moved out of state.json, so the hot state keeps growing and status/doctor keep loading their full history. Merges, gates and every other capability keep working; nothing is deleted. afterDays/maxMissions keep their tuning meaning."
74
+ },
54
75
  {
55
76
  "id": "tower-hmr",
56
77
  "scope": "visible",
57
78
  "kind": "row",
58
- "fallback": "tower_reload and /tower reload are unavailable; a profile without HMR behaves exactly as before. Shipped disabled: enable the row for a linked dev profile."
79
+ "fallback": "Development-only tooling: tower_reload and /tower reload are unavailable, so a linked-profile reload has to be triggered outside Tower. A normal profile behaves exactly as before; the row ships disabled on purpose."
59
80
  },
60
81
  {
61
82
  "id": "preset-tower-worker",
@@ -68,9 +89,19 @@
68
89
  "scope": "visible",
69
90
  "kind": "row",
70
91
  "fallback": "Declared by dsh-tower/preset-reviewer: the reviewer cannot be dispatched. Supported while review.requireReview is false (merge is unaffected); with the gate on planning/mode entry is locked with an explanatory error."
92
+ },
93
+ {
94
+ "id": "tower-ui",
95
+ "scope": "visible",
96
+ "kind": "row",
97
+ "fallback": "The Tower web UI plugin does not load in this profile: no Tower tool cards, composer-dock summary or status strip. Host tools, missions, state and the MISSIONS.md board are unaffected."
71
98
  }
72
99
  ],
73
100
  "gateableTools": {},
101
+ "behaviorKnobs": [
102
+ "spawn.requireLease",
103
+ "tests.admission"
104
+ ],
74
105
  "keys": [
75
106
  {
76
107
  "name": "section",
@@ -85,17 +116,35 @@
85
116
  {
86
117
  "name": "init.autoGitInit",
87
118
  "class": "optional-config",
88
- "note": "Bootstrap convenience; off only means tower_init will not run git init."
119
+ "note": "Bootstrap convenience; off only means tower_init will not run git init.",
120
+ "exemption": {
121
+ "date": "2026-11-03",
122
+ "ref": "docs/adr/0005-component-surface.md",
123
+ "issue": "#138",
124
+ "reason": "Tuning, not capability: tower_init stays the component and the switch only decides whether it runs git init."
125
+ }
89
126
  },
90
127
  {
91
128
  "name": "init.snapshotDirty",
92
129
  "class": "optional-config",
93
- "note": "Dirty-work baseline convenience; no capability is removed."
130
+ "note": "Dirty-work baseline convenience; no capability is removed.",
131
+ "exemption": {
132
+ "date": "2026-11-03",
133
+ "ref": "docs/adr/0005-component-surface.md",
134
+ "issue": "#138",
135
+ "reason": "Tuning, not capability: the init component remains available with or without a dirty-work snapshot baseline."
136
+ }
94
137
  },
95
138
  {
96
139
  "name": "init.autoExclude",
97
140
  "class": "optional-config",
98
- "note": "Keeps Tower artifacts out of .git/info/exclude; safe either way."
141
+ "note": "Keeps Tower artifacts out of .git/info/exclude; safe either way.",
142
+ "exemption": {
143
+ "date": "2026-11-03",
144
+ "ref": "docs/adr/0005-component-surface.md",
145
+ "issue": "#138",
146
+ "reason": "Tuning, not capability: both values keep the init component and only change which artifacts the exclude file names."
147
+ }
99
148
  },
100
149
  {
101
150
  "name": "mode.promptSection",
@@ -250,7 +299,13 @@
250
299
  {
251
300
  "name": "spawn.requireLease",
252
301
  "class": "optional-config",
253
- "note": "Advisory-lease gate before a worker starts."
302
+ "note": "Advisory-lease gate before a worker starts.",
303
+ "exemption": {
304
+ "date": "2026-11-03",
305
+ "ref": "docs/adr/0005-component-surface.md",
306
+ "issue": "#138",
307
+ "reason": "Tuning the safety level inside the optional tower-leases row; with leases off the Host refuses requireLease instead of removing the spawn capability."
308
+ }
254
309
  },
255
310
  {
256
311
  "name": "send.maxPerMinute",
@@ -260,17 +315,24 @@
260
315
  {
261
316
  "name": "send.interruptDefault",
262
317
  "class": "optional-config",
263
- "note": "Default for the interrupt-before-deliver flag."
264
- },
265
- {
266
- "name": "tests.enabled",
267
- "class": "optional-component",
268
- "note": "Tuning inside the optional tower-tests component: it decides whether discovery runs when the row is on. Off with gates.requireTests:true is rejected at config resolution."
318
+ "note": "Default for the interrupt-before-deliver flag.",
319
+ "exemption": {
320
+ "date": "2026-11-03",
321
+ "ref": "docs/adr/0005-component-surface.md",
322
+ "issue": "#138",
323
+ "reason": "Tuning, not capability: the send component always exists and the switch only picks a delivery default."
324
+ }
269
325
  },
270
326
  {
271
327
  "name": "tests.admission",
272
328
  "class": "optional-config",
273
- "note": "Chooses when discovery runs inside the optional tests capability."
329
+ "note": "Chooses when discovery runs inside the optional tests capability.",
330
+ "exemption": {
331
+ "date": "2026-11-03",
332
+ "ref": "docs/adr/0005-component-surface.md",
333
+ "issue": "#138",
334
+ "reason": "Tuning when the admission check runs inside the optional tower-tests row, which still owns whether test tooling exists."
335
+ }
274
336
  },
275
337
  {
276
338
  "name": "tests.timeoutMs",
@@ -285,7 +347,13 @@
285
347
  {
286
348
  "name": "tests.autoChecks",
287
349
  "class": "optional-config",
288
- "note": "Attaches a tests check to completion when discovery runs."
350
+ "note": "Attaches a tests check to completion when discovery runs.",
351
+ "exemption": {
352
+ "date": "2026-11-03",
353
+ "ref": "docs/adr/0005-component-surface.md",
354
+ "issue": "#138",
355
+ "reason": "Tuning the default receipt policy inside the optional tower-tests row, which still owns whether any test tooling exists."
356
+ }
289
357
  },
290
358
  {
291
359
  "name": "tests.allowPackageDiscovery",
@@ -312,25 +380,15 @@
312
380
  "class": "forbidden-to-disable",
313
381
  "note": "The report cap is part of the completion contract; the refusal itself is never configurable away."
314
382
  },
315
- {
316
- "name": "docs.board",
317
- "class": "optional-config",
318
- "note": "Writes the human-readable board; no capability is removed."
319
- },
320
383
  {
321
384
  "name": "docs.boardLocation",
322
385
  "class": "optional-config",
323
- "note": "Chooses the board location."
324
- },
325
- {
326
- "name": "archive.enabled",
327
- "class": "optional-config",
328
- "note": "Archival is maintenance, not a tool capability; off only leaves terminal missions hot."
386
+ "note": "Chooses where the board is written; whether it is written is the tower-board component row (#133)."
329
387
  },
330
388
  {
331
389
  "name": "archive.afterDays",
332
390
  "class": "optional-config",
333
- "note": "Archive age threshold."
391
+ "note": "Archive age threshold inside the optional tower-archive component (#135)."
334
392
  },
335
393
  {
336
394
  "name": "archive.maxMissions",
@@ -30,6 +30,7 @@ const LANGUAGES = Object.keys(LANGUAGE_FILES)
30
30
  // `disables` prose, so the check can prove every description names what the
31
31
  // switch removes without depending on one English or Chinese phrasing.
32
32
  const OFF_EFFECT_LABEL = { en: 'When off:', zh: '关闭后:' }
33
+ export { OFF_EFFECT_LABEL }
33
34
  // The worker preset has no off state; it is a mandatory dependency of the Tower
34
35
  // host. Its description says so with an explicit marker instead of a "When off"
35
36
  // label that would make the row read like a switch.
@@ -46,6 +47,8 @@ export const CAPABILITY_LOCALES = [
46
47
  { specifier: 'import', component: 'tower-import' },
47
48
  { specifier: 'leases', component: 'tower-leases' },
48
49
  { specifier: 'tests', component: 'tower-tests' },
50
+ { specifier: 'board', component: 'tower-board' },
51
+ { specifier: 'archive', component: 'tower-archive' },
49
52
  { specifier: 'hmr', component: 'tower-hmr' },
50
53
  ]
51
54
 
@@ -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',
@@ -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
@@ -164,8 +168,12 @@ host/tool-registry.js
164
168
  host/tower.js
165
169
  icon.svg
166
170
  index.js
171
+ locale/archive/en.json
172
+ locale/archive/zh.json
167
173
  locale/batch/en.json
168
174
  locale/batch/zh.json
175
+ locale/board/en.json
176
+ locale/board/zh.json
169
177
  locale/en.json
170
178
  locale/hmr/en.json
171
179
  locale/hmr/zh.json
@@ -225,6 +233,7 @@ scripts/mount-smoke/surface.mjs
225
233
  scripts/pack-manifest.txt
226
234
  scripts/regression-audit.mjs
227
235
  scripts/release-check.mjs
236
+ scripts/release-notes.mjs
228
237
  scripts/surface-baseline.json
229
238
  scripts/surface-baseline.mjs
230
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}`)