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.
- package/CHANGELOG.md +40 -0
- package/COMPATIBILITY.md +1 -0
- package/CONTRIBUTING.md +5 -3
- package/GLOSSARY.md +67 -0
- package/README.md +6 -0
- package/app/archive.js +16 -1
- package/app/board.js +16 -1
- package/app/gate.js +14 -0
- package/app/spawn-prechecks.js +18 -3
- package/cordis.patch.yml +21 -6
- package/docs/adr/0004-surface-budget.md +10 -4
- package/docs/adr/0005-component-surface.md +166 -0
- package/docs/guide/install-upgrade-migration.md +21 -0
- package/docs/guide/quickstart.md +71 -0
- package/docs/guide/release.md +60 -19
- package/docs/reference/component-surface.md +27 -15
- package/docs/reference/config.generated.md +4 -7
- package/docs/reference/config.md +7 -5
- package/docs/reference/doctor.md +11 -5
- package/docs/reference/surface-budget.md +29 -11
- package/docs/reference/tools.generated.md +1 -1
- package/docs/reference/tools.md +2 -2
- package/docs/zh/guide/install-upgrade-migration.md +1 -1
- package/docs/zh/reference/config.md +7 -5
- package/docs/zh/reference/doctor.md +7 -2
- package/docs/zh/reference/tools.md +3 -2
- package/domain/component-rules.js +216 -0
- package/domain/components.js +169 -49
- package/domain/config/schema.js +46 -23
- package/domain/gate.js +17 -2
- package/domain/scope.js +176 -5
- package/domain/surface.js +21 -7
- package/host/capability-archive.js +18 -0
- package/host/capability-board.js +18 -0
- package/host/capability-registry.js +4 -0
- package/host/doctor.js +50 -5
- package/host/presets.js +17 -0
- package/host/tool-registry.js +21 -4
- package/index.js +17 -6
- package/locale/archive/en.json +6 -0
- package/locale/archive/zh.json +6 -0
- package/locale/batch/en.json +1 -1
- package/locale/batch/zh.json +1 -1
- package/locale/board/en.json +6 -0
- package/locale/board/zh.json +6 -0
- package/locale/en.json +37 -22
- package/locale/hmr/en.json +2 -2
- package/locale/hmr/zh.json +1 -1
- package/locale/import/en.json +1 -1
- package/locale/import/zh.json +1 -1
- package/locale/leases/en.json +2 -2
- package/locale/leases/zh.json +2 -2
- package/locale/preset-reviewer/en.json +1 -1
- package/locale/preset-reviewer/zh.json +1 -1
- package/locale/preset-worker/en.json +1 -1
- package/locale/preset-worker/zh.json +1 -1
- package/locale/skills/en.json +1 -1
- package/locale/skills/zh.json +1 -1
- package/locale/tests/en.json +2 -2
- package/locale/tests/zh.json +2 -2
- package/locale/zh.json +36 -21
- package/package.json +11 -2
- package/scripts/check-components.mjs +582 -42
- package/scripts/component-surface.json +85 -27
- package/scripts/generate-capability-locale.mjs +3 -0
- package/scripts/mount-smoke/surface.mjs +2 -0
- package/scripts/pack-manifest.txt +9 -0
- package/scripts/release-notes.mjs +256 -0
- package/scripts/surface-baseline.json +32 -3
- package/scripts/surface-baseline.mjs +15 -5
- package/skills.js +4 -1
- package/tools/complete.js +3 -1
- package/tools/spawn.js +5 -5
- package/tools/test.js +7 -7
- package/ui-bundle/locale/en.json +1 -1
- 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
|
|
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
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
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":
|
|
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":
|
|
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
|
|
12
|
-
// 4 -> 1 contraction and any future move
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`)
|