dsh-tower 2.4.1 → 2.4.2

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 CHANGED
@@ -4,6 +4,17 @@
4
4
 
5
5
  ## 未发布(Unreleased)
6
6
 
7
+ ## 2.4.2(2026-10-24)
8
+
9
+ ### 修复
10
+
11
+ - **两个 Agent 预设行在 Plugins 页面无法区分**:此前 `preset-tower-worker` 与 `preset-tower-reviewer`
12
+ 共用 `@deepseek-ai/dsh-agent-preset` 模块,页面按模块包取标签,两行都显示「Agent 预设定义」。
13
+ 现在两行分别由 Tower 自有的 import-free preset 声明模块承载(`dsh-tower/preset-worker` /
14
+ `dsh-tower/preset-reviewer`),通过注入的 `agentPresets.register(config)` 注册,
15
+ preset 的 id/name/description/order/plugins 保持不变;两个 specifier 各自提供生成的
16
+ `<specifier>/locale/{en,zh}.json`,页面显示「Tower Worker 预设」「Tower Reviewer 预设」
17
+ 及各自用途与关闭影响。真实 `smoke:mount` 在 pinned host 上确认 2 个 preset 均挂载且标签不同。
7
18
  ## 2.4.1(2026-10-24)
8
19
 
9
20
  ### 修复
package/COMPATIBILITY.md CHANGED
@@ -5,6 +5,7 @@ is shipped in the npm package; doctor reads it locally and never contacts a regi
5
5
 
6
6
  | dsh version | tag / commit | verified on | what was run |
7
7
  |---|---|---|---|
8
+ | 0.2.1-rc.2 | v2.4.2 | 2026-10-24 | mount smoke (28/28 default tools, 3 skills, 2 presets with distinct labels, 1 bundle row), `npm test` (631), docs + seven generated checks, types coverage 148/148, components + philosophy + surface gates |
8
9
  | 0.2.1-rc.2 | v2.4.1 | 2026-10-24 | mount smoke (28/28 default tools, 3 skills, 2 presets, 1 bundle row), `npm test` (620), `types:coverage` 146/146, docs + seven generated checks incl capability-row locales, components + philosophy + surface gates |
9
10
  | 0.2.1-rc.2 | v2.4.0 | 2026-10-24 | mount smoke (28/28 default tools, 3 skills, 2 presets, 1 bundle row), `npm test` (612), `types:coverage` 146/146, strict ratchet 10, docs + six generated checks, components + philosophy + surface gates |
10
11
  | 0.2.1-rc.2 | v2.3.0 | 2026-10-23 | mount smoke (29 tower tools, `/tower`, 3 skills, 2 presets, single bundle row), `npm test` (573), `npm run docs:check`, four generated docs checks, philosophy + surface gates, live `--check` |
package/CONTRIBUTING.md CHANGED
@@ -26,7 +26,8 @@ The component axis — who may be switched off — is principle 11 and
26
26
  [scripts/component-surface.json](scripts/component-surface.json) into
27
27
  [docs/reference/component-surface.md](docs/reference/component-surface.md) and
28
28
  enforced by `npm run docs:check:components`: every visible component needs
29
- `title`/`summary`/`disables` in both locales, the host/health/recovery/review/
29
+ `title`/`summary`/`disables` in both locales (and every generated row label —
30
+ capability or preset — needs the same three strings), the host/health/recovery/review/
30
31
  merge-gate capabilities may not be gated by any row or boolean key (the
31
32
  `gateableTools` allowlist is empty since #122), and the component count — 8
32
33
  visible rows in 2.4.0 — is the fourth baselined surface.
@@ -116,13 +117,14 @@ the push gates; CI schedules it nightly.
116
117
  - `index.js` — Host entry: `/tower` command, mode guard, dynamic prompt section, `tower_control`.
117
118
  - `host/` — the composition root and session/process glue (`tool-registry.js`, `config.js`, `hmr.js`, `modeStore.js`, `doctor.js`); `adapters/` — the only I/O implementations (store, git, archive, leases, locks); `app/` — use cases; `domain/` — the pure core.
118
119
  - `worker.js` — the `tower-worker` preset policy (tool surface + protocol text).
120
+ - `host/preset-worker.js` / `host/preset-reviewer.js` — the two Tower-owned preset declaration rows (`dsh-tower/preset-*`). They keep the row config in `cordis.patch.yml`, forward it to `ctx.agentPresets.register()`, and give each preset row its own Plugins-page label; do not mount the shared `@deepseek-ai/dsh-agent-preset` for these rows.
119
121
  - `tests/` — add a regression test for every behavior change. The offline suite is `tests/*.test.mjs`; live-runtime scripts are documented in `tests/README.md`.
120
122
  - `scripts/architecture.mjs` — the fitness rules; `scripts/architecture-waivers.json` — the only exemptions, each with a `reason` and an `owner`/`ref`. An entry without a reason fails the blocking `waiver-contract` rule, The three layering rules that keep the core pure — `domain-pure`, `host-scope`, and `no-lib-from-core` — cannot be waived at all. The `let` scan behind `host-scope` is statement-aware: every binding of every top-level declaration is recorded, and only `host/**` may hold ambient state.
121
123
  - `types/` — the published declaration surface (`package.json` `types` is `types/index.d.ts`). The vocabulary block in that file is generated from the runtime constants by `npm run types:generate` and gated by `npm run docs:check:generated:types`; never edit it by hand. `tsconfig.strict.json` turns `noImplicitAny` on for an explicit file list that may grow but never silently shrink: add a file, then record it with `npm run types:strict:write` (a removal also needs `--allow-shrink`). `npm run types:coverage` fails when a shipped runtime `.js` file is outside the `tsc` program.
122
124
 
123
125
  ## Pull request checklist
124
126
 
125
- 1. `npm test` passes locally (620 tests today, including protocol end-to-end against a real temporary Git repository and mock spawn coverage) and `npm run docs:check` passes.
127
+ 1. `npm test` passes locally (631 tests today, including protocol end-to-end against a real temporary Git repository and mock spawn coverage) and `npm run docs:check` passes.
126
128
  2. New behavior has a test. Bug fixes should add a test that fails before the fix.
127
129
  3. No secrets, tokens, personal paths, session logs, or generated artifacts are added. `.gitignore` covers local acceptance output; do not force-add it.
128
130
  4. Keep the public API and state-file formats compatible; if a change is breaking, call it out in `CHANGELOG.md` and bump the version.
@@ -139,7 +141,9 @@ the push gates; CI schedules it nightly.
139
141
  `docs/reference/component-surface.md` (`npm run docs:generate:components`), and must pass
140
142
  `npm run docs:check:components`. A new visible capability row also ships its own
141
143
  `<specifier>/locale/*.json` row label via `npm run docs:generate:locale` and passes
142
- `npm run docs:check:generated:locale` (see
144
+ `npm run docs:check:generated:locale`; a preset-declaring row does the same through
145
+ `PRESET_LOCALES`, and every generated row label (capability or preset) must keep
146
+ `title`/`summary`/`disables` in both locales (see
143
147
  [architecture/overview.md](docs/architecture/overview.md#row-display-metadata-121)).
144
148
 
145
149
  ## Mount smoke
package/cordis.patch.yml CHANGED
@@ -33,8 +33,12 @@
33
33
  interruptDefault: true
34
34
  spawn:
35
35
  staleMs: 15000
36
+ # Each preset row mounts its own Tower-owned declaration module, not the
37
+ # shared @deepseek-ai/dsh-agent-preset: DSH labels a row from
38
+ # <specifier>/locale/<lang>.json, so two rows sharing one module would
39
+ # render the same title. The row config is unchanged.
36
40
  - id: preset-tower-worker
37
- name: '@deepseek-ai/dsh-agent-preset'
41
+ name: 'dsh-tower/preset-worker'
38
42
  config:
39
43
  id: tower-worker
40
44
  name: Tower Worker
@@ -70,7 +74,7 @@
70
74
  # review.requireReview is false; with the gate on planning/mode entry refuse
71
75
  # the combination with an explanatory error.
72
76
  - id: preset-tower-reviewer
73
- name: '@deepseek-ai/dsh-agent-preset'
77
+ name: 'dsh-tower/preset-reviewer'
74
78
  config:
75
79
  id: tower-reviewer
76
80
  name: Tower Reviewer
@@ -23,7 +23,7 @@ to a tool that is not on the recorded allowlist (empty since #122) and any
23
23
 
24
24
  ### Row display metadata (#121)
25
25
 
26
- The Plugins page renders an optional row as `row.meta.title ?? row.moduleName`
26
+ The Plugins page renders a row as `row.meta.title ?? row.moduleName`
27
27
  plus `row.meta.description`. The Host fills `row.meta` by resolving
28
28
  `<specifier>/locale/en.json` and then every sibling `<lang>.json` in that
29
29
  directory, reading `meta.title` / `meta.description`; title falls back to the full
@@ -31,7 +31,7 @@ module specifier and description to empty when no resource resolves. That is why
31
31
  the mandatory `tower` card explains itself (the package root ships
32
32
  `locale/*.json`) while a bare subpath row would show only `dsh-tower/batch`.
33
33
 
34
- Each optional capability therefore ships its own resolvable subpath resource:
34
+ Each optional capability row therefore ships its own resolvable subpath resource:
35
35
  `dsh-tower/skills/locale/{en,zh}.json` and the same for `batch`, `import`,
36
36
  `leases`, `tests` and `hmr`. `package.json` `exports` maps every
37
37
  `./<capability>/locale/*.json` to `./locale/<capability>/*.json`, and
@@ -39,31 +39,56 @@ Each optional capability therefore ships its own resolvable subpath resource:
39
39
  package. The root `./locale/*.json` export stays so the Tower group keeps its own
40
40
  `meta`.
41
41
 
42
- The capability files are generated, never hand-written. `npm run
43
- docs:generate:locale` renders them from the `components` block of
44
- `locale/en.json` and `locale/zh.json` (the same source `docs:check:components`
45
- audits): `meta.title` is the component `title`, and `meta.description` joins the
46
- component `summary` (what the capability enables) with its `disables` text (what
47
- turning it off removes) behind a per-language "When off / 关闭后" label. The
48
- row is therefore decidable from the page alone. `npm run
49
- docs:check:generated:locale` fails on drift, runs in `npm run check`, and is its
50
- own step in the `test` CI job (`.github/workflows/test.yml`).
51
-
52
- To label a new capability row:
42
+ The two preset-declaring rows need the same treatment but cannot share a module.
43
+ DSH labels a row from its own `moduleName`, so while both rows mounted
44
+ `@deepseek-ai/dsh-agent-preset` they resolved one locale resource and rendered the
45
+ same title: the mandatory worker dependency and the conditional reviewer switch
46
+ were indistinguishable. That package is also supplied by the DSH profile, not by
47
+ this bundle, so Tower cannot add per-row locale files beside its label. Tower
48
+ therefore owns one tiny declaration module per preset row:
49
+ `host/preset-worker.js` (row specifier `dsh-tower/preset-worker`) and
50
+ `host/preset-reviewer.js` (`dsh-tower/preset-reviewer`). Each is a plain object
51
+ plugin with `inject: ['agentPresets']` whose async `apply` returns
52
+ `ctx.agentPresets.register(config)`; Cordis collects the resolved function as the
53
+ plugin's disposer, so disabling the row or an HMR unload unregisters the preset.
54
+ The registry still owns preset semantics — it mounts the definition's `plugins`
55
+ list itself — and the modules import nothing, so they are renderer-free and do
56
+ not depend on `@deepseek-ai/dsh-agent-preset`. In `cordis.patch.yml` only the two
57
+ `name:` lines changed; the id/name/description/order/plugins config blocks are
58
+ unchanged.
59
+
60
+ The row label files are generated, never hand-written. `npm run
61
+ docs:generate:locale` renders every row label — capability and preset — from the
62
+ `components` block of `locale/en.json` and `locale/zh.json` (the same source
63
+ `docs:check:components` audits): `meta.title` is the component `title`, and
64
+ `meta.description` joins the component `summary` (what the row provides) with its
65
+ `disables` text (what turning it off removes) behind a per-language "When off /
66
+ 关闭后" label. The mandatory worker preset has no off state, so its description
67
+ uses a "Mandatory dependency, not a switch / 强制依赖,不是开关" label instead; the
68
+ conditional reviewer keeps the off-effect wording. The row is therefore decidable
69
+ from the page alone. `npm run docs:check:generated:locale` fails on drift, runs in
70
+ `npm run check`, and is its own step in the `test` CI job
71
+ (`.github/workflows/test.yml`). `npm run docs:check:components` also covers the
72
+ generated rows: a label whose `components` entry is missing, or whose `mandatory`
73
+ flag disagrees with the component scope, is refused there. The real
74
+ `npm run smoke:mount` calls the same host `readPluginMeta` the Plugins page uses
75
+ and fails unless the two preset rows resolve distinct title/description maps, so
76
+ the shared-label defect cannot return unnoticed.
77
+
78
+ To label a new row:
53
79
 
54
80
  1. Classify it in `scripts/component-surface.json`, ship it in
55
81
  `cordis.patch.yml`, and add `title` / `summary` / `disables` to the
56
82
  `components` block of both `locale/en.json` and `locale/zh.json`.
57
- 2. Add `{ specifier, component }` to `CAPABILITY_LOCALES` in
58
- `scripts/generate-capability-locale.mjs`, the matching
59
- `./<specifier>/locale/*.json` export in `package.json`, and the row to the
60
- coverage map in `tests/capability-locales.test.mjs`.
83
+ 2. Add `{ specifier, component }` to `CAPABILITY_LOCALES` — or to
84
+ `PRESET_LOCALES` with `mandatory` matching the component scope for a
85
+ preset-declaring row — in `scripts/generate-capability-locale.mjs`, the
86
+ matching `./<specifier>/locale/*.json` export in `package.json`, and the row
87
+ to the coverage map in `tests/capability-locales.test.mjs`.
61
88
  3. Run `npm run docs:generate:locale`, then regenerate
62
89
  `scripts/pack-manifest.txt` with `npm run pack:check -- --write`.
63
90
 
64
- The reviewer preset row is the one visible component that is not a package
65
- specifier: its label comes from the `cordis.patch.yml` preset `config.name` /
66
- `description`. The generated capability files never touch the root locale `meta`.
91
+ The generated row files never touch the root locale `meta`.
67
92
 
68
93
  ```
69
94
  Host plugin (index.js)
@@ -90,6 +115,8 @@ Host plugin (index.js)
90
115
  host/modeStore.js per-agent Tower mode persistence
91
116
  host/modeState.js owned in-memory mode state
92
117
  host/presets.js cross-field preset validation before work starts
118
+ host/preset-worker.js mandatory worker preset declaration (dsh-tower/preset-worker)
119
+ host/preset-reviewer.js conditional reviewer preset declaration (dsh-tower/preset-reviewer)
93
120
  host/compatibility.js COMPATIBILITY.md provenance
94
121
  host/tower.js default-deps Tower binding for tests/embedders
95
122
  ports (explicit runtime contracts, no implementation):
@@ -32,8 +32,8 @@ row is a section of its parent and is not independently switchable.
32
32
  | `tower-leases` | visible | row | tower_resource is not registered; advisory leases are coordination only, so nothing else depends on them. |
33
33
  | `tower-tests` | visible | row | 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. |
34
34
  | `tower-hmr` | visible | row | 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. |
35
- | `preset-tower-worker` | internal | row | tower_spawn fails without the worker preset. A mandatory dependency, never a switch. |
36
- | `preset-tower-reviewer` | visible | row | 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. |
35
+ | `preset-tower-worker` | internal | row | Declared by dsh-tower/preset-worker: tower_spawn fails without the worker preset. A mandatory dependency, never a switch. |
36
+ | `preset-tower-reviewer` | visible | row | 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. |
37
37
 
38
38
  ## Forbidden to disable
39
39
 
@@ -0,0 +1,27 @@
1
+ // Tower reviewer preset declaration (#121): the read-only preset's own label.
2
+ //
3
+ // This is the reviewer half of the pair documented in `host/preset-worker.js`.
4
+ // The row used to mount `@deepseek-ai/dsh-agent-preset`, so DSH resolved that
5
+ // shared module's locale and both preset rows rendered the same title; the
6
+ // conditional reviewer switch was therefore indistinguishable from the
7
+ // mandatory worker dependency. A Tower-owned module gives each row its own
8
+ // `<specifier>/locale/<lang>.json` resource without importing the agent-preset
9
+ // package (which is not resolvable from this package anyway).
10
+ //
11
+ // The module forwards the unchanged row config to the injected `agentPresets`
12
+ // service and returns its disposer, so `register`/unregister lifecycle, the
13
+ // definition's `plugins` mount and the activation diagnostic all stay registry
14
+ // owned. It imports nothing and renders nothing.
15
+ export const name = 'dsh-tower/preset-reviewer'
16
+
17
+ export const inject = ['agentPresets']
18
+
19
+ /**
20
+ * Register the reviewer preset with the profile's preset registry.
21
+ * @param {{ agentPresets: { register: (config: any) => Promise<() => Promise<void> | void> } }} ctx
22
+ * @param {any} config the `cordis.patch.yml` row config (id/name/description/order/plugins)
23
+ * @returns {Promise<() => Promise<void> | void>} the registry's unregister disposer
24
+ */
25
+ export async function apply(ctx, config) {
26
+ return ctx.agentPresets.register(config)
27
+ }
@@ -0,0 +1,32 @@
1
+ // Tower worker preset declaration (#121): one row, one Plugins-page label.
2
+ //
3
+ // DSH labels a row from `readPluginMeta(row.moduleName)`, which resolves
4
+ // `<specifier>/locale/en.json` and every sibling `<lang>.json` there. Both
5
+ // preset rows used to mount `@deepseek-ai/dsh-agent-preset`, so both shared that
6
+ // module's single label and the page could not tell the worker from the
7
+ // reviewer. That package is also not resolvable from this package, so Tower
8
+ // cannot add per-row locale files to it.
9
+ //
10
+ // This module is the tiny Tower-owned twin: it keeps the row config in
11
+ // `cordis.patch.yml` and forwards it to the injected `agentPresets` service. The
12
+ // service owns preset semantics — it mounts the definition's `plugins` list
13
+ // itself and returns the disposer that unmounts it — so this module stays
14
+ // renderer-free and imports nothing. Its specifier carries the worker label
15
+ // (`dsh-tower/preset-worker/locale/<lang>.json`).
16
+ //
17
+ // Shape: a plain object plugin (named exports) whose async `apply` returns the
18
+ // registry's disposer. Cordis collects the resolved function as the plugin's
19
+ // disposer, so disabling the row (or an HMR unload) unregisters the preset.
20
+ export const name = 'dsh-tower/preset-worker'
21
+
22
+ export const inject = ['agentPresets']
23
+
24
+ /**
25
+ * Register the worker preset with the profile's preset registry.
26
+ * @param {{ agentPresets: { register: (config: any) => Promise<() => Promise<void> | void> } }} ctx
27
+ * @param {any} config the `cordis.patch.yml` row config (id/name/description/order/plugins)
28
+ * @returns {Promise<() => Promise<void> | void>} the registry's unregister disposer
29
+ */
30
+ export async function apply(ctx, config) {
31
+ return ctx.agentPresets.register(config)
32
+ }
package/locale/en.json CHANGED
@@ -39,9 +39,14 @@
39
39
  "summary": "Register tower_reload and /tower reload for linked-profile development.",
40
40
  "disables": "tower_reload and /tower reload are unavailable; a non-HMR profile behaves exactly as before. Shipped off."
41
41
  },
42
+ "preset-tower-worker": {
43
+ "title": "Tower Worker preset",
44
+ "summary": "Declares the Tower Worker preset: the mission protocol plus the filesystem, shell and Tower tools a worker runs inside its assigned worktree.",
45
+ "disables": "tower_spawn cannot start a worker without it."
46
+ },
42
47
  "preset-tower-reviewer": {
43
- "title": "Tower reviewer",
44
- "summary": "Read-only independent review: inspect the mission diff, verify scope and acceptance, return a verdict.",
48
+ "title": "Tower Reviewer preset",
49
+ "summary": "Declares the Tower Reviewer preset: read-only independent review that inspects the mission diff, verifies scope and acceptance, and returns a verdict.",
45
50
  "disables": "The reviewer cannot be dispatched. Supported while review.requireReview is false; with the gate on planning/mode entry is locked with an explanatory error."
46
51
  }
47
52
  }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Tower Reviewer preset",
4
+ "description": "Declares the Tower Reviewer preset: read-only independent review that inspects the mission diff, verifies scope and acceptance, and returns a verdict. When off: The reviewer cannot be dispatched. Supported while review.requireReview is false; with the gate on planning/mode entry is locked with an explanatory error."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Tower Reviewer 预设",
4
+ "description": "声明 Tower Reviewer 预设:只读独立审查,检视任务差异、核对范围与验收,并返回结论。关闭后:无法派发审查代理。review.requireReview 为 false 时允许关闭;强制审查开启时规划/进入模式会被锁定并给出解释。"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Tower Worker preset",
4
+ "description": "Declares the Tower Worker preset: the mission protocol plus the filesystem, shell and Tower tools a worker runs inside its assigned worktree. Mandatory dependency, not a switch: tower_spawn cannot start a worker without it."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Tower Worker 预设",
4
+ "description": "声明 Tower Worker 预设:任务协议,以及执行代理在其工作树内使用的文件系统、终端与 Tower 工具。强制依赖,不是开关:缺少它 tower_spawn 无法启动执行代理。"
5
+ }
6
+ }
package/locale/zh.json CHANGED
@@ -39,9 +39,14 @@
39
39
  "summary": "为联动开发 profile 注册 tower_reload 与 /tower reload。",
40
40
  "disables": "tower_reload 与 /tower reload 不可用;非 HMR profile 的行为与之前完全相同。随包默认关闭。"
41
41
  },
42
+ "preset-tower-worker": {
43
+ "title": "Tower Worker 预设",
44
+ "summary": "声明 Tower Worker 预设:任务协议,以及执行代理在其工作树内使用的文件系统、终端与 Tower 工具。",
45
+ "disables": "缺少它 tower_spawn 无法启动执行代理。"
46
+ },
42
47
  "preset-tower-reviewer": {
43
- "title": "Tower 审查代理",
44
- "summary": "只读独立审查:检查 mission diff、核对范围与验收,给出结论。",
48
+ "title": "Tower Reviewer 预设",
49
+ "summary": "声明 Tower Reviewer 预设:只读独立审查,检视任务差异、核对范围与验收,并返回结论。",
45
50
  "disables": "无法派发审查代理。review.requireReview 为 false 时允许关闭;强制审查开启时规划/进入模式会被锁定并给出解释。"
46
51
  }
47
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tower",
3
- "version": "2.4.1",
3
+ "version": "2.4.2",
4
4
  "type": "module",
5
5
  "description": "DSH-native Tower: mission planning, worktree isolation, review-gated merges, and a human-owned Tower mode.",
6
6
  "license": "MIT",
@@ -36,12 +36,16 @@
36
36
  "./leases": "./host/capability-leases.js",
37
37
  "./tests": "./host/capability-tests.js",
38
38
  "./hmr": "./host/capability-hmr.js",
39
+ "./preset-worker": "./host/preset-worker.js",
40
+ "./preset-reviewer": "./host/preset-reviewer.js",
39
41
  "./skills/locale/*.json": "./locale/skills/*.json",
40
42
  "./batch/locale/*.json": "./locale/batch/*.json",
41
43
  "./import/locale/*.json": "./locale/import/*.json",
42
44
  "./leases/locale/*.json": "./locale/leases/*.json",
43
45
  "./tests/locale/*.json": "./locale/tests/*.json",
44
46
  "./hmr/locale/*.json": "./locale/hmr/*.json",
47
+ "./preset-worker/locale/*.json": "./locale/preset-worker/*.json",
48
+ "./preset-reviewer/locale/*.json": "./locale/preset-reviewer/*.json",
45
49
  "./locale/*.json": "./locale/*.json",
46
50
  "./icon": "./icon.svg",
47
51
  "./package.json": "./package.json"
@@ -5,7 +5,9 @@
5
5
  // gate is what makes that a rule instead of a slogan. It fails on:
6
6
  //
7
7
  // 1. a visible component without `title`, `summary` and `disables` in both
8
- // locale/en.json and locale/zh.json;
8
+ // locale/en.json and locale/zh.json — and the same three strings for every
9
+ // component that ships a generated row label, including the internal
10
+ // worker preset (#121);
9
11
  // 2. a forbidden-to-disable capability (host, health, recovery, review
10
12
  // contract, merge gates) that any group, row or boolean key can disable;
11
13
  // 3. a component count that moved without a decision in
@@ -28,6 +30,7 @@ import { Config, configSurface } from '../domain/config/schema.js'
28
30
  import {
29
31
  COMPONENT_REFERENCE_FILE,
30
32
  COMPONENT_SURFACE_FILE,
33
+ LOCALE_FIELDS,
31
34
  LOCALE_FILES,
32
35
  checkForbidden,
33
36
  checkLocaleCoverage,
@@ -40,6 +43,7 @@ import {
40
43
  import { validateSurfaceBaselineKey } from '../domain/surface.js'
41
44
  import { readBaseline } from './lib/surface.mjs'
42
45
  import { parseArgs, runMain } from './lib/cli.mjs'
46
+ import { ROW_LOCALES } from './generate-capability-locale.mjs'
43
47
 
44
48
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
45
49
  const REGISTRY_FILE = 'host/tool-registry.js'
@@ -51,6 +55,7 @@ const USAGE = `usage: node scripts/check-components.mjs [--root <dir>] [--json]
51
55
  --help print this text
52
56
 
53
57
  Fails on a visible component without title/summary/disables in both locale files,
58
+ a generated row label (capability or preset) whose component entry is missing,
54
59
  a forbidden-to-disable capability disabled by a group, row or boolean key, a
55
60
  component count that moved without a scripts/surface-baseline.json decision,
56
61
  an unclassified config key or component row, and a stale generated reference.
@@ -60,6 +65,57 @@ function finding(code, level, message) {
60
65
  return { code, level, message }
61
66
  }
62
67
 
68
+ /**
69
+ * The generated row-label map is a coverage axis of its own (#121). A component
70
+ * that ships a `<specifier>/locale/<lang>.json` label must carry `title`,
71
+ * `summary` and `disables` in every shipped locale — including an internal
72
+ * component such as the mandatory worker preset, which the visible-only rule in
73
+ * `domain/components.js` deliberately does not cover.
74
+ *
75
+ * `rows` is the generator's declaration (`{ specifier, component, mandatory? }`).
76
+ * When a row records `mandatory`, it must agree with the component-surface
77
+ * scope: an internal component is a dependency, never a switch. That is how the
78
+ * worker's "mandatory dependency" description and the component policy stay one
79
+ * decision.
80
+ *
81
+ * @param {any} surface the parsed `scripts/component-surface.json`
82
+ * @param {Record<string, any>} locales file path -> parsed locale JSON
83
+ * @param {Array<{ specifier: string, component: string, mandatory?: boolean }>} rows
84
+ */
85
+ export function checkRowLocaleCoverage(surface, locales, rows) {
86
+ const findings = []
87
+ const record = (code, message) => findings.push(finding(code, 'error', message))
88
+ const components = new Map(surface.components.map((component) => [component.id, component]))
89
+ for (const row of rows ?? []) {
90
+ const component = components.get(row?.component)
91
+ if (!component) {
92
+ record(
93
+ 'row-locale-unknown',
94
+ `row locale "${row?.specifier}" names component "${row?.component}", which the component surface does not declare`,
95
+ )
96
+ continue
97
+ }
98
+ if (typeof row.mandatory === 'boolean' && row.mandatory !== (component.scope === 'internal')) {
99
+ record(
100
+ 'row-locale-scope',
101
+ `row locale "${row.specifier}" labels ${component.id} ${row.mandatory ? 'mandatory' : 'optional'}, but the component surface scope is ${component.scope}`,
102
+ )
103
+ }
104
+ for (const file of LOCALE_FILES) {
105
+ const entry = locales?.[file]?.components?.[component.id]
106
+ if (!entry || typeof entry !== 'object') {
107
+ record('row-locale', `row locale ${component.id} has no entry in ${file}`)
108
+ continue
109
+ }
110
+ for (const field of LOCALE_FIELDS) {
111
+ const value = typeof entry[field] === 'string' ? entry[field].trim() : ''
112
+ if (!value) record('row-locale', `row locale ${component.id} misses "${field}" in ${file}`)
113
+ }
114
+ }
115
+ }
116
+ return findings
117
+ }
118
+
63
119
  /** The live boolean config keys, derived from the schemastery schema. */
64
120
  function liveBooleanKeys() {
65
121
  const keys = []
@@ -141,6 +197,10 @@ export function checkComponents(root) {
141
197
  }
142
198
  findings.push(...validateComponentSurface(surface, { configKeys: live.configKeys, patch: live.patch }))
143
199
  findings.push(...checkLocaleCoverage(surface, live.locales))
200
+ // #121: a row that ships a generated `<specifier>/locale/*.json` label needs
201
+ // the same three strings in both locales, including an internal row (the
202
+ // mandatory worker preset) that the visible-only rule above does not cover.
203
+ findings.push(...checkRowLocaleCoverage(surface, live.locales, ROW_LOCALES))
144
204
  findings.push(...checkForbidden(surface, { booleans: live.booleans, registry: live.registry, patch: live.patch }))
145
205
 
146
206
  // #122: an empty OPERATIONAL_TOOLS table is the expected state. Every route
@@ -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',
@@ -61,13 +61,13 @@
61
61
  "id": "preset-tower-worker",
62
62
  "scope": "internal",
63
63
  "kind": "row",
64
- "fallback": "tower_spawn fails without the worker preset. A mandatory dependency, never a switch."
64
+ "fallback": "Declared by dsh-tower/preset-worker: tower_spawn fails without the worker preset. A mandatory dependency, never a switch."
65
65
  },
66
66
  {
67
67
  "id": "preset-tower-reviewer",
68
68
  "scope": "visible",
69
69
  "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."
70
+ "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."
71
71
  }
72
72
  ],
73
73
  "gateableTools": {},
@@ -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,10 @@ 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
+ // The worker preset has no off state; it is a mandatory dependency of the Tower
34
+ // host. Its description says so with an explicit marker instead of a "When off"
35
+ // label that would make the row read like a switch.
36
+ const DEPENDENCY_LABEL = { en: 'Mandatory dependency, not a switch:', zh: '强制依赖,不是开关:' }
31
37
 
32
38
  /**
33
39
  * The optional capability rows that need their own subpath locale resource, in
@@ -43,6 +49,22 @@ export const CAPABILITY_LOCALES = [
43
49
  { specifier: 'hmr', component: 'tower-hmr' },
44
50
  ]
45
51
 
52
+ /**
53
+ * The two preset-declaring rows. They used to mount the shared
54
+ * `@deepseek-ai/dsh-agent-preset`, so DSH resolved one locale resource for both
55
+ * and rendered the same title twice. Each now owns a Tower declaration module
56
+ * (`host/preset-*.js`) and therefore its own `<specifier>/locale/*.json` label.
57
+ * `mandatory` is the worker dependency; it is asserted against the component
58
+ * surface scope by the gate/tests so the flag cannot drift from the policy.
59
+ */
60
+ export const PRESET_LOCALES = [
61
+ { specifier: 'preset-worker', component: 'preset-tower-worker', mandatory: true },
62
+ { specifier: 'preset-reviewer', component: 'preset-tower-reviewer', mandatory: false },
63
+ ]
64
+
65
+ /** Every row that ships a generated subpath locale resource. */
66
+ export const ROW_LOCALES = [...CAPABILITY_LOCALES, ...PRESET_LOCALES]
67
+
46
68
  export const CAPABILITY_LOCALE_LANGUAGES = LANGUAGES
47
69
 
48
70
  /** Repo-relative paths of every generated capability locale file. */
@@ -52,7 +74,19 @@ export function capabilityLocalePaths() {
52
74
  )
53
75
  }
54
76
 
55
- /** The `package.json` exports entry that exposes one capability's locale files. */
77
+ /** Repo-relative paths of every generated preset row locale file. */
78
+ export function presetLocalePaths() {
79
+ return PRESET_LOCALES.flatMap(({ specifier }) =>
80
+ LANGUAGES.map((lang) => `locale/${specifier}/${lang}.json`),
81
+ )
82
+ }
83
+
84
+ /** Repo-relative paths of every generated row locale file (capability + preset). */
85
+ export function rowLocalePaths() {
86
+ return [...capabilityLocalePaths(), ...presetLocalePaths()]
87
+ }
88
+
89
+ /** The `package.json` exports entry that exposes one row's locale files. */
56
90
  export function capabilityLocaleExport(specifier) {
57
91
  return { key: `./${specifier}/locale/*.json`, target: `./locale/${specifier}/*.json` }
58
92
  }
@@ -63,15 +97,22 @@ function requireText(value, label) {
63
97
  return text
64
98
  }
65
99
 
100
+ /** Normalize the three locale prose fields a generated row label needs. */
101
+ function rowText(entry, lang) {
102
+ return {
103
+ title: requireText(entry?.title, `${lang} row title`),
104
+ summary: requireText(entry?.summary, `${lang} row summary`).replace(/\s+/g, ' '),
105
+ disables: requireText(entry?.disables, `${lang} row disables`).replace(/\s+/g, ' '),
106
+ }
107
+ }
108
+
66
109
  /**
67
110
  * Compose the display metadata for one capability row in one language.
68
111
  * @param {{ title: string, summary: string, disables: string }} entry
69
112
  * @param {'en' | 'zh'} lang
70
113
  */
71
114
  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, ' ')
115
+ const { title, summary, disables } = rowText(entry, lang)
75
116
  // Chinese prose does not space CJK punctuation, so the label joins directly.
76
117
  const space = lang === 'zh' ? '' : ' '
77
118
  return {
@@ -80,23 +121,57 @@ export function renderCapabilityMeta(entry, lang) {
80
121
  }
81
122
 
82
123
  /**
83
- * Render every generated file for one repository root.
84
- * @param {string} root
85
- * @returns {Map<string, string>} repo-relative path -> file text
124
+ * Compose the display metadata for one preset-declaring row in one language.
125
+ * The mandatory worker dependency is labelled as a dependency, never as a
126
+ * switch; the conditional reviewer keeps the ordinary off-effect label.
127
+ * @param {{ title: string, summary: string, disables: string }} entry
128
+ * @param {'en' | 'zh'} lang
129
+ * @param {boolean} mandatory
86
130
  */
87
- export function renderCapabilityLocaleFiles(root) {
131
+ export function renderPresetMeta(entry, lang, mandatory) {
132
+ const { title, summary, disables } = rowText(entry, lang)
133
+ const space = lang === 'zh' ? '' : ' '
134
+ const label = mandatory ? DEPENDENCY_LABEL[lang] : OFF_EFFECT_LABEL[lang]
135
+ return {
136
+ meta: { title, description: `${summary}${space}${label}${space}${disables}` },
137
+ }
138
+ }
139
+
140
+ function readLocales(root) {
88
141
  const locales = {}
89
142
  for (const lang of LANGUAGES) {
90
143
  const file = path.join(root, LANGUAGE_FILES[lang])
91
144
  if (!fs.existsSync(file)) throw new Error(`${LANGUAGE_FILES[lang]} is missing under ${root}`)
92
145
  locales[lang] = JSON.parse(fs.readFileSync(file, 'utf8'))
93
146
  }
147
+ return locales
148
+ }
149
+
150
+ /** Pull one row's components entry out of both locales, or throw a named error. */
151
+ function componentEntry(locales, lang, component) {
152
+ const entry = locales[lang]?.components?.[component]
153
+ if (!entry) throw new Error(`${LANGUAGE_FILES[lang]} has no components.${component} entry`)
154
+ return entry
155
+ }
156
+
157
+ /**
158
+ * Render every generated file for one repository root.
159
+ * @param {string} root
160
+ * @returns {Map<string, string>} repo-relative path -> file text
161
+ */
162
+ export function renderRowLocaleFiles(root) {
163
+ const locales = readLocales(root)
94
164
  const files = new Map()
95
165
  for (const { specifier, component } of CAPABILITY_LOCALES) {
96
166
  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`)
167
+ const rendered = renderCapabilityMeta(componentEntry(locales, lang, component), lang)
168
+ files.set(`locale/${specifier}/${lang}.json`, `${JSON.stringify(rendered, null, 2)}\n`)
169
+ }
170
+ }
171
+ for (const { specifier, component, mandatory } of PRESET_LOCALES) {
172
+ for (const lang of LANGUAGES) {
173
+ const rendered = renderPresetMeta(componentEntry(locales, lang, component), lang, mandatory)
174
+ files.set(`locale/${specifier}/${lang}.json`, `${JSON.stringify(rendered, null, 2)}\n`)
100
175
  }
101
176
  }
102
177
  return files
@@ -116,11 +191,12 @@ function main(argv) {
116
191
  --help print this text
117
192
 
118
193
  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.`)
194
+ locale/zh.json; do not edit locale/<row>/<lang>.json by hand. Capability and
195
+ preset-declaring rows share this generator so their labels cannot drift.`)
120
196
  return 0
121
197
  }
122
198
  const root = path.resolve(options.root)
123
- const files = renderCapabilityLocaleFiles(root)
199
+ const files = renderRowLocaleFiles(root)
124
200
  if (options.check) {
125
201
  const stale = []
126
202
  for (const [relative, expected] of files) {
@@ -129,11 +205,11 @@ locale/zh.json; do not edit locale/<capability>/<lang>.json by hand.`)
129
205
  if (actual !== expected) stale.push(relative)
130
206
  }
131
207
  if (stale.length > 0) {
132
- console.error('capability locale files are stale; run npm run docs:generate:locale')
208
+ console.error('row locale files are stale; run npm run docs:generate:locale')
133
209
  for (const relative of stale) console.error(` ${relative}`)
134
210
  return 1
135
211
  }
136
- console.log(`capability row locales: ${files.size} files in sync (${CAPABILITY_LOCALES.length} specifiers, en/zh)`)
212
+ console.log(`row locales: ${files.size} files in sync (${ROW_LOCALES.length} specifiers, en/zh)`)
137
213
  return 0
138
214
  }
139
215
  for (const [relative, text] of files) {
@@ -141,7 +217,7 @@ locale/zh.json; do not edit locale/<capability>/<lang>.json by hand.`)
141
217
  fs.mkdirSync(path.dirname(target), { recursive: true })
142
218
  fs.writeFileSync(target, text)
143
219
  }
144
- console.log(`wrote ${files.size} capability row locale files (${CAPABILITY_LOCALES.length} specifiers, en/zh)`)
220
+ console.log(`wrote ${files.size} row locale files (${ROW_LOCALES.length} specifiers, en/zh)`)
145
221
  return 0
146
222
  }
147
223
 
@@ -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))
@@ -55,11 +55,30 @@ export function defaultOffToolIds() {
55
55
  return ids
56
56
  }
57
57
 
58
+ /**
59
+ * The two preset rows the bundle declares. Each row mounts a Tower-owned
60
+ * declaration module (`dsh-tower/preset-*`) whose config the agent-preset
61
+ * registry registers; the label and the read-only reviewer surface are part of
62
+ * the contract, so the smoke compares the loaded `agentPresets.list()` entries
63
+ * against this table (#121).
64
+ */
65
+ export const PRESET_ROWS = Object.freeze([
66
+ Object.freeze({ id: 'tower-worker', specifier: 'preset-worker', name: 'Tower Worker', order: 60 }),
67
+ Object.freeze({ id: 'tower-reviewer', specifier: 'preset-reviewer', name: 'Tower Reviewer', order: 61 }),
68
+ ])
69
+
58
70
  /** Presets the bundle declares for its worker and reviewer roles. */
59
- export const PRESET_IDS = ['tower-reviewer', 'tower-worker']
71
+ export const PRESET_IDS = PRESET_ROWS.map((row) => row.id).sort()
72
+
73
+ /** Module specifiers the two preset rows mount; each owns its Plugins-page label. */
74
+ export const PRESET_SPECIFIERS = PRESET_ROWS.map((row) => `dsh-tower/${row.specifier}`)
60
75
 
61
76
  /** Cordis fiber state 2: mounted and active. */
62
77
  const FIBER_ACTIVE = 2
78
+ /** Cordis fiber state 3: the plugin failed to activate. */
79
+ const FIBER_FAILED = 3
80
+ /** Cordis fiber state 1: activation is still in flight (the boot has not settled). */
81
+ const FIBER_PENDING = 1
63
82
 
64
83
  const OWNED_ROW_IDS = new Set([BUNDLE_ID, ...BUNDLE_CHILD_IDS, ...OPTIONAL_ROW_IDS])
65
84
 
@@ -128,6 +147,128 @@ export function compareIds(label, expectedIds, loadedIds) {
128
147
  return failures
129
148
  }
130
149
 
150
+ /** Read one localized field: DSH reports `{ en, zh }` maps, a bare string, or nothing. */
151
+ function localizedField(value, lang) {
152
+ if (typeof value === 'string') return value.trim() || undefined
153
+ if (value && typeof value === 'object' && typeof value[lang] === 'string') return value[lang].trim() || undefined
154
+ return undefined
155
+ }
156
+
157
+ /**
158
+ * Assert the two preset rows resolve distinct Plugins-page labels. The defect
159
+ * this mission fixes was two rows sharing `@deepseek-ai/dsh-agent-preset`, so
160
+ * both rendered that module's single title; the smoke now reads the same
161
+ * `readPluginMeta` the page uses and requires a distinct title and description
162
+ * per specifier (#121).
163
+ * @param {Record<string, { title?: unknown, description?: unknown, error?: string } | null>} labels
164
+ * module specifier -> label metadata from `readPluginMeta`.
165
+ * @returns {string[]} one message per missing, failed or indistinguishable label.
166
+ */
167
+ export function checkPresetLabels(labels) {
168
+ const failures = []
169
+ const byLanguage = { en: new Map(), zh: new Map() }
170
+ for (const row of PRESET_ROWS) {
171
+ const specifier = `dsh-tower/${row.specifier}`
172
+ const label = labels?.[specifier]
173
+ if (label === undefined) {
174
+ failures.push(`label: no label reported for ${specifier}`)
175
+ continue
176
+ }
177
+ if (label === null) {
178
+ failures.push(`label: ${specifier} resolves no locale metadata`)
179
+ continue
180
+ }
181
+ if (label.error) {
182
+ failures.push(`label: ${specifier}: ${label.error}`)
183
+ continue
184
+ }
185
+ for (const lang of ['en', 'zh']) {
186
+ const title = localizedField(label.title, lang)
187
+ const description = localizedField(label.description, lang)
188
+ if (!title) {
189
+ failures.push(`label: ${specifier} has no ${lang} title`)
190
+ continue
191
+ }
192
+ if (!description) failures.push(`label: ${specifier} has no ${lang} description`)
193
+ if (title === specifier) failures.push(`label: ${specifier} falls back to the module specifier as its ${lang} title`)
194
+ if (!title.includes(row.name)) failures.push(`label: ${specifier} ${lang} title does not name ${row.name}`)
195
+ byLanguage[lang].set(specifier, { title, description })
196
+ }
197
+ }
198
+ for (const lang of ['en', 'zh']) {
199
+ const entries = [...byLanguage[lang].entries()]
200
+ if (entries.length < 2) continue
201
+ const [first] = entries
202
+ for (const [specifier, entry] of entries.slice(1)) {
203
+ if (entry.title === first[1].title) {
204
+ failures.push(`label: ${first[0]} and ${specifier} share the same ${lang} title`)
205
+ }
206
+ if (entry.description !== undefined && entry.description === first[1].description) {
207
+ failures.push(`label: ${first[0]} and ${specifier} share the same ${lang} description`)
208
+ }
209
+ }
210
+ }
211
+ return failures
212
+ }
213
+
214
+ /**
215
+ * Compare the loaded preset definitions against {@link PRESET_ROWS}. A missing
216
+ * preset, a changed display name/order, or an activation diagnostic (`broken`)
217
+ * is a failed claim: a preset row that mounts but never activates is exactly the
218
+ * regression the smoke exists to catch.
219
+ * @param {Array<{ id?: string, name?: string, order?: number, broken?: string }>} entries
220
+ * @returns {string[]} one message per violated preset claim.
221
+ */
222
+ export function checkPresetRows(entries) {
223
+ const list = Array.isArray(entries) ? entries : []
224
+ const byId = new Map(list.map((entry) => [String(entry?.id), entry]))
225
+ const failures = []
226
+ for (const row of PRESET_ROWS) {
227
+ const entry = byId.get(row.id)
228
+ if (!entry) {
229
+ failures.push(`preset: missing ${row.id}`)
230
+ continue
231
+ }
232
+ if (entry.name !== row.name) {
233
+ failures.push(`preset: ${row.id} name ${JSON.stringify(entry.name)}, expected ${JSON.stringify(row.name)}`)
234
+ }
235
+ if (entry.order !== row.order) {
236
+ failures.push(`preset: ${row.id} order ${JSON.stringify(entry.order)}, expected ${row.order}`)
237
+ }
238
+ if (entry.broken) failures.push(`preset: ${row.id} failed to mount: ${entry.broken}`)
239
+ }
240
+ return failures
241
+ }
242
+
243
+ /** Entry ids whose fiber reported an activation error (state 3). */
244
+ export function failedEntryStates(entryStates = {}) {
245
+ return Object.entries(entryStates)
246
+ .filter(([, state]) => state === FIBER_FAILED)
247
+ .map(([id]) => id)
248
+ .sort()
249
+ }
250
+
251
+ /** Entry ids still loading when the boot returned (state 1). */
252
+ export function pendingEntryStates(entryStates = {}) {
253
+ return Object.entries(entryStates)
254
+ .filter(([, state]) => state === FIBER_PENDING)
255
+ .map(([id]) => id)
256
+ .sort()
257
+ }
258
+
259
+ /**
260
+ * No row may fail or stay pending in a green smoke: the bundle is supposed to
261
+ * compose a working profile, not merely register the ids it claims.
262
+ * @param {Record<string, number | null | undefined>} entryStates
263
+ * @returns {string[]} one message per failed or pending row.
264
+ */
265
+ export function checkRowStates(entryStates = {}) {
266
+ const failures = []
267
+ for (const id of failedEntryStates(entryStates)) failures.push(`row: ${id} failed to mount (entry state 3)`)
268
+ for (const id of pendingEntryStates(entryStates)) failures.push(`row: ${id} is still pending (entry state 1)`)
269
+ return failures
270
+ }
271
+
131
272
  /** Effective loader id for a nested row (`group:child`), falling back to the raw id. */
132
273
  function stateKey(id, entryStates) {
133
274
  if (entryStates[id] !== undefined) return id
@@ -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) failures.push(...compareIds('preset', PRESET_IDS, 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) failures.push(...checkBundleRow(tree.top, tree.states))
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) {
@@ -157,6 +157,8 @@ host/doctor.js
157
157
  host/hmr.js
158
158
  host/modeState.js
159
159
  host/modeStore.js
160
+ host/preset-reviewer.js
161
+ host/preset-worker.js
160
162
  host/presets.js
161
163
  host/tool-registry.js
162
164
  host/tower.js
@@ -171,6 +173,10 @@ locale/import/en.json
171
173
  locale/import/zh.json
172
174
  locale/leases/en.json
173
175
  locale/leases/zh.json
176
+ locale/preset-reviewer/en.json
177
+ locale/preset-reviewer/zh.json
178
+ locale/preset-worker/en.json
179
+ locale/preset-worker/zh.json
174
180
  locale/skills/en.json
175
181
  locale/skills/zh.json
176
182
  locale/tests/en.json