dsh-tower 2.4.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +11 -5
  4. package/GLOSSARY.md +67 -0
  5. package/README.md +6 -0
  6. package/app/archive.js +16 -1
  7. package/app/board.js +16 -1
  8. package/app/gate.js +14 -0
  9. package/app/spawn-prechecks.js +18 -3
  10. package/cordis.patch.yml +27 -8
  11. package/docs/adr/0004-surface-budget.md +10 -4
  12. package/docs/adr/0005-component-surface.md +166 -0
  13. package/docs/architecture/overview.md +47 -20
  14. package/docs/guide/install-upgrade-migration.md +21 -0
  15. package/docs/guide/quickstart.md +71 -0
  16. package/docs/guide/release.md +60 -19
  17. package/docs/reference/component-surface.md +29 -17
  18. package/docs/reference/config.generated.md +4 -7
  19. package/docs/reference/config.md +7 -5
  20. package/docs/reference/doctor.md +11 -5
  21. package/docs/reference/surface-budget.md +29 -11
  22. package/docs/reference/tools.generated.md +1 -1
  23. package/docs/reference/tools.md +2 -2
  24. package/docs/zh/guide/install-upgrade-migration.md +1 -1
  25. package/docs/zh/reference/config.md +7 -5
  26. package/docs/zh/reference/doctor.md +7 -2
  27. package/docs/zh/reference/tools.md +3 -2
  28. package/domain/component-rules.js +216 -0
  29. package/domain/components.js +169 -49
  30. package/domain/config/schema.js +46 -23
  31. package/domain/gate.js +17 -2
  32. package/domain/scope.js +176 -5
  33. package/domain/surface.js +21 -7
  34. package/host/capability-archive.js +18 -0
  35. package/host/capability-board.js +18 -0
  36. package/host/capability-registry.js +4 -0
  37. package/host/doctor.js +50 -5
  38. package/host/preset-reviewer.js +27 -0
  39. package/host/preset-worker.js +32 -0
  40. package/host/presets.js +17 -0
  41. package/host/tool-registry.js +21 -4
  42. package/index.js +17 -6
  43. package/locale/archive/en.json +6 -0
  44. package/locale/archive/zh.json +6 -0
  45. package/locale/batch/en.json +1 -1
  46. package/locale/batch/zh.json +1 -1
  47. package/locale/board/en.json +6 -0
  48. package/locale/board/zh.json +6 -0
  49. package/locale/en.json +41 -21
  50. package/locale/hmr/en.json +2 -2
  51. package/locale/hmr/zh.json +1 -1
  52. package/locale/import/en.json +1 -1
  53. package/locale/import/zh.json +1 -1
  54. package/locale/leases/en.json +2 -2
  55. package/locale/leases/zh.json +2 -2
  56. package/locale/preset-reviewer/en.json +6 -0
  57. package/locale/preset-reviewer/zh.json +6 -0
  58. package/locale/preset-worker/en.json +6 -0
  59. package/locale/preset-worker/zh.json +6 -0
  60. package/locale/skills/en.json +1 -1
  61. package/locale/skills/zh.json +1 -1
  62. package/locale/tests/en.json +2 -2
  63. package/locale/tests/zh.json +2 -2
  64. package/locale/zh.json +40 -20
  65. package/package.json +15 -2
  66. package/scripts/check-components.mjs +642 -42
  67. package/scripts/check-pack.mjs +7 -3
  68. package/scripts/component-surface.json +87 -29
  69. package/scripts/generate-capability-locale.mjs +100 -21
  70. package/scripts/mount-smoke/probe.mjs +33 -3
  71. package/scripts/mount-smoke/surface.mjs +144 -1
  72. package/scripts/mount-smoke.mjs +24 -4
  73. package/scripts/pack-manifest.txt +15 -0
  74. package/scripts/release-notes.mjs +256 -0
  75. package/scripts/surface-baseline.json +32 -3
  76. package/scripts/surface-baseline.mjs +15 -5
  77. package/skills.js +4 -1
  78. package/tools/complete.js +3 -1
  79. package/tools/spawn.js +5 -5
  80. package/tools/test.js +7 -7
  81. package/ui-bundle/locale/en.json +1 -1
  82. package/ui-bundle/locale/zh.json +1 -1
@@ -10,7 +10,7 @@ import { execFileSync } from 'node:child_process'
10
10
  import fs from 'node:fs'
11
11
  import path from 'node:path'
12
12
  import { fileURLToPath } from 'node:url'
13
- import { capabilityLocalePaths } from './generate-capability-locale.mjs'
13
+ import { capabilityLocalePaths, presetLocalePaths } from './generate-capability-locale.mjs'
14
14
 
15
15
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
16
16
  const REQUIRED_FILES = [
@@ -18,9 +18,13 @@ const REQUIRED_FILES = [
18
18
  'index.js',
19
19
  'locale/en.json',
20
20
  'locale/zh.json',
21
- // #121: every optional capability row explains itself on the Plugins page
22
- // through a subpath locale resource, so those generated files are required.
21
+ // #121: every optional capability row and both preset-declaring rows explain
22
+ // themselves on the Plugins page through a subpath locale resource, so those
23
+ // generated files are required.
23
24
  ...capabilityLocalePaths(),
25
+ ...presetLocalePaths(),
26
+ 'host/preset-worker.js',
27
+ 'host/preset-reviewer.js',
24
28
  'ui-bundle/index.js',
25
29
  'ui-bundle/package.json',
26
30
  'ui-bundle/cordis.patch.yml',
@@ -19,7 +19,16 @@
19
19
  "id": "tower-host",
20
20
  "scope": "internal",
21
21
  "kind": "row",
22
- "fallback": "The Host plugin itself, nested under the mandatory tower anchor. A mandatory dependency, never its own switch."
22
+ "fallback": "The Host plugin itself, nested under the mandatory tower anchor. A mandatory dependency, never its own switch.",
23
+ "allowedOverrides": [
24
+ "mode.allowAgentControl",
25
+ "mode.workspaceLock",
26
+ "mode.workspaceLockScope",
27
+ "mode.planModePolicy",
28
+ "init.autoGitInit",
29
+ "send.interruptDefault",
30
+ "spawn.staleMs"
31
+ ]
23
32
  },
24
33
  {
25
34
  "id": "tower-skills",
@@ -43,7 +52,7 @@
43
52
  "id": "tower-leases",
44
53
  "scope": "visible",
45
54
  "kind": "row",
46
- "fallback": "tower_resource is not registered; advisory leases are coordination only, so nothing else depends on them."
55
+ "fallback": "tower_resource is not registered; advisory leases are coordination only, but spawn.requireLease still needs the tool to acquire one, so a config that sets spawn.requireLease is rejected at runtime preconditions instead of failing every spawn at lease-busy."
47
56
  },
48
57
  {
49
58
  "id": "tower-tests",
@@ -51,26 +60,48 @@
51
60
  "kind": "row",
52
61
  "fallback": "tower_test is not registered and no test receipt can be recorded. With gates.requireTests:true planning/mode entry is rejected with an explanation, and the merge gate still reports tests-missing."
53
62
  },
63
+ {
64
+ "id": "tower-board",
65
+ "scope": "visible",
66
+ "kind": "row",
67
+ "fallback": "MISSIONS.md is not written or refreshed, so an existing board goes stale. Mission state tools, gates and every other capability keep working; docs.boardLocation still chooses where a board would be written."
68
+ },
69
+ {
70
+ "id": "tower-archive",
71
+ "scope": "visible",
72
+ "kind": "row",
73
+ "fallback": "Terminal missions are not moved out of state.json, so the hot state keeps growing and status/doctor keep loading their full history. Merges, gates and every other capability keep working; nothing is deleted. afterDays/maxMissions keep their tuning meaning."
74
+ },
54
75
  {
55
76
  "id": "tower-hmr",
56
77
  "scope": "visible",
57
78
  "kind": "row",
58
- "fallback": "tower_reload and /tower reload are unavailable; a profile without HMR behaves exactly as before. Shipped disabled: enable the row for a linked dev profile."
79
+ "fallback": "Development-only tooling: tower_reload and /tower reload are unavailable, so a linked-profile reload has to be triggered outside Tower. A normal profile behaves exactly as before; the row ships disabled on purpose."
59
80
  },
60
81
  {
61
82
  "id": "preset-tower-worker",
62
83
  "scope": "internal",
63
84
  "kind": "row",
64
- "fallback": "tower_spawn fails without the worker preset. A mandatory dependency, never a switch."
85
+ "fallback": "Declared by dsh-tower/preset-worker: tower_spawn fails without the worker preset. A mandatory dependency, never a switch."
65
86
  },
66
87
  {
67
88
  "id": "preset-tower-reviewer",
68
89
  "scope": "visible",
69
90
  "kind": "row",
70
- "fallback": "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."
91
+ "fallback": "Declared by dsh-tower/preset-reviewer: the reviewer cannot be dispatched. Supported while review.requireReview is false (merge is unaffected); with the gate on planning/mode entry is locked with an explanatory error."
92
+ },
93
+ {
94
+ "id": "tower-ui",
95
+ "scope": "visible",
96
+ "kind": "row",
97
+ "fallback": "The Tower web UI plugin does not load in this profile: no Tower tool cards, composer-dock summary or status strip. Host tools, missions, state and the MISSIONS.md board are unaffected."
71
98
  }
72
99
  ],
73
100
  "gateableTools": {},
101
+ "behaviorKnobs": [
102
+ "spawn.requireLease",
103
+ "tests.admission"
104
+ ],
74
105
  "keys": [
75
106
  {
76
107
  "name": "section",
@@ -85,17 +116,35 @@
85
116
  {
86
117
  "name": "init.autoGitInit",
87
118
  "class": "optional-config",
88
- "note": "Bootstrap convenience; off only means tower_init will not run git init."
119
+ "note": "Bootstrap convenience; off only means tower_init will not run git init.",
120
+ "exemption": {
121
+ "date": "2026-11-03",
122
+ "ref": "docs/adr/0005-component-surface.md",
123
+ "issue": "#138",
124
+ "reason": "Tuning, not capability: tower_init stays the component and the switch only decides whether it runs git init."
125
+ }
89
126
  },
90
127
  {
91
128
  "name": "init.snapshotDirty",
92
129
  "class": "optional-config",
93
- "note": "Dirty-work baseline convenience; no capability is removed."
130
+ "note": "Dirty-work baseline convenience; no capability is removed.",
131
+ "exemption": {
132
+ "date": "2026-11-03",
133
+ "ref": "docs/adr/0005-component-surface.md",
134
+ "issue": "#138",
135
+ "reason": "Tuning, not capability: the init component remains available with or without a dirty-work snapshot baseline."
136
+ }
94
137
  },
95
138
  {
96
139
  "name": "init.autoExclude",
97
140
  "class": "optional-config",
98
- "note": "Keeps Tower artifacts out of .git/info/exclude; safe either way."
141
+ "note": "Keeps Tower artifacts out of .git/info/exclude; safe either way.",
142
+ "exemption": {
143
+ "date": "2026-11-03",
144
+ "ref": "docs/adr/0005-component-surface.md",
145
+ "issue": "#138",
146
+ "reason": "Tuning, not capability: both values keep the init component and only change which artifacts the exclude file names."
147
+ }
99
148
  },
100
149
  {
101
150
  "name": "mode.promptSection",
@@ -250,7 +299,13 @@
250
299
  {
251
300
  "name": "spawn.requireLease",
252
301
  "class": "optional-config",
253
- "note": "Advisory-lease gate before a worker starts."
302
+ "note": "Advisory-lease gate before a worker starts.",
303
+ "exemption": {
304
+ "date": "2026-11-03",
305
+ "ref": "docs/adr/0005-component-surface.md",
306
+ "issue": "#138",
307
+ "reason": "Tuning the safety level inside the optional tower-leases row; with leases off the Host refuses requireLease instead of removing the spawn capability."
308
+ }
254
309
  },
255
310
  {
256
311
  "name": "send.maxPerMinute",
@@ -260,17 +315,24 @@
260
315
  {
261
316
  "name": "send.interruptDefault",
262
317
  "class": "optional-config",
263
- "note": "Default for the interrupt-before-deliver flag."
264
- },
265
- {
266
- "name": "tests.enabled",
267
- "class": "optional-component",
268
- "note": "Tuning inside the optional tower-tests component: it decides whether discovery runs when the row is on. Off with gates.requireTests:true is rejected at config resolution."
318
+ "note": "Default for the interrupt-before-deliver flag.",
319
+ "exemption": {
320
+ "date": "2026-11-03",
321
+ "ref": "docs/adr/0005-component-surface.md",
322
+ "issue": "#138",
323
+ "reason": "Tuning, not capability: the send component always exists and the switch only picks a delivery default."
324
+ }
269
325
  },
270
326
  {
271
327
  "name": "tests.admission",
272
328
  "class": "optional-config",
273
- "note": "Chooses when discovery runs inside the optional tests capability."
329
+ "note": "Chooses when discovery runs inside the optional tests capability.",
330
+ "exemption": {
331
+ "date": "2026-11-03",
332
+ "ref": "docs/adr/0005-component-surface.md",
333
+ "issue": "#138",
334
+ "reason": "Tuning when the admission check runs inside the optional tower-tests row, which still owns whether test tooling exists."
335
+ }
274
336
  },
275
337
  {
276
338
  "name": "tests.timeoutMs",
@@ -285,7 +347,13 @@
285
347
  {
286
348
  "name": "tests.autoChecks",
287
349
  "class": "optional-config",
288
- "note": "Attaches a tests check to completion when discovery runs."
350
+ "note": "Attaches a tests check to completion when discovery runs.",
351
+ "exemption": {
352
+ "date": "2026-11-03",
353
+ "ref": "docs/adr/0005-component-surface.md",
354
+ "issue": "#138",
355
+ "reason": "Tuning the default receipt policy inside the optional tower-tests row, which still owns whether any test tooling exists."
356
+ }
289
357
  },
290
358
  {
291
359
  "name": "tests.allowPackageDiscovery",
@@ -312,25 +380,15 @@
312
380
  "class": "forbidden-to-disable",
313
381
  "note": "The report cap is part of the completion contract; the refusal itself is never configurable away."
314
382
  },
315
- {
316
- "name": "docs.board",
317
- "class": "optional-config",
318
- "note": "Writes the human-readable board; no capability is removed."
319
- },
320
383
  {
321
384
  "name": "docs.boardLocation",
322
385
  "class": "optional-config",
323
- "note": "Chooses the board location."
324
- },
325
- {
326
- "name": "archive.enabled",
327
- "class": "optional-config",
328
- "note": "Archival is maintenance, not a tool capability; off only leaves terminal missions hot."
386
+ "note": "Chooses where the board is written; whether it is written is the tower-board component row (#133)."
329
387
  },
330
388
  {
331
389
  "name": "archive.afterDays",
332
390
  "class": "optional-config",
333
- "note": "Archive age threshold."
391
+ "note": "Archive age threshold inside the optional tower-archive component (#135)."
334
392
  },
335
393
  {
336
394
  "name": "archive.maxMissions",
@@ -1,6 +1,6 @@
1
- // Generate the per-capability row locale files (#121).
1
+ // Generate the per-row locale files (#121).
2
2
  //
3
- // The DSH Plugins page renders an optional row as
3
+ // The DSH Plugins page renders a row as
4
4
  // `row.meta.title ?? row.moduleName` plus `row.meta.description`, and the Host
5
5
  // fills `row.meta` by resolving `<specifier>/locale/en.json` and then every
6
6
  // sibling `<lang>.json` in that directory. A subpath row without such a resource
@@ -10,9 +10,11 @@
10
10
  // Those files are generated, not hand-written: the single source of truth is the
11
11
  // `components` block of `locale/<lang>.json` that `npm run docs:check:components`
12
12
  // already audits. `meta.title` is the component `title`; `meta.description`
13
- // composes that component's `summary` (what the capability enables) with its
14
- // `disables` text (what turning it off removes), so the row explains the switch
15
- // in both directions.
13
+ // composes that component's `summary` (what the row provides) with either its
14
+ // `disables` text (what turning it off removes) or, for the mandatory worker
15
+ // preset, an explicit "mandatory dependency, not a switch" label. The same
16
+ // generator covers capability rows and the two preset-declaring rows, so their
17
+ // labels cannot drift apart.
16
18
  //
17
19
  // Usage:
18
20
  // node scripts/generate-capability-locale.mjs [--root <dir>] [--check]
@@ -28,6 +30,11 @@ const LANGUAGES = Object.keys(LANGUAGE_FILES)
28
30
  // `disables` prose, so the check can prove every description names what the
29
31
  // switch removes without depending on one English or Chinese phrasing.
30
32
  const OFF_EFFECT_LABEL = { en: 'When off:', zh: '关闭后:' }
33
+ export { OFF_EFFECT_LABEL }
34
+ // The worker preset has no off state; it is a mandatory dependency of the Tower
35
+ // host. Its description says so with an explicit marker instead of a "When off"
36
+ // label that would make the row read like a switch.
37
+ const DEPENDENCY_LABEL = { en: 'Mandatory dependency, not a switch:', zh: '强制依赖,不是开关:' }
31
38
 
32
39
  /**
33
40
  * The optional capability rows that need their own subpath locale resource, in
@@ -40,9 +47,27 @@ export const CAPABILITY_LOCALES = [
40
47
  { specifier: 'import', component: 'tower-import' },
41
48
  { specifier: 'leases', component: 'tower-leases' },
42
49
  { specifier: 'tests', component: 'tower-tests' },
50
+ { specifier: 'board', component: 'tower-board' },
51
+ { specifier: 'archive', component: 'tower-archive' },
43
52
  { specifier: 'hmr', component: 'tower-hmr' },
44
53
  ]
45
54
 
55
+ /**
56
+ * The two preset-declaring rows. They used to mount the shared
57
+ * `@deepseek-ai/dsh-agent-preset`, so DSH resolved one locale resource for both
58
+ * and rendered the same title twice. Each now owns a Tower declaration module
59
+ * (`host/preset-*.js`) and therefore its own `<specifier>/locale/*.json` label.
60
+ * `mandatory` is the worker dependency; it is asserted against the component
61
+ * surface scope by the gate/tests so the flag cannot drift from the policy.
62
+ */
63
+ export const PRESET_LOCALES = [
64
+ { specifier: 'preset-worker', component: 'preset-tower-worker', mandatory: true },
65
+ { specifier: 'preset-reviewer', component: 'preset-tower-reviewer', mandatory: false },
66
+ ]
67
+
68
+ /** Every row that ships a generated subpath locale resource. */
69
+ export const ROW_LOCALES = [...CAPABILITY_LOCALES, ...PRESET_LOCALES]
70
+
46
71
  export const CAPABILITY_LOCALE_LANGUAGES = LANGUAGES
47
72
 
48
73
  /** Repo-relative paths of every generated capability locale file. */
@@ -52,7 +77,19 @@ export function capabilityLocalePaths() {
52
77
  )
53
78
  }
54
79
 
55
- /** The `package.json` exports entry that exposes one capability's locale files. */
80
+ /** Repo-relative paths of every generated preset row locale file. */
81
+ export function presetLocalePaths() {
82
+ return PRESET_LOCALES.flatMap(({ specifier }) =>
83
+ LANGUAGES.map((lang) => `locale/${specifier}/${lang}.json`),
84
+ )
85
+ }
86
+
87
+ /** Repo-relative paths of every generated row locale file (capability + preset). */
88
+ export function rowLocalePaths() {
89
+ return [...capabilityLocalePaths(), ...presetLocalePaths()]
90
+ }
91
+
92
+ /** The `package.json` exports entry that exposes one row's locale files. */
56
93
  export function capabilityLocaleExport(specifier) {
57
94
  return { key: `./${specifier}/locale/*.json`, target: `./locale/${specifier}/*.json` }
58
95
  }
@@ -63,15 +100,22 @@ function requireText(value, label) {
63
100
  return text
64
101
  }
65
102
 
103
+ /** Normalize the three locale prose fields a generated row label needs. */
104
+ function rowText(entry, lang) {
105
+ return {
106
+ title: requireText(entry?.title, `${lang} row title`),
107
+ summary: requireText(entry?.summary, `${lang} row summary`).replace(/\s+/g, ' '),
108
+ disables: requireText(entry?.disables, `${lang} row disables`).replace(/\s+/g, ' '),
109
+ }
110
+ }
111
+
66
112
  /**
67
113
  * Compose the display metadata for one capability row in one language.
68
114
  * @param {{ title: string, summary: string, disables: string }} entry
69
115
  * @param {'en' | 'zh'} lang
70
116
  */
71
117
  export function renderCapabilityMeta(entry, lang) {
72
- const title = requireText(entry?.title, `${lang} capability title`)
73
- const summary = requireText(entry?.summary, `${lang} capability summary`).replace(/\s+/g, ' ')
74
- const disables = requireText(entry?.disables, `${lang} capability disables`).replace(/\s+/g, ' ')
118
+ const { title, summary, disables } = rowText(entry, lang)
75
119
  // Chinese prose does not space CJK punctuation, so the label joins directly.
76
120
  const space = lang === 'zh' ? '' : ' '
77
121
  return {
@@ -80,23 +124,57 @@ export function renderCapabilityMeta(entry, lang) {
80
124
  }
81
125
 
82
126
  /**
83
- * Render every generated file for one repository root.
84
- * @param {string} root
85
- * @returns {Map<string, string>} repo-relative path -> file text
127
+ * Compose the display metadata for one preset-declaring row in one language.
128
+ * The mandatory worker dependency is labelled as a dependency, never as a
129
+ * switch; the conditional reviewer keeps the ordinary off-effect label.
130
+ * @param {{ title: string, summary: string, disables: string }} entry
131
+ * @param {'en' | 'zh'} lang
132
+ * @param {boolean} mandatory
86
133
  */
87
- export function renderCapabilityLocaleFiles(root) {
134
+ export function renderPresetMeta(entry, lang, mandatory) {
135
+ const { title, summary, disables } = rowText(entry, lang)
136
+ const space = lang === 'zh' ? '' : ' '
137
+ const label = mandatory ? DEPENDENCY_LABEL[lang] : OFF_EFFECT_LABEL[lang]
138
+ return {
139
+ meta: { title, description: `${summary}${space}${label}${space}${disables}` },
140
+ }
141
+ }
142
+
143
+ function readLocales(root) {
88
144
  const locales = {}
89
145
  for (const lang of LANGUAGES) {
90
146
  const file = path.join(root, LANGUAGE_FILES[lang])
91
147
  if (!fs.existsSync(file)) throw new Error(`${LANGUAGE_FILES[lang]} is missing under ${root}`)
92
148
  locales[lang] = JSON.parse(fs.readFileSync(file, 'utf8'))
93
149
  }
150
+ return locales
151
+ }
152
+
153
+ /** Pull one row's components entry out of both locales, or throw a named error. */
154
+ function componentEntry(locales, lang, component) {
155
+ const entry = locales[lang]?.components?.[component]
156
+ if (!entry) throw new Error(`${LANGUAGE_FILES[lang]} has no components.${component} entry`)
157
+ return entry
158
+ }
159
+
160
+ /**
161
+ * Render every generated file for one repository root.
162
+ * @param {string} root
163
+ * @returns {Map<string, string>} repo-relative path -> file text
164
+ */
165
+ export function renderRowLocaleFiles(root) {
166
+ const locales = readLocales(root)
94
167
  const files = new Map()
95
168
  for (const { specifier, component } of CAPABILITY_LOCALES) {
96
169
  for (const lang of LANGUAGES) {
97
- const entry = locales[lang]?.components?.[component]
98
- if (!entry) throw new Error(`${LANGUAGE_FILES[lang]} has no components.${component} entry`)
99
- files.set(`locale/${specifier}/${lang}.json`, `${JSON.stringify(renderCapabilityMeta(entry, lang), null, 2)}\n`)
170
+ const rendered = renderCapabilityMeta(componentEntry(locales, lang, component), lang)
171
+ files.set(`locale/${specifier}/${lang}.json`, `${JSON.stringify(rendered, null, 2)}\n`)
172
+ }
173
+ }
174
+ for (const { specifier, component, mandatory } of PRESET_LOCALES) {
175
+ for (const lang of LANGUAGES) {
176
+ const rendered = renderPresetMeta(componentEntry(locales, lang, component), lang, mandatory)
177
+ files.set(`locale/${specifier}/${lang}.json`, `${JSON.stringify(rendered, null, 2)}\n`)
100
178
  }
101
179
  }
102
180
  return files
@@ -116,11 +194,12 @@ function main(argv) {
116
194
  --help print this text
117
195
 
118
196
  The files are generated from the components block of locale/en.json and
119
- locale/zh.json; do not edit locale/<capability>/<lang>.json by hand.`)
197
+ locale/zh.json; do not edit locale/<row>/<lang>.json by hand. Capability and
198
+ preset-declaring rows share this generator so their labels cannot drift.`)
120
199
  return 0
121
200
  }
122
201
  const root = path.resolve(options.root)
123
- const files = renderCapabilityLocaleFiles(root)
202
+ const files = renderRowLocaleFiles(root)
124
203
  if (options.check) {
125
204
  const stale = []
126
205
  for (const [relative, expected] of files) {
@@ -129,11 +208,11 @@ locale/zh.json; do not edit locale/<capability>/<lang>.json by hand.`)
129
208
  if (actual !== expected) stale.push(relative)
130
209
  }
131
210
  if (stale.length > 0) {
132
- console.error('capability locale files are stale; run npm run docs:generate:locale')
211
+ console.error('row locale files are stale; run npm run docs:generate:locale')
133
212
  for (const relative of stale) console.error(` ${relative}`)
134
213
  return 1
135
214
  }
136
- console.log(`capability row locales: ${files.size} files in sync (${CAPABILITY_LOCALES.length} specifiers, en/zh)`)
215
+ console.log(`row locales: ${files.size} files in sync (${ROW_LOCALES.length} specifiers, en/zh)`)
137
216
  return 0
138
217
  }
139
218
  for (const [relative, text] of files) {
@@ -141,7 +220,7 @@ locale/zh.json; do not edit locale/<capability>/<lang>.json by hand.`)
141
220
  fs.mkdirSync(path.dirname(target), { recursive: true })
142
221
  fs.writeFileSync(target, text)
143
222
  }
144
- console.log(`wrote ${files.size} capability row locale files (${CAPABILITY_LOCALES.length} specifiers, en/zh)`)
223
+ console.log(`wrote ${files.size} row locale files (${ROW_LOCALES.length} specifiers, en/zh)`)
145
224
  return 0
146
225
  }
147
226
 
@@ -11,6 +11,7 @@
11
11
  import fs from 'node:fs'
12
12
  import path from 'node:path'
13
13
  import { pathToFileURL } from 'node:url'
14
+ import { PRESET_SPECIFIERS } from './surface.mjs'
14
15
 
15
16
  function parseArgs(argv) {
16
17
  const options = {}
@@ -45,7 +46,7 @@ for (const file of [profileBoot, appBoot]) {
45
46
  }
46
47
 
47
48
  const { runProfile } = await import(pathToFileURL(profileBoot).href)
48
- const { loadLayeredEnv } = await import(pathToFileURL(appBoot).href)
49
+ const { loadLayeredEnv, readPluginMeta } = await import(pathToFileURL(appBoot).href)
49
50
  if (typeof runProfile !== 'function') throw new Error(`probe: host ${hostVersion} does not export runProfile`)
50
51
 
51
52
  /** Run one surface read, keeping a missing service a reportable value. */
@@ -64,7 +65,7 @@ const { ctx, shutdown } = await runProfile({
64
65
  args: [],
65
66
  })
66
67
 
67
- const surface = { host: { version: hostVersion }, profile: {}, tools: null, skills: null, presets: null, commands: null, tree: null }
68
+ const surface = { host: { version: hostVersion }, profile: {}, tools: null, skills: null, presets: null, labels: null, commands: null, tree: null }
68
69
  try {
69
70
  const profileContext = ctx.get('profileContext')
70
71
  surface.profile = {
@@ -88,9 +89,38 @@ try {
88
89
 
89
90
  const presets = ctx.get('agentPresets')
90
91
  surface.presets = presets
91
- ? await read(async () => (await presets.list()).map((preset) => preset.id))
92
+ ? await read(async () => {
93
+ // Both preset rows declare themselves through Tower-owned modules
94
+ // (`dsh-tower/preset-*`). Keep the display metadata and the activation
95
+ // diagnostic so the smoke can prove each row still mounts, not only
96
+ // that two ids exist (#121).
97
+ const list = await presets.list()
98
+ return {
99
+ ids: list.map((preset) => preset.id),
100
+ entries: list.map(({ id, name, description, order, broken }) => ({
101
+ id,
102
+ ...name === undefined ? {} : { name },
103
+ ...description === undefined ? {} : { description },
104
+ ...order === undefined ? {} : { order },
105
+ ...broken === undefined ? {} : { broken },
106
+ })),
107
+ }
108
+ })
92
109
  : { error: 'the agentPresets service is not mounted (profile has no preset registry)' }
93
110
 
111
+ // The Plugins page labels a row through this exact call. Resolve both preset
112
+ // row specifiers from the installed profile so the smoke proves the two rows
113
+ // render distinct titles/descriptions instead of one shared label (#121).
114
+ surface.labels =
115
+ typeof readPluginMeta === 'function' && typeof profileContext?.dir === 'string'
116
+ ? await read(() => {
117
+ const base = pathToFileURL(path.join(profileContext.dir, 'cordis.yml')).href
118
+ return Object.fromEntries(
119
+ PRESET_SPECIFIERS.map((specifier) => [specifier, readPluginMeta(specifier, base) ?? null]),
120
+ )
121
+ })
122
+ : { error: 'the host exports no readPluginMeta, so preset row labels cannot be read' }
123
+
94
124
  const commands = ctx.get('commands')
95
125
  surface.commands = commands
96
126
  ? await read(() => commands.list(undefined).map((command) => command.name))