dsh-tower 2.4.2 → 2.6.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 +72 -0
- package/COMPATIBILITY.md +2 -0
- package/CONTRIBUTING.md +5 -3
- package/GLOSSARY.md +73 -0
- package/PRINCIPLES.md +15 -7
- package/README.en.md +16 -15
- package/README.md +18 -10
- package/app/archive.js +16 -1
- package/app/board.js +16 -1
- package/app/gate.js +14 -0
- package/app/review.js +7 -0
- package/app/spawn-prechecks.js +30 -5
- package/app/tower.js +5 -5
- package/client.js +321 -0
- package/cordis.patch.yml +26 -9
- package/docs/adr/0004-surface-budget.md +10 -4
- package/docs/adr/0005-component-surface.md +222 -11
- package/docs/architecture/overview.md +12 -6
- package/docs/examples/first-mission.md +3 -2
- package/docs/faq.md +9 -7
- package/docs/guide/install-upgrade-migration.md +53 -7
- package/docs/guide/quickstart.md +91 -10
- package/docs/guide/release.md +60 -19
- package/docs/guide/scenarios-and-philosophy.md +35 -28
- package/docs/operations/security.md +11 -10
- package/docs/reference/blockers.md +12 -7
- package/docs/reference/component-surface.md +40 -21
- package/docs/reference/config.generated.md +6 -8
- package/docs/reference/config.md +36 -8
- package/docs/reference/doctor.md +11 -5
- package/docs/reference/errors.generated.md +1 -0
- package/docs/reference/errors.md +2 -1
- package/docs/reference/surface-budget.md +47 -12
- package/docs/reference/testing.md +1 -1
- package/docs/reference/tools.generated.md +5 -5
- package/docs/reference/tools.md +5 -5
- package/docs/zh/faq.md +7 -5
- package/docs/zh/guide/install-upgrade-migration.md +26 -7
- package/docs/zh/guide/quickstart.md +17 -10
- package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
- package/docs/zh/operations/security.md +9 -8
- package/docs/zh/reference/blockers.md +11 -7
- package/docs/zh/reference/config.md +28 -8
- package/docs/zh/reference/doctor.md +7 -2
- package/docs/zh/reference/errors.md +2 -1
- package/docs/zh/reference/testing.md +2 -2
- package/docs/zh/reference/tools.md +6 -5
- package/domain/component-rules.js +216 -0
- package/domain/components.js +181 -53
- package/domain/config/schema.js +86 -28
- package/domain/contracts/outputs.js +2 -0
- package/domain/contracts/tool-inputs/missions.js +1 -1
- package/domain/errors.js +6 -0
- package/domain/gate.js +23 -5
- package/domain/integration/review.js +203 -9
- 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/config.js +40 -3
- package/host/doctor.js +68 -12
- package/host/presets.js +26 -8
- package/host/tool-registry.js +30 -5
- 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 +17 -2
- package/scripts/check-components.mjs +698 -44
- package/scripts/check-pack.mjs +3 -0
- package/scripts/component-surface.json +101 -37
- package/scripts/generate-capability-locale.mjs +3 -0
- package/scripts/mount-smoke/surface.mjs +2 -0
- package/scripts/pack-manifest.txt +10 -0
- package/scripts/release-notes.mjs +256 -0
- package/scripts/surface-baseline.json +39 -3
- package/scripts/surface-baseline.mjs +15 -5
- package/scripts/types-coverage.mjs +7 -0
- package/skills.js +4 -1
- package/tools/complete.js +3 -1
- package/tools/preflight.js +1 -1
- package/tools/review.js +1 -1
- package/tools/spawn.js +7 -7
- package/tools/status.js +8 -1
- package/tools/test.js +7 -7
- package/types/index.d.ts +10 -0
- package/ui-bundle/locale/en.json +1 -1
- package/ui-bundle/locale/zh.json +1 -1
package/scripts/check-pack.mjs
CHANGED
|
@@ -16,6 +16,9 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
|
16
16
|
const REQUIRED_FILES = [
|
|
17
17
|
'cordis.patch.yml',
|
|
18
18
|
'index.js',
|
|
19
|
+
// #M40: the core Web Client half carries the Plugins-page review policy
|
|
20
|
+
// control; it is part of the published bundle like ui-bundle/.
|
|
21
|
+
'client.js',
|
|
19
22
|
'locale/en.json',
|
|
20
23
|
'locale/zh.json',
|
|
21
24
|
// #121: every optional capability row and both preset-declaring rows explain
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
|
-
"date": "2026-
|
|
3
|
+
"date": "2026-11-01",
|
|
4
4
|
"adr": "docs/adr/0005-component-surface.md",
|
|
5
5
|
"classes": {
|
|
6
6
|
"core": "Removing it breaks the Tower contract. It is never a switch.",
|
|
7
7
|
"optional-component": "A capability whose off state leaves the rest of Tower intact. It must be a user-visible switch with a documented fallback.",
|
|
8
8
|
"optional-config": "A tuning knob, not a capability. It stays in the config form and never hides a component.",
|
|
9
|
-
"forbidden-to-disable": "The host, the health contract, the recovery escape
|
|
9
|
+
"forbidden-to-disable": "The host, the health contract, the recovery escape and the merge gates. No group, row or boolean key may turn it off."
|
|
10
10
|
},
|
|
11
11
|
"components": [
|
|
12
12
|
{
|
|
@@ -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",
|
|
@@ -67,10 +88,21 @@
|
|
|
67
88
|
"id": "preset-tower-reviewer",
|
|
68
89
|
"scope": "visible",
|
|
69
90
|
"kind": "row",
|
|
70
|
-
"fallback": "Declared by dsh-tower/preset-reviewer: the reviewer cannot be dispatched. Supported while review.
|
|
91
|
+
"fallback": "Declared by dsh-tower/preset-reviewer: the reviewer cannot be dispatched. Supported while review.mode is off or optional (merge is unaffected; off does not use the preset at all); with review.mode required, 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
|
+
"review.mode",
|
|
103
|
+
"spawn.requireLease",
|
|
104
|
+
"tests.admission"
|
|
105
|
+
],
|
|
74
106
|
"keys": [
|
|
75
107
|
{
|
|
76
108
|
"name": "section",
|
|
@@ -85,17 +117,35 @@
|
|
|
85
117
|
{
|
|
86
118
|
"name": "init.autoGitInit",
|
|
87
119
|
"class": "optional-config",
|
|
88
|
-
"note": "Bootstrap convenience; off only means tower_init will not run git init."
|
|
120
|
+
"note": "Bootstrap convenience; off only means tower_init will not run git init.",
|
|
121
|
+
"exemption": {
|
|
122
|
+
"date": "2026-11-03",
|
|
123
|
+
"ref": "docs/adr/0005-component-surface.md",
|
|
124
|
+
"issue": "#138",
|
|
125
|
+
"reason": "Tuning, not capability: tower_init stays the component and the switch only decides whether it runs git init."
|
|
126
|
+
}
|
|
89
127
|
},
|
|
90
128
|
{
|
|
91
129
|
"name": "init.snapshotDirty",
|
|
92
130
|
"class": "optional-config",
|
|
93
|
-
"note": "Dirty-work baseline convenience; no capability is removed."
|
|
131
|
+
"note": "Dirty-work baseline convenience; no capability is removed.",
|
|
132
|
+
"exemption": {
|
|
133
|
+
"date": "2026-11-03",
|
|
134
|
+
"ref": "docs/adr/0005-component-surface.md",
|
|
135
|
+
"issue": "#138",
|
|
136
|
+
"reason": "Tuning, not capability: the init component remains available with or without a dirty-work snapshot baseline."
|
|
137
|
+
}
|
|
94
138
|
},
|
|
95
139
|
{
|
|
96
140
|
"name": "init.autoExclude",
|
|
97
141
|
"class": "optional-config",
|
|
98
|
-
"note": "Keeps Tower artifacts out of .git/info/exclude; safe either way."
|
|
142
|
+
"note": "Keeps Tower artifacts out of .git/info/exclude; safe either way.",
|
|
143
|
+
"exemption": {
|
|
144
|
+
"date": "2026-11-03",
|
|
145
|
+
"ref": "docs/adr/0005-component-surface.md",
|
|
146
|
+
"issue": "#138",
|
|
147
|
+
"reason": "Tuning, not capability: both values keep the init component and only change which artifacts the exclude file names."
|
|
148
|
+
}
|
|
99
149
|
},
|
|
100
150
|
{
|
|
101
151
|
"name": "mode.promptSection",
|
|
@@ -172,10 +222,15 @@
|
|
|
172
222
|
"class": "optional-config",
|
|
173
223
|
"note": "Bounded wait for the cross-process mode lock."
|
|
174
224
|
},
|
|
225
|
+
{
|
|
226
|
+
"name": "review.mode",
|
|
227
|
+
"class": "core",
|
|
228
|
+
"note": "The review policy: off, optional (default) or required, selected by one tri-state control on the plugin-page configuration form. It is configuration, not a component row and not three mutually exclusive rows. off intentionally removes tower_review, refuses reviewer spawn and makes the merge gate ignore review; optional never blocks a merge; required makes a clean tip-bound review a merge blocker. The reviewer preset row stays a capability the mode may leave unused."
|
|
229
|
+
},
|
|
175
230
|
{
|
|
176
231
|
"name": "review.requireReview",
|
|
177
|
-
"class": "
|
|
178
|
-
"note": "
|
|
232
|
+
"class": "core",
|
|
233
|
+
"note": "Deprecated one-release boolean alias for review.mode (true -> required, false -> optional; default optional). It is policy configuration and a deprecated alias, not a switch a row may own; false never overrides an explicit mode. Kept so 2.5 configurations keep working; remove after the 2.6 alias window."
|
|
179
234
|
},
|
|
180
235
|
{
|
|
181
236
|
"name": "review.maxRoundsPerReviewer",
|
|
@@ -250,7 +305,13 @@
|
|
|
250
305
|
{
|
|
251
306
|
"name": "spawn.requireLease",
|
|
252
307
|
"class": "optional-config",
|
|
253
|
-
"note": "Advisory-lease gate before a worker starts."
|
|
308
|
+
"note": "Advisory-lease gate before a worker starts.",
|
|
309
|
+
"exemption": {
|
|
310
|
+
"date": "2026-11-03",
|
|
311
|
+
"ref": "docs/adr/0005-component-surface.md",
|
|
312
|
+
"issue": "#138",
|
|
313
|
+
"reason": "Tuning the safety level inside the optional tower-leases row; with leases off the Host refuses requireLease instead of removing the spawn capability."
|
|
314
|
+
}
|
|
254
315
|
},
|
|
255
316
|
{
|
|
256
317
|
"name": "send.maxPerMinute",
|
|
@@ -260,17 +321,24 @@
|
|
|
260
321
|
{
|
|
261
322
|
"name": "send.interruptDefault",
|
|
262
323
|
"class": "optional-config",
|
|
263
|
-
"note": "Default for the interrupt-before-deliver flag."
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
324
|
+
"note": "Default for the interrupt-before-deliver flag.",
|
|
325
|
+
"exemption": {
|
|
326
|
+
"date": "2026-11-03",
|
|
327
|
+
"ref": "docs/adr/0005-component-surface.md",
|
|
328
|
+
"issue": "#138",
|
|
329
|
+
"reason": "Tuning, not capability: the send component always exists and the switch only picks a delivery default."
|
|
330
|
+
}
|
|
269
331
|
},
|
|
270
332
|
{
|
|
271
333
|
"name": "tests.admission",
|
|
272
334
|
"class": "optional-config",
|
|
273
|
-
"note": "Chooses when discovery runs inside the optional tests capability."
|
|
335
|
+
"note": "Chooses when discovery runs inside the optional tests capability.",
|
|
336
|
+
"exemption": {
|
|
337
|
+
"date": "2026-11-03",
|
|
338
|
+
"ref": "docs/adr/0005-component-surface.md",
|
|
339
|
+
"issue": "#138",
|
|
340
|
+
"reason": "Tuning when the admission check runs inside the optional tower-tests row, which still owns whether test tooling exists."
|
|
341
|
+
}
|
|
274
342
|
},
|
|
275
343
|
{
|
|
276
344
|
"name": "tests.timeoutMs",
|
|
@@ -285,7 +353,13 @@
|
|
|
285
353
|
{
|
|
286
354
|
"name": "tests.autoChecks",
|
|
287
355
|
"class": "optional-config",
|
|
288
|
-
"note": "Attaches a tests check to completion when discovery runs."
|
|
356
|
+
"note": "Attaches a tests check to completion when discovery runs.",
|
|
357
|
+
"exemption": {
|
|
358
|
+
"date": "2026-11-03",
|
|
359
|
+
"ref": "docs/adr/0005-component-surface.md",
|
|
360
|
+
"issue": "#138",
|
|
361
|
+
"reason": "Tuning the default receipt policy inside the optional tower-tests row, which still owns whether any test tooling exists."
|
|
362
|
+
}
|
|
289
363
|
},
|
|
290
364
|
{
|
|
291
365
|
"name": "tests.allowPackageDiscovery",
|
|
@@ -312,25 +386,15 @@
|
|
|
312
386
|
"class": "forbidden-to-disable",
|
|
313
387
|
"note": "The report cap is part of the completion contract; the refusal itself is never configurable away."
|
|
314
388
|
},
|
|
315
|
-
{
|
|
316
|
-
"name": "docs.board",
|
|
317
|
-
"class": "optional-config",
|
|
318
|
-
"note": "Writes the human-readable board; no capability is removed."
|
|
319
|
-
},
|
|
320
389
|
{
|
|
321
390
|
"name": "docs.boardLocation",
|
|
322
391
|
"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."
|
|
392
|
+
"note": "Chooses where the board is written; whether it is written is the tower-board component row (#133)."
|
|
329
393
|
},
|
|
330
394
|
{
|
|
331
395
|
"name": "archive.afterDays",
|
|
332
396
|
"class": "optional-config",
|
|
333
|
-
"note": "Archive age threshold."
|
|
397
|
+
"note": "Archive age threshold inside the optional tower-archive component (#135)."
|
|
334
398
|
},
|
|
335
399
|
{
|
|
336
400
|
"name": "archive.maxMissions",
|
|
@@ -377,11 +441,11 @@
|
|
|
377
441
|
},
|
|
378
442
|
{
|
|
379
443
|
"id": "review",
|
|
380
|
-
"capability": "the review
|
|
381
|
-
"reason": "
|
|
382
|
-
"tools": [
|
|
383
|
-
"rowIds": [
|
|
384
|
-
"gatedBy": ["review.
|
|
444
|
+
"capability": "the review gate in required mode",
|
|
445
|
+
"reason": "The policy is one tri-state configuration control on the plugin-page form, not a component row: review.mode is off, optional (default) or required, and off is a documented mode that removes tower_review and makes the merge gate ignore review. What no group, row or boolean key may switch off is the gate once the mode is required: a completed mission still needs a clean review bound to the branch tip, and the exemption path stays explicit. review.requireReview is a deprecated one-release alias, not a switch. The reviewer preset row remains a capability the policy may leave unused, never a way to weaken required mode.",
|
|
446
|
+
"tools": [],
|
|
447
|
+
"rowIds": [],
|
|
448
|
+
"gatedBy": ["review.mode", "review.maxRoundsPerReviewer", "gates.allowReviewExemption"]
|
|
385
449
|
},
|
|
386
450
|
{
|
|
387
451
|
"id": "merge-gates",
|
|
@@ -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
|
|
@@ -45,6 +46,7 @@ app/spawn-support.js
|
|
|
45
46
|
app/spawn.js
|
|
46
47
|
app/teardown.js
|
|
47
48
|
app/tower.js
|
|
49
|
+
client.js
|
|
48
50
|
cordis.patch.yml
|
|
49
51
|
docs/README.md
|
|
50
52
|
docs/adr/0001-ports-adapters-as-data.md
|
|
@@ -102,6 +104,7 @@ domain/collab/batch.js
|
|
|
102
104
|
domain/collab/messages.js
|
|
103
105
|
domain/collab/roster.js
|
|
104
106
|
domain/collab/spawn.js
|
|
107
|
+
domain/component-rules.js
|
|
105
108
|
domain/components.js
|
|
106
109
|
domain/config/schema.js
|
|
107
110
|
domain/contracts/outputs.js
|
|
@@ -145,7 +148,9 @@ domain/state/semantics.js
|
|
|
145
148
|
domain/summary.js
|
|
146
149
|
domain/surface.js
|
|
147
150
|
domain/vocabulary.js
|
|
151
|
+
host/capability-archive.js
|
|
148
152
|
host/capability-batch.js
|
|
153
|
+
host/capability-board.js
|
|
149
154
|
host/capability-hmr.js
|
|
150
155
|
host/capability-import.js
|
|
151
156
|
host/capability-leases.js
|
|
@@ -164,8 +169,12 @@ host/tool-registry.js
|
|
|
164
169
|
host/tower.js
|
|
165
170
|
icon.svg
|
|
166
171
|
index.js
|
|
172
|
+
locale/archive/en.json
|
|
173
|
+
locale/archive/zh.json
|
|
167
174
|
locale/batch/en.json
|
|
168
175
|
locale/batch/zh.json
|
|
176
|
+
locale/board/en.json
|
|
177
|
+
locale/board/zh.json
|
|
169
178
|
locale/en.json
|
|
170
179
|
locale/hmr/en.json
|
|
171
180
|
locale/hmr/zh.json
|
|
@@ -225,6 +234,7 @@ scripts/mount-smoke/surface.mjs
|
|
|
225
234
|
scripts/pack-manifest.txt
|
|
226
235
|
scripts/regression-audit.mjs
|
|
227
236
|
scripts/release-check.mjs
|
|
237
|
+
scripts/release-notes.mjs
|
|
228
238
|
scripts/surface-baseline.json
|
|
229
239
|
scripts/surface-baseline.mjs
|
|
230
240
|
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,23 @@
|
|
|
4
4
|
"surfaces": {
|
|
5
5
|
"configKeys": {
|
|
6
6
|
"seed": 64,
|
|
7
|
-
"value":
|
|
8
|
-
"decisions": [
|
|
7
|
+
"value": 54,
|
|
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
|
+
{
|
|
17
|
+
"from": 53,
|
|
18
|
+
"to": 54,
|
|
19
|
+
"date": "2026-11-03",
|
|
20
|
+
"adr": "docs/adr/0005-component-surface.md",
|
|
21
|
+
"note": "#M39: one new key, review.mode (off/optional/required), replaces the deprecated review.requireReview boolean as the review policy. It is not a fifth component row: the reviewer preset row stays a capability, and the mode decides whether it is required (required) or unused (off). review.requireReview remains accepted for one release as a normalized alias, so it is a deprecated key rather than a removed one and the surface grows by exactly one."
|
|
22
|
+
}
|
|
23
|
+
]
|
|
9
24
|
},
|
|
10
25
|
"sections": {
|
|
11
26
|
"seed": 15,
|
|
@@ -30,7 +45,7 @@
|
|
|
30
45
|
},
|
|
31
46
|
"components": {
|
|
32
47
|
"seed": 4,
|
|
33
|
-
"value":
|
|
48
|
+
"value": 11,
|
|
34
49
|
"decisions": [
|
|
35
50
|
{
|
|
36
51
|
"from": 4,
|
|
@@ -45,6 +60,27 @@
|
|
|
45
60
|
"date": "2026-10-23",
|
|
46
61
|
"adr": "docs/adr/0005-component-surface.md",
|
|
47
62
|
"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."
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"from": 8,
|
|
66
|
+
"to": 9,
|
|
67
|
+
"date": "2026-11-02",
|
|
68
|
+
"adr": "docs/adr/0005-component-surface.md",
|
|
69
|
+
"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."
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"from": 9,
|
|
73
|
+
"to": 10,
|
|
74
|
+
"date": "2026-11-02",
|
|
75
|
+
"adr": "docs/adr/0005-component-surface.md",
|
|
76
|
+
"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."
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"from": 10,
|
|
80
|
+
"to": 11,
|
|
81
|
+
"date": "2026-11-02",
|
|
82
|
+
"adr": "docs/adr/0005-component-surface.md",
|
|
83
|
+
"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
84
|
}
|
|
49
85
|
]
|
|
50
86
|
}
|