dsh-tower 2.4.0 → 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,27 @@
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 均挂载且标签不同。
18
+ ## 2.4.1(2026-10-24)
19
+
20
+ ### 修复
21
+
22
+ - **可选组件行在 Plugins 页面只显示模块名**:每个可选能力导出现在提供
23
+ `<specifier>/locale/{en,zh}.json`(`meta.title` / `meta.description`),由
24
+ `locale/<lang>.json` 的 `components` 块经 `npm run docs:generate:locale` 生成,并由
25
+ `docs:check:generated:locale` + CI 步骤防漂移;描述同时说明能力用途与关闭后移除的内容,
26
+ 开关无需离开页面即可判断。
27
+
7
28
  ## 2.4.0(2026-10-24)
8
29
 
9
30
  ### 新增
package/COMPATIBILITY.md CHANGED
@@ -5,6 +5,8 @@ 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 |
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 |
8
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 |
9
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` |
10
12
  | 0.2.1-rc.2 | v2.2.0 | 2026-10-23 | mount smoke (29 tower tools, `/tower`, 3 skills, 2 presets, single bundle row), `npm test` (536), `npm run docs:check`, four generated docs checks |
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 (612 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.
@@ -137,7 +139,12 @@ the push gates; CI schedules it nightly.
137
139
  same PR, and `npm run surface:check` passes. A visible component change also updates
138
140
  `scripts/surface-baseline.json` (`components`), `scripts/component-surface.json` and
139
141
  `docs/reference/component-surface.md` (`npm run docs:generate:components`), and must pass
140
- `npm run docs:check:components`.
142
+ `npm run docs:check:components`. A new visible capability row also ships its own
143
+ `<specifier>/locale/*.json` row label via `npm run docs:generate:locale` and passes
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
147
+ [architecture/overview.md](docs/architecture/overview.md#row-display-metadata-121)).
141
148
 
142
149
  ## Mount smoke
143
150
 
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
@@ -21,6 +21,75 @@ allowed to be switches is a table lookup in
21
21
  to a tool that is not on the recorded allowlist (empty since #122) and any
22
22
  `cordis.patch.yml` row that defaults a forbidden key off.
23
23
 
24
+ ### Row display metadata (#121)
25
+
26
+ The Plugins page renders a row as `row.meta.title ?? row.moduleName`
27
+ plus `row.meta.description`. The Host fills `row.meta` by resolving
28
+ `<specifier>/locale/en.json` and then every sibling `<lang>.json` in that
29
+ directory, reading `meta.title` / `meta.description`; title falls back to the full
30
+ module specifier and description to empty when no resource resolves. That is why
31
+ the mandatory `tower` card explains itself (the package root ships
32
+ `locale/*.json`) while a bare subpath row would show only `dsh-tower/batch`.
33
+
34
+ Each optional capability row therefore ships its own resolvable subpath resource:
35
+ `dsh-tower/skills/locale/{en,zh}.json` and the same for `batch`, `import`,
36
+ `leases`, `tests` and `hmr`. `package.json` `exports` maps every
37
+ `./<capability>/locale/*.json` to `./locale/<capability>/*.json`, and
38
+ `files: ["locale/"]` ships the nested tree, so the lookup matches the installed
39
+ package. The root `./locale/*.json` export stays so the Tower group keeps its own
40
+ `meta`.
41
+
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:
79
+
80
+ 1. Classify it in `scripts/component-surface.json`, ship it in
81
+ `cordis.patch.yml`, and add `title` / `summary` / `disables` to the
82
+ `components` block of both `locale/en.json` and `locale/zh.json`.
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`.
88
+ 3. Run `npm run docs:generate:locale`, then regenerate
89
+ `scripts/pack-manifest.txt` with `npm run pack:check -- --write`.
90
+
91
+ The generated row files never touch the root locale `meta`.
92
+
24
93
  ```
25
94
  Host plugin (index.js)
26
95
  ├─ /tower command + Tower mode (guard, prompt section, tower_mode/tower_control)
@@ -46,6 +115,8 @@ Host plugin (index.js)
46
115
  host/modeStore.js per-agent Tower mode persistence
47
116
  host/modeState.js owned in-memory mode state
48
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)
49
120
  host/compatibility.js COMPATIBILITY.md provenance
50
121
  host/tower.js default-deps Tower binding for tests/embedders
51
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
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Evidence batches",
4
+ "description": "Group missions into finite evidence batches and close them against required checks and receipts. When off: tower_batch is not registered; missions, gates and merges are unaffected."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "证据批次",
4
+ "description": "把 mission 归入有限证据批次,并按必需检查与回执关闭批次。关闭后:不再注册 tower_batch;mission、门禁与合并不受影响。"
5
+ }
6
+ }
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": "Dev hot reload",
4
+ "description": "Register tower_reload and /tower reload for linked-profile development. When off: tower_reload and /tower reload are unavailable; a non-HMR profile behaves exactly as before. Shipped off."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "开发热重载",
4
+ "description": "为联动开发 profile 注册 tower_reload 与 /tower reload。关闭后:tower_reload 与 /tower reload 不可用;非 HMR profile 的行为与之前完全相同。随包默认关闭。"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Legacy import",
4
+ "description": "Import a Tower Codex legacy state file and audit its snapshot-to-mission mapping. When off: tower_import_legacy and tower_legacy_map are not registered; new state is unaffected."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "旧版导入",
4
+ "description": "导入 Tower Codex 旧状态文件,并审计快照到 mission 的映射。关闭后:不再注册 tower_import_legacy 与 tower_legacy_map;新状态不受影响。"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Advisory leases",
4
+ "description": "Coordinate heavy per-repository work with advisory resource leases. When off: tower_resource is not registered; nothing else depends on a lease."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "咨询式租约",
4
+ "description": "用咨询式资源租约协调每个仓库中的重型工作。关闭后:不再注册 tower_resource;其他能力不依赖租约。"
5
+ }
6
+ }
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Tower skills",
4
+ "description": "Playbooks for worker/reviewer agents and recovery after a blocked merge. When off: The three Tower skills leave the catalog; the worker/reviewer protocol sections and every mission tool keep working."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Tower 技能包",
4
+ "description": "执行与审查代理的操作手册,以及失败后的恢复指南。关闭后:三个 Tower 技能从目录中移除;执行/审查协议段落与所有 mission 工具继续工作。"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Test discovery",
4
+ "description": "Run the worktree's declared test discovery and record a receipt for the mission tip. When off: tower_test is not registered and no receipt can be recorded; with gates.requireTests:true planning/mode entry is rejected with an explanation."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "测试发现",
4
+ "description": "运行 worktree 声明的测试发现,并为 mission tip 记录回执。关闭后:不再注册 tower_test,也无法记录回执;若 gates.requireTests:true,规划/进入模式会以解释性错误拒绝。"
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.0",
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,6 +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",
41
+ "./skills/locale/*.json": "./locale/skills/*.json",
42
+ "./batch/locale/*.json": "./locale/batch/*.json",
43
+ "./import/locale/*.json": "./locale/import/*.json",
44
+ "./leases/locale/*.json": "./locale/leases/*.json",
45
+ "./tests/locale/*.json": "./locale/tests/*.json",
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",
39
49
  "./locale/*.json": "./locale/*.json",
40
50
  "./icon": "./icon.svg",
41
51
  "./package.json": "./package.json"
@@ -54,7 +64,7 @@
54
64
  "types/",
55
65
  "cordis.patch.yml",
56
66
  "scripts/",
57
- "locale/*.json",
67
+ "locale/",
58
68
  "icon.svg",
59
69
  "README.md",
60
70
  "README.en.md",
@@ -99,6 +109,8 @@
99
109
  "docs:check:generated:types": "node scripts/generate-types.mjs --check",
100
110
  "docs:generate:components": "node scripts/generate-component-surface.mjs",
101
111
  "docs:check:generated:components": "node scripts/generate-component-surface.mjs --check",
112
+ "docs:generate:locale": "node scripts/generate-capability-locale.mjs",
113
+ "docs:check:generated:locale": "node scripts/generate-capability-locale.mjs --check",
102
114
  "docs:check:components": "node scripts/check-components.mjs",
103
115
  "pack:check": "node scripts/check-pack.mjs",
104
116
  "architecture:check": "node scripts/architecture.mjs",
@@ -106,7 +118,7 @@
106
118
  "types:coverage": "node scripts/types-coverage.mjs",
107
119
  "types:strict:check": "node scripts/types-strict.mjs",
108
120
  "types:strict:write": "node scripts/types-strict.mjs --write",
109
- "check": "npm test && npm run docs:check && npm run docs:check:philosophy && npm run docs:check:generated && npm run docs:check:generated:tools && npm run docs:check:generated:config && npm run docs:check:generated:events && npm run docs:check:generated:types && npm run docs:check:generated:components && npm run docs:check:components && npm run surface:check && npm run pack:check && npm run architecture:check && npm run types:coverage && npm run types:strict:check && npm run types:check && npm run domain:check",
121
+ "check": "npm test && npm run docs:check && npm run docs:check:philosophy && npm run docs:check:generated && npm run docs:check:generated:tools && npm run docs:check:generated:config && npm run docs:check:generated:events && npm run docs:check:generated:types && npm run docs:check:generated:components && npm run docs:check:generated:locale && npm run docs:check:components && npm run surface:check && npm run pack:check && npm run architecture:check && npm run types:coverage && npm run types:strict:check && npm run types:check && npm run domain:check",
110
122
  "domain:metrics": "node scripts/domain-metrics.mjs",
111
123
  "domain:check": "node scripts/domain-metrics.mjs --check"
112
124
  },
@@ -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,6 +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, presetLocalePaths } from './generate-capability-locale.mjs'
13
14
 
14
15
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
15
16
  const REQUIRED_FILES = [
@@ -17,6 +18,13 @@ const REQUIRED_FILES = [
17
18
  'index.js',
18
19
  'locale/en.json',
19
20
  'locale/zh.json',
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.
24
+ ...capabilityLocalePaths(),
25
+ ...presetLocalePaths(),
26
+ 'host/preset-worker.js',
27
+ 'host/preset-reviewer.js',
20
28
  'ui-bundle/index.js',
21
29
  'ui-bundle/package.json',
22
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": {},
@@ -0,0 +1,224 @@
1
+ // Generate the per-row locale files (#121).
2
+ //
3
+ // The DSH Plugins page renders a row as
4
+ // `row.meta.title ?? row.moduleName` plus `row.meta.description`, and the Host
5
+ // fills `row.meta` by resolving `<specifier>/locale/en.json` and then every
6
+ // sibling `<lang>.json` in that directory. A subpath row without such a resource
7
+ // therefore shows only `dsh-tower/batch`, and the switch is not decidable from
8
+ // the page.
9
+ //
10
+ // Those files are generated, not hand-written: the single source of truth is the
11
+ // `components` block of `locale/<lang>.json` that `npm run docs:check:components`
12
+ // already audits. `meta.title` is the component `title`; `meta.description`
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.
18
+ //
19
+ // Usage:
20
+ // node scripts/generate-capability-locale.mjs [--root <dir>] [--check]
21
+ import fs from 'node:fs'
22
+ import path from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+ import { parseArgs, runMain } from './lib/cli.mjs'
25
+
26
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
27
+ const LANGUAGE_FILES = { en: 'locale/en.json', zh: 'locale/zh.json' }
28
+ const LANGUAGES = Object.keys(LANGUAGE_FILES)
29
+ // The off-effect marker is generated, not recovered from the hand-written
30
+ // `disables` prose, so the check can prove every description names what the
31
+ // switch removes without depending on one English or Chinese phrasing.
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: '强制依赖,不是开关:' }
37
+
38
+ /**
39
+ * The optional capability rows that need their own subpath locale resource, in
40
+ * the row order of `cordis.patch.yml`. The specifier is the module subpath the
41
+ * row mounts; the component id is the key in the locale `components` block.
42
+ */
43
+ export const CAPABILITY_LOCALES = [
44
+ { specifier: 'skills', component: 'tower-skills' },
45
+ { specifier: 'batch', component: 'tower-batch' },
46
+ { specifier: 'import', component: 'tower-import' },
47
+ { specifier: 'leases', component: 'tower-leases' },
48
+ { specifier: 'tests', component: 'tower-tests' },
49
+ { specifier: 'hmr', component: 'tower-hmr' },
50
+ ]
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
+
68
+ export const CAPABILITY_LOCALE_LANGUAGES = LANGUAGES
69
+
70
+ /** Repo-relative paths of every generated capability locale file. */
71
+ export function capabilityLocalePaths() {
72
+ return CAPABILITY_LOCALES.flatMap(({ specifier }) =>
73
+ LANGUAGES.map((lang) => `locale/${specifier}/${lang}.json`),
74
+ )
75
+ }
76
+
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. */
90
+ export function capabilityLocaleExport(specifier) {
91
+ return { key: `./${specifier}/locale/*.json`, target: `./locale/${specifier}/*.json` }
92
+ }
93
+
94
+ function requireText(value, label) {
95
+ const text = typeof value === 'string' ? value.trim() : ''
96
+ if (!text) throw new Error(`${label} must be a non-empty string`)
97
+ return text
98
+ }
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
+
109
+ /**
110
+ * Compose the display metadata for one capability row in one language.
111
+ * @param {{ title: string, summary: string, disables: string }} entry
112
+ * @param {'en' | 'zh'} lang
113
+ */
114
+ export function renderCapabilityMeta(entry, lang) {
115
+ const { title, summary, disables } = rowText(entry, lang)
116
+ // Chinese prose does not space CJK punctuation, so the label joins directly.
117
+ const space = lang === 'zh' ? '' : ' '
118
+ return {
119
+ meta: { title, description: `${summary}${space}${OFF_EFFECT_LABEL[lang]}${space}${disables}` },
120
+ }
121
+ }
122
+
123
+ /**
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
130
+ */
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) {
141
+ const locales = {}
142
+ for (const lang of LANGUAGES) {
143
+ const file = path.join(root, LANGUAGE_FILES[lang])
144
+ if (!fs.existsSync(file)) throw new Error(`${LANGUAGE_FILES[lang]} is missing under ${root}`)
145
+ locales[lang] = JSON.parse(fs.readFileSync(file, 'utf8'))
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)
164
+ const files = new Map()
165
+ for (const { specifier, component } of CAPABILITY_LOCALES) {
166
+ for (const lang of LANGUAGES) {
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`)
175
+ }
176
+ }
177
+ return files
178
+ }
179
+
180
+ function main(argv) {
181
+ const options = parseArgs(argv, {
182
+ initial: { root: PACKAGE_ROOT, check: false, help: false },
183
+ values: { '--root': { key: 'root', parse: path.resolve } },
184
+ flags: { '--check': 'check', '--help': 'help', '-h': 'help' },
185
+ })
186
+ if (options.help) {
187
+ console.log(`usage: node scripts/generate-capability-locale.mjs [--root <dir>] [--check]
188
+
189
+ --root repository root to read and write (default: this package)
190
+ --check fail when a committed file differs instead of writing it
191
+ --help print this text
192
+
193
+ The files are generated from the components block of locale/en.json and
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.`)
196
+ return 0
197
+ }
198
+ const root = path.resolve(options.root)
199
+ const files = renderRowLocaleFiles(root)
200
+ if (options.check) {
201
+ const stale = []
202
+ for (const [relative, expected] of files) {
203
+ const target = path.join(root, relative)
204
+ const actual = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : ''
205
+ if (actual !== expected) stale.push(relative)
206
+ }
207
+ if (stale.length > 0) {
208
+ console.error('row locale files are stale; run npm run docs:generate:locale')
209
+ for (const relative of stale) console.error(` ${relative}`)
210
+ return 1
211
+ }
212
+ console.log(`row locales: ${files.size} files in sync (${ROW_LOCALES.length} specifiers, en/zh)`)
213
+ return 0
214
+ }
215
+ for (const [relative, text] of files) {
216
+ const target = path.join(root, relative)
217
+ fs.mkdirSync(path.dirname(target), { recursive: true })
218
+ fs.writeFileSync(target, text)
219
+ }
220
+ console.log(`wrote ${files.size} row locale files (${ROW_LOCALES.length} specifiers, en/zh)`)
221
+ return 0
222
+ }
223
+
224
+ await runMain(import.meta.url, () => main(process.argv.slice(2)), { errorPrefix: 'capability-locales' })
@@ -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,12 +157,30 @@ 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
163
165
  icon.svg
164
166
  index.js
167
+ locale/batch/en.json
168
+ locale/batch/zh.json
165
169
  locale/en.json
170
+ locale/hmr/en.json
171
+ locale/hmr/zh.json
172
+ locale/import/en.json
173
+ locale/import/zh.json
174
+ locale/leases/en.json
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
180
+ locale/skills/en.json
181
+ locale/skills/zh.json
182
+ locale/tests/en.json
183
+ locale/tests/zh.json
166
184
  locale/zh.json
167
185
  package.json
168
186
  ports/archive.js
@@ -187,6 +205,7 @@ scripts/check-philosophy.mjs
187
205
  scripts/component-surface.json
188
206
  scripts/domain-metrics.mjs
189
207
  scripts/enable-dev-hmr.mjs
208
+ scripts/generate-capability-locale.mjs
190
209
  scripts/generate-component-surface.mjs
191
210
  scripts/generate-config-reference.mjs
192
211
  scripts/generate-error-reference.mjs