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