dsh-llm-fallbacks 0.1.0-alpha.1 → 0.1.0-alpha.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/README.md CHANGED
@@ -9,10 +9,10 @@
9
9
 
10
10
  Automatic provider/model fallback chains for dsh (DeepSeek Harness): when an agent's LLM requests keep failing — retries exhausted, auth errors, quota exceeded, rate limiting (429) — the plugin switches provider/model along the fallback chain for the current role, and the current step/turn continues on the target model: tasks are not interrupted by model problems.
11
11
 
12
- Install with a single command (pnpm ≥ 10 needs one build-allow step — see [Install](#install)):
12
+ Install with a single command (see [Install](#install)):
13
13
 
14
14
  ```sh
15
- dsh plugin --profile web add github:btspoony/dsh-llm-fallbacks # pin a commit with #<sha>
15
+ dsh plugin --profile web add dsh-llm-fallbacks # pin a version with @<version>
16
16
  ```
17
17
 
18
18
  ## Features
@@ -27,13 +27,31 @@ dsh plugin --profile web add github:btspoony/dsh-llm-fallbacks # pin a commit
27
27
 
28
28
  ## Install
29
29
 
30
- ### One-line git install
30
+ ### One-line install
31
31
 
32
32
  ```sh
33
- dsh plugin --profile web add github:btspoony/dsh-llm-fallbacks # pin a commit with #<sha>
33
+ dsh plugin --profile web add dsh-llm-fallbacks # pin a version with @<version>
34
34
  ```
35
35
 
36
- A git install fetches **sources, not built artifacts**, so the bundle builds itself on install (`prepare` self-build). The plugin is **mount-only**: it never modifies the dsh source tree, and no patch / postinstall step exists — dsh upgrades never require re-patching. pnpm ≥ 10 blocks a git dependency's `prepare` by default: the first `add` fails with `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`, and pnpm prints the exact package key. Allow the build in the profile's `pnpm-workspace.yaml` (`onlyBuiltDependencies: [dsh-llm-fallbacks]`, or run `dsh plugin --profile web approve-builds`), then re-run the `add`. Treat that allowance as permission to execute the package's code on your machine at install time, and pin a commit (`github:btspoony/dsh-llm-fallbacks#<sha>`) so a later push cannot silently change what runs. The full URL form works equivalently: `dsh plugin --profile web add https://github.com/btspoony/dsh-llm-fallbacks.git`.
36
+ A registry install fetches the **built package** (`dist/`) — nothing is built on the target machine. The plugin is **mount-only**: it never modifies the dsh source tree, and no patch / postinstall step exists — dsh upgrades never require re-patching. Versioning follows npm dist-tags (`latest` by default); pin an exact version with `dsh plugin --profile web add dsh-llm-fallbacks@<version>`.
37
+
38
+ > **Release status**: the package is **not on the npm registry yet** — the first release (`0.1.0-alpha.2`) is pending (the release pipeline is in place; the first publish uses a one-time `NODE_AUTH_TOKEN` bootstrap secret, with Trusted Publishing configured afterwards for tokenless releases). All registry-based commands in this section are **preview** until the first release PR is merged; until then use the [git install](#git-install-works-today) channel.
39
+
40
+ ### Registry package (npm / pnpm)
41
+
42
+ ```sh
43
+ npm install dsh-llm-fallbacks # or: pnpm add dsh-llm-fallbacks
44
+ ```
45
+
46
+ > **Preview**: effective after `dsh-llm-fallbacks@0.1.0-alpha.2` is published. Release process → [docs/release.md](docs/release.md).
47
+
48
+ ### Git install (works today)
49
+
50
+ ```sh
51
+ dsh plugin --profile web add github:omdsh-dev/dsh-llm-fallbacks # pin a commit with #<sha>
52
+ ```
53
+
54
+ A git install fetches **sources, not built artifacts** — the bundle builds itself on install (`prepare` self-build), so the target machine needs node + pnpm. pnpm ≥ 10 blocks a git dependency's `prepare` by default (`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`); allow the build in the profile's `pnpm-workspace.yaml` and re-run the `add` (details in [docs/install.md](docs/install.md)).
37
55
 
38
56
  ### Local directory install (recommended for development / verification)
39
57
 
@@ -77,7 +95,7 @@ fallbacks:
77
95
  role: reviewer
78
96
  ```
79
97
 
80
- Roles are declared entities: `roles.list` holds role cards (id/label/description + optional chain/fallback), and `roles.rules` match origin/provider/model in order to a declared role id or the built-in `inherit` (first match wins) — **no rule match → `inherit` → `rootChain`**. A declared role is only ever hit when a rule references it. The legacy chain-key namespace and role-default field are gone (migration table: [docs/configuration.md](docs/configuration.md)).
98
+ Roles are declared entities: `roles.list` holds role cards (id/label/description + chain/fallback), and `roles.rules` match origin/provider/model in order to a declared role id or the built-in `inherit` (first match wins) — **no rule match → `inherit` → `rootChain`**. A declared role is only ever hit when a rule references it. A role without a chain is meaningless — the settings card blocks saving it and the host warns at startup; give every declared role a chain, or point its rules at the built-in `inherit`. (A hand-written YAML role with a missing/empty chain is not fatal: with `fallback: inherit-root` (the default) an empty chain still falls back to `rootChain`; with `fallback: none` an empty chain leaves no candidates and the request passes through untouched — either way it is only a startup warn.) The legacy chain-key namespace and role-default field are gone (migration table: [docs/configuration.md](docs/configuration.md)).
81
99
 
82
100
  Save and restart the web session for the changes to take effect. The feature switch `fallbacks.enabled` **defaults to off (`false`)** — the plugin only engages once it is turned on; `triggerCodes` defaults to `AUTH` / `QUOTA` / `RATE_LIMIT`; and with **no `rootChain`/role chains configured the behavior is identical to not having the plugin installed**. More examples (role entities, fallback strategies, rules referencing `inherit`) → [docs/configuration.md](docs/configuration.md).
83
101
 
@@ -103,7 +121,7 @@ The plugin installs as a **pure mount** — it never modifies the dsh source tre
103
121
  the Fallbacks card on the Settings → 插件配置 page, and settings read/write/reset
104
122
  go through the plugin's own gateway channel (`/api/fallbacks/get|set|reset`).
105
123
  - **No patches, no auto-apply step**: there are no dsh-body patch files and no install
106
- lifecycle step that applies one. A one-line git install works as-is.
124
+ lifecycle step that applies one. A one-line registry install works as-is.
107
125
  - **dsh upgrades never require re-patching**: a dsh upgrade that resets the source
108
126
  tree changes nothing for this plugin — it keeps working without any re-apply step.
109
127
  - **Stale leftover patches are harmless**: the plugin never depends on a patch
@@ -111,12 +129,23 @@ The plugin installs as a **pure mount** — it never modifies the dsh source tre
111
129
  was removed), so a previously patched dsh tree can be left as-is or manually
112
130
  reverted — neither is required.
113
131
 
132
+ ## Developer consumption
133
+
134
+ Beyond the dsh plugin mount, `dsh-llm-fallbacks` exposes a programmable consumer surface — both faces share the same function implementations (single point of truth, no copied logic):
135
+
136
+ - **Library API**: import the runtime functions and types from the package root — `import { resolveRole, resolveChain, validateFallbacksConfig } from 'dsh-llm-fallbacks'`.
137
+ - **Named cordis service**: while the plugin is applied, `ctx.get('llm-fallbacks')` returns a pure-function service (`{ name, version, resolveRole, resolveChain, validateFallbacksConfig, detectLegacyKeys }`); after the plugin is disposed it is `undefined`. Runtime state stays observable via `fallbacks/switch` events, not through the service.
138
+
139
+ Full contract (export inventory, minimal examples, lifecycle, typing) → [docs/consumer-api.md](docs/consumer-api.md).
140
+
114
141
  ## Documentation
115
142
 
116
143
  | Doc | Content |
117
144
  |---|---|
118
- | [docs/install.md](docs/install.md) | profile install / git install / uninstall / `--dump-config` verification |
145
+ | [docs/install.md](docs/install.md) | profile install / registry install / uninstall / `--dump-config` verification |
146
+ | [docs/release.md](docs/release.md) | release process: Trusted Publishing setup, Release prep SOP, fragment format, rollback |
119
147
  | [docs/configuration.md](docs/configuration.md) | full `fallbacks` namespace reference, selector syntax, example YAML, plugin-config card usage, behavior notes |
148
+ | [docs/consumer-api.md](docs/consumer-api.md) | developer consumption contract: export inventory, minimal examples, lifecycle, typing |
120
149
  | [docs/verification.md](docs/verification.md) | verification records (test matrix, bundle layer order, runtime contracts, QA gate script) |
121
150
 
122
151
  ## License
package/README.zh-CN.md CHANGED
@@ -9,10 +9,10 @@
9
9
 
10
10
  dsh(DeepSeek Harness)的自动模型降级插件:当 root agent 或 subagent 的模型请求持续失败(重试耗尽、权限、配额超限、限流 429)时,按角色/模型 fallback 链自动切换 provider/model,当前 step/turn 在目标模型上继续完成——任务不因模型问题中断。
11
11
 
12
- 一句话安装(pnpm ≥ 10 需一次构建放行,见 [安装](#安装)):
12
+ 一句话安装(见 [安装](#安装)):
13
13
 
14
14
  ```sh
15
- dsh plugin --profile web add github:btspoony/dsh-llm-fallbacks # 钉 commit:加 #<sha>
15
+ dsh plugin --profile web add dsh-llm-fallbacks # 钉版本:加 @<version>
16
16
  ```
17
17
 
18
18
  ## 能力一览
@@ -27,13 +27,31 @@ dsh plugin --profile web add github:btspoony/dsh-llm-fallbacks # 钉 commit:
27
27
 
28
28
  ## 安装
29
29
 
30
- ### 一句话 git 安装
30
+ ### 一句话安装
31
31
 
32
32
  ```sh
33
- dsh plugin --profile web add github:btspoony/dsh-llm-fallbacks # 钉 commit:加 #<sha>
33
+ dsh plugin --profile web add dsh-llm-fallbacks # 钉版本:加 @<version>
34
34
  ```
35
35
 
36
- git 安装拉取的是**源码而非构建产物**,安装时由包自行构建(`prepare` 自构建)。插件为**纯挂载**:对 dsh 源码树零修改,无任何补丁 / postinstall 步骤——dsh 升级无需重打。pnpm ≥ 10 默认不执行 git 依赖的 `prepare`:第一次 `add` 会失败并打印 `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`,同时给出精确的包 key。在该 profile 的 `pnpm-workspace.yaml` 中放行构建(`onlyBuiltDependencies: [dsh-llm-fallbacks]`,或运行 `dsh plugin --profile web approve-builds`),然后重跑 `add`。请把这次放行当作它本来的样子:允许该包代码在安装期于你的机器上执行——建议钉 commit(`github:btspoony/dsh-llm-fallbacks#<sha>`),防止后续 push 悄悄改变实际运行的代码。完整 URL 形式等价:`dsh plugin --profile web add https://github.com/btspoony/dsh-llm-fallbacks.git`。
36
+ registry 安装拉取的是**已构建产物**(`dist/`),目标机无需构建。插件为**纯挂载**:对 dsh 源码树零修改,无任何补丁 / postinstall 步骤——dsh 升级无需重打。版本跟随 npm dist-tag(默认 `latest`);钉精确版本:`dsh plugin --profile web add dsh-llm-fallbacks@<version>`。
37
+
38
+ > **发布状态**:包**尚未发布到 npm registry**——首个版本(`0.1.0-alpha.2`)待发布(发布流水线已就绪;首次发布用一次性 `NODE_AUTH_TOKEN`,Trusted Publishing 在首次发布后配置)。本节的 registry 安装命令在首个 release PR merge 前均为**预告**;在此之前请用 [git 安装](#git-安装当前可用)。
39
+
40
+ ### registry 包安装(npm / pnpm)
41
+
42
+ ```sh
43
+ npm install dsh-llm-fallbacks # 或:pnpm add dsh-llm-fallbacks
44
+ ```
45
+
46
+ > **预告**:`dsh-llm-fallbacks@0.1.0-alpha.2` 发布后生效。发布流程见 [docs/release.md](docs/release.md)。
47
+
48
+ ### git 安装(当前可用)
49
+
50
+ ```sh
51
+ dsh plugin --profile web add github:omdsh-dev/dsh-llm-fallbacks # 钉 commit:加 #<sha>
52
+ ```
53
+
54
+ git 安装拉取的是**源码而非构建产物**——安装时由 `prepare` 自构建,目标机需要 node 与 pnpm。pnpm ≥ 10 默认拦截 git 依赖的 `prepare`(`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`);在 profile 的 `pnpm-workspace.yaml` 中放行构建后重跑 `add`(详见 [docs/install.md](docs/install.md))。
37
55
 
38
56
  ### 本地目录安装(开发/验证推荐)
39
57
 
@@ -75,7 +93,7 @@ fallbacks:
75
93
  role: reviewer
76
94
  ```
77
95
 
78
- 角色是声明式实体:`roles.list` 存放角色卡(id/label/description + 可选 chain/fallback),`roles.rules` 按 origin/provider/model 顺序匹配到**已声明角色 id 或内置 `inherit`**(首个命中即停)——**未命中 → `inherit` → `rootChain`**。只声明不写规则的角色永不命中。旧链键命名空间与角色兜底字段已删除(迁移表见 [docs/configuration.md](docs/configuration.md))。
96
+ 角色是声明式实体:`roles.list` 存放角色卡(id/label/description + chain/fallback),`roles.rules` 按 origin/provider/model 顺序匹配到**已声明角色 id 或内置 `inherit`**(首个命中即停)——**未命中 → `inherit` → `rootChain`**。只声明不写规则的角色永不命中。角色无 chain 无意义——设置卡拦截保存、启动时告警;请为每个声明角色配置 chain,或让规则直接引用内置 `inherit`。(手写 YAML 角色链缺省/为空不致命:`fallback: inherit-root`(默认)时空链仍按防御语义回退 `rootChain`;`fallback: none` 时空链无候选 → no-op 透传;两种情况都仅启动告警。)旧链键命名空间与角色兜底字段已删除(迁移表见 [docs/configuration.md](docs/configuration.md))。
79
97
 
80
98
  保存并重启 web 会话后生效。功能级开关 `fallbacks.enabled` **默认关闭(`false`)**——打开开关后插件才会介入;`triggerCodes` 默认覆盖 `AUTH` / `QUOTA` / `RATE_LIMIT`;**未配置任何 `rootChain`/角色链时行为与未安装插件完全一致**。更多示例(角色实体、fallback 策略、引用 `inherit` 的规则)见 [docs/configuration.md](docs/configuration.md)。
81
99
 
@@ -101,17 +119,28 @@ fallbacks:
101
119
  Fallbacks 卡片,设置读写/重置
102
120
  走插件自有 gateway 通道(`/api/fallbacks/get|set|reset`)。
103
121
  - **无补丁、无自动打补丁**:没有 dsh 本体 patch 文件,也没有任何安装期 apply 步骤;
104
- 一句话 git 安装即可用。
122
+ 一句话 registry 安装即可用。
105
123
  - **dsh 升级永不需重打**:dsh 升级重置源码树对本插件无影响——无需任何重打步骤。
106
124
  - **残留旧补丁无害**:插件不依赖任何补丁导出(角色解析 rules-only;model-selection
107
125
  标记协调已移除)——已打过旧补丁的 dsh 树可原样保留或手动回滚,均非必需。
108
126
 
127
+ ## 开发者消费面
128
+
129
+ 除插件挂载外,`dsh-llm-fallbacks` 还暴露可编程消费面——两个入口共享同一函数实现(单点真相,无复制逻辑):
130
+
131
+ - **库 API**:从包根直接 import 运行时函数与类型——`import { resolveRole, resolveChain, validateFallbacksConfig } from 'dsh-llm-fallbacks'`。
132
+ - **具名 cordis service**:插件 apply 期间 `ctx.get('llm-fallbacks')` 返回纯函数服务(`{ name, version, resolveRole, resolveChain, validateFallbacksConfig, detectLegacyKeys }`),插件 dispose 后为 `undefined`。运行态(冷却、最近切换)请读 `fallbacks/switch` 事件,不走服务对象。
133
+
134
+ 完整契约(导出清单、最小示例、生命周期、类型说明)见 [docs/consumer-api.md](docs/consumer-api.md)。
135
+
109
136
  ## 文档
110
137
 
111
138
  | 文档 | 内容 |
112
139
  |---|---|
113
- | [docs/install.md](docs/install.md) | profile 安装 / git 安装 / 卸载 / `--dump-config` 验证 |
140
+ | [docs/install.md](docs/install.md) | profile 安装 / registry 安装 / 卸载 / `--dump-config` 验证 |
141
+ | [docs/release.md](docs/release.md) | 发布流程:Trusted Publishing 前置、Release prep SOP、fragment 格式、回滚 |
114
142
  | [docs/configuration.md](docs/configuration.md) | `fallbacks` 命名空间全字段、selector 语法、示例 YAML、插件配置卡使用、行为说明 |
143
+ | [docs/consumer-api.md](docs/consumer-api.md) | 开发者消费契约:导出清单、最小示例、生命周期、类型说明 |
115
144
  | [docs/verification.md](docs/verification.md) | 验证记录(测试矩阵、bundle 层序、运行契约、QA gate 剧本) |
116
145
 
117
146
  ## 许可
@@ -74,13 +74,13 @@ export type ConversationFallbackSwitchProps = PropsRuntime<'conversation.chat.no
74
74
  * Render one fallback switch as a compact system-style transcript line.
75
75
  *
76
76
  * Geometry follows the upstream chat system rows (the compaction boundary
77
- * notice: dim title + separator + ellipsized summary — `chat/MessageItem
78
- * .module.css:38-122`); every color resolves through a `--dsw-alias-*`
79
- * token. A reason outside the current union renders raw (forward-compatible
77
+ * notice: warning-toned title + separator + ellipsized summary —
78
+ * `chat/MessageItem .module.css:38-122`); every color resolves through a
79
+ * `--dsw-alias-*` token. A reason outside the current union renders raw (forward-compatible
80
80
  * durable log, same rule as the card/general row summaries). A malformed or
81
81
  * partial payload (version skew) degrades to the title-only line instead of
82
- * throwing during interpolation — the transcript slot stays visible with a
83
- * truthful "a switch happened" notice and no summary details.
82
+ * throwing during interpolation — the transcript slot stays visible with the
83
+ * warning-toned "model downgraded" title (T1 copy) and no summary details.
84
84
  * @param props - composed keyed seat props.
85
85
  * @returns the switch line element tree.
86
86
  */
@@ -26,8 +26,9 @@
26
26
  * `roles.rules`, whose role field is a dropdown bound to the declared ids
27
27
  * + the built-in `inherit`, same-page live). Saving runs `validateDraft`
28
28
  * first — id format/reserved word/duplicates, undeclared rule role
29
- * references, and illegal selectors block the write with a validation
30
- * banner + inline red borders (never touching the store error path); a
29
+ * references, illegal selectors, and a role with no chain entries (no
30
+ * model config) block the write with a validation banner + inline red
31
+ * borders / hints (never touching the store error path); a
31
32
  * non-empty `state.legacyKeys` renders the migration banner at the top of
32
33
  * the card body. The row editors keep their filled editorCard surface
33
34
  * inside the card, with `--dsw-alias-*` tokens throughout. The reset-
@@ -1887,6 +1887,7 @@ window.__ModuleLoader__.load({
1887
1887
  "validation.roleIdDuplicate": "角色 id \"{id}\" 重复",
1888
1888
  "validation.ruleRoleUndeclared": "规则引用了未声明的角色 \"{role}\"",
1889
1889
  "validation.ruleRoleRequired": "规则未选择角色:请选择目标角色,或删除该行",
1890
+ "validation.roleChainRequired": "角色 \"{id}\" 未配置模型:请至少添加一条链选择器(模型配置)",
1890
1891
  "validation.selector": "选择器 \"{entry}\" 非法:{message}",
1891
1892
  "legacy.banner": "检测到旧格式配置字段({keys}):已按新模型展示,请按 docs/configuration.md 迁移表手工改写;插件不会自动改写配置。",
1892
1893
  "catalog.empty": "暂无可用模型:请先在模型页添加模型,添加后此处将自动可选。",
@@ -1915,7 +1916,7 @@ window.__ModuleLoader__.load({
1915
1916
  "general.switch": "最近切换:{from} → {to}({role} · {reason})",
1916
1917
  "general.switch.empty": "本会话暂无切换",
1917
1918
  "general.error": "状态读取失败:{message}",
1918
- "chat.switch.title": "模型切换",
1919
+ "chat.switch.title": "模型已降级",
1919
1920
  "chat.switch.summary": "{from} → {to}({role} · {reason})",
1920
1921
  "defaults.prefix": "默认值",
1921
1922
  "save": "保存",
@@ -2013,6 +2014,7 @@ window.__ModuleLoader__.load({
2013
2014
  "validation.roleIdDuplicate": "Duplicate role id \"{id}\"",
2014
2015
  "validation.ruleRoleUndeclared": "Rule references undeclared role \"{role}\"",
2015
2016
  "validation.ruleRoleRequired": "Rule has no role selected: pick a target role, or remove the row",
2017
+ "validation.roleChainRequired": "Role \"{id}\" has no model config: add at least one chain entry",
2016
2018
  "validation.selector": "Invalid selector \"{entry}\": {message}",
2017
2019
  "legacy.banner": "Legacy config fields detected ({keys}): now shown in the new model — rewrite them manually following the migration table in docs/configuration.md (the plugin will not rewrite them automatically).",
2018
2020
  "catalog.empty": "No models yet: add a model on the Models page first; options will appear here automatically.",
@@ -2041,7 +2043,7 @@ window.__ModuleLoader__.load({
2041
2043
  "general.switch": "Last switch: {from} → {to} ({role} · {reason})",
2042
2044
  "general.switch.empty": "No switches this session",
2043
2045
  "general.error": "Status read failed: {message}",
2044
- "chat.switch.title": "Model switch",
2046
+ "chat.switch.title": "Model downgraded",
2045
2047
  "chat.switch.summary": "{from} → {to} ({role} · {reason})",
2046
2048
  "defaults.prefix": "Default",
2047
2049
  "save": "Save",
@@ -2091,7 +2093,7 @@ window.__ModuleLoader__.load({
2091
2093
  return [...next];
2092
2094
  }
2093
2095
  //#endregion
2094
- //#region \0dsh-css:/Users/bibi/workspace/ai/deepseek/dsh-llm-fallbacks/src/client/FallbacksCard.module.css.mjs
2096
+ //#region \0dsh-css:/home/runner/work/dsh-llm-fallbacks/dsh-llm-fallbacks/src/client/FallbacksCard.module.css.mjs
2095
2097
  const css$2 = "\n\n\n._8827595f_card {\n list-style: none;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 12px;\n background: var(--dsw-alias-bg-layer-3);\n transition: border-color .16s, background .16s;\n}\n\n._8827595f_card:hover {\n border-color: var(--dsw-alias-label-dimmed);\n}\n\n\n._06704203_cardOpen {\n background: var(--dsw-alias-bg-layer-2);\n border-color: var(--dsw-alias-label-dimmed);\n}\n\n\n._e488d460_header {\n width: 100%;\n appearance: none;\n border: 0;\n background: none;\n font: inherit;\n color: inherit;\n text-align: left;\n cursor: pointer;\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 14px 16px;\n border-radius: 12px;\n}\n\n._e488d460_header:focus-visible {\n outline: 2px solid var(--dsw-alias-brand-primary);\n outline-offset: -2px;\n}\n\n\n._f5dfe084_headText {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n._8d39bde6_name {\n font-size: 15px;\n font-weight: 600;\n line-height: 1.4;\n color: var(--dsw-alias-label-primary);\n}\n\n._346f3b69_description {\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n\n._631094a0_chevron {\n flex: none;\n color: var(--dsw-alias-label-tertiary);\n transition: transform .16s;\n}\n\n._44836ce8_chevronOpen {\n transform: rotate(180deg);\n}\n\n\n._dbaa7975_body {\n border-top: 1px solid var(--dsw-alias-border-l2);\n margin: 0 16px;\n padding-bottom: 8px;\n}\n\n._4e39cf17_readOnly {\n margin: 12px 0 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n\n\n._99bf7f3c_pending {\n flex: none;\n border-radius: 999px;\n padding: 1px 8px;\n font-size: 11px;\n line-height: 17px;\n font-weight: 500;\n white-space: nowrap;\n background: var(--dsw-alias-bg-module-platform);\n color: var(--dsw-alias-label-secondary);\n}\n\n._114985b2_footer {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n padding: 12px 0 4px;\n border-top: 1px solid var(--dsw-alias-border-l2);\n}\n\n\n._9860a5b1_notice {\n margin: 12px 0 0;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-state-business-primary);\n}\n\n\n._5006d43e_legacyNotice {\n margin: 12px 0 0;\n padding: 10px 12px;\n border-radius: 8px;\n background: var(--dsw-alias-bg-module-platform);\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-state-business-primary);\n}\n\n._21918751_error {\n margin: 12px 0 0;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-state-error-primary);\n}\n\n\n._7cea4b2f_noticeRow {\n display: flex;\n align-items: center;\n gap: 8px;\n margin: 12px 0 0;\n}\n\n._7cea4b2f_noticeRow ._21918751_error {\n flex: 1;\n min-width: 0;\n margin: 0;\n}\n\n\n._4058c747_form {\n display: flex;\n flex-direction: column;\n gap: 12px;\n padding: 12px 0 0;\n}\n\n\n._9998065c_checkboxRow {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n._02c2f6df_checkLabel {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n font-size: 14px;\n line-height: 22px;\n font-weight: 400;\n color: var(--dsw-alias-label-primary);\n cursor: pointer;\n}\n\n._f2d47237_checkLabelTitle {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n font-weight: 400;\n}\n\n._c1bed8ea_checkLabelDesc {\n font-size: 12px;\n line-height: 18px;\n font-weight: 400;\n color: var(--dsw-alias-label-tertiary);\n}\n\n\n._06389b18_checkbox {\n flex: none;\n width: 16px;\n height: 16px;\n margin: 0;\n accent-color: var(--dsw-alias-brand-primary);\n cursor: pointer;\n}\n\n._06389b18_checkbox:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n\n._94c91bfd_fieldset {\n margin: 0;\n padding: 0;\n border: none;\n display: flex;\n flex-direction: column;\n gap: 12px;\n}\n\n\n._67826267_field {\n display: flex;\n flex-direction: column;\n gap: 6px;\n margin: 0;\n padding: 0;\n border: none;\n min-width: 0;\n}\n\n._13e68c3f_fieldLabel {\n \n display: inline-flex;\n align-items: center;\n gap: 10px;\n padding: 0;\n font-size: 12px;\n line-height: 18px;\n font-weight: 500;\n color: var(--dsw-alias-label-secondary);\n}\n\n._4bc809b8_hint {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n flex-wrap: wrap;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n._579f813a_defaultNote {\n font-size: 12px;\n line-height: 18px;\n font-weight: 400;\n color: var(--dsw-alias-label-tertiary);\n}\n\n\n._a6244318_infoHint {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: none;\n width: 16px;\n height: 16px;\n border-radius: 50%;\n font-size: 14px;\n line-height: 1;\n color: var(--dsw-alias-label-tertiary);\n background: var(--dsw-alias-interactive-bg-hover);\n cursor: help;\n user-select: none;\n}\n\n._a6244318_infoHint:focus-visible {\n outline: none;\n box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);\n}\n\n\n._e655c840_infoHintDisabled {\n opacity: 0.4;\n cursor: default;\n}\n\n\n._a2521bb0_optionRow {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 6px 8px;\n border-radius: 6px;\n font-size: 14px;\n line-height: 22px;\n color: var(--dsw-alias-label-primary);\n cursor: pointer;\n}\n\n._a2521bb0_optionRow:hover:has(input:not(:disabled)) {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n\n._06389b18_checkbox,\n._a2521bb0_optionRow input,\n._929f3ef3_wildcardCell input {\n flex: none;\n width: 16px;\n height: 16px;\n margin: 0;\n accent-color: var(--dsw-alias-brand-primary);\n cursor: pointer;\n}\n\n._06389b18_checkbox:focus-visible,\n._a2521bb0_optionRow input:focus-visible,\n._929f3ef3_wildcardCell input:focus-visible {\n outline: none;\n box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);\n}\n\n._a2521bb0_optionRow input:disabled,\n._929f3ef3_wildcardCell input:disabled {\n cursor: default;\n}\n\n\n._f9d86f7b_input {\n box-sizing: border-box;\n width: 100%;\n height: 32px;\n padding: 0 10px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 8px;\n font: inherit;\n font-size: 14px;\n line-height: 22px;\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-primary);\n}\n\n._f9d86f7b_input:focus {\n outline: none;\n border-color: var(--dsw-alias-brand-primary);\n}\n\n._f9d86f7b_input::placeholder {\n color: var(--dsw-alias-label-dimmed);\n}\n\n._f9d86f7b_input:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n\n._7b60337a_inputInvalid,\n._7b60337a_inputInvalid:focus {\n border-color: var(--dsw-alias-state-error-primary);\n}\n\n\nselect._f9d86f7b_input {\n max-width: 240px;\n cursor: pointer;\n}\n\n\n._16eb7153_selectInput {\n appearance: none;\n padding-right: 32px;\n \n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");\n background-repeat: no-repeat;\n background-position: right 12px center;\n background-size: 12px 12px;\n}\n\n\n._3e95886d_numberFields {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));\n gap: 8px;\n}\n\n._0cfb5881_list {\n display: flex;\n flex-direction: column;\n gap: 8px;\n margin-top: 4px;\n}\n\n\n._f5c620d6_editorCard {\n display: flex;\n flex-direction: column;\n gap: 14px;\n padding: 14px 16px;\n border-radius: 12px;\n background: var(--dsw-alias-bg-module-platform);\n}\n\n._224b1387_cardFoot {\n display: flex;\n justify-content: flex-end;\n}\n\n._8e220e07_ruleGrid {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n._24a12a2f_ruleCell {\n display: flex;\n flex-direction: column;\n gap: 4px;\n min-width: 120px;\n flex: 1;\n}\n\n._1014c097_ruleCellLabel {\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-secondary);\n}\n\n\n._15a12620_chainSelectors {\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n._240cbbf8_selectorRow {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n\n._929f3ef3_wildcardCell {\n flex-direction: row;\n align-items: center;\n gap: 8px;\n min-width: 200px;\n align-self: center;\n padding: 6px 8px;\n border-radius: 6px;\n cursor: pointer;\n}\n\n._929f3ef3_wildcardCell:hover:has(input:not(:disabled)) {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n\n._70effa64_iconButton {\n position: relative;\n box-sizing: border-box;\n appearance: none;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 0;\n border: 0;\n border-radius: 6px;\n background: none;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n\n._70effa64_iconButton:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n._70effa64_iconButton:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-primary);\n}\n\n._70effa64_iconButton:focus-visible {\n outline: none;\n box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);\n}\n\n._70effa64_iconButton::after {\n content: attr(data-tip);\n position: absolute;\n bottom: calc(100% + 6px);\n left: 50%;\n transform: translateX(-50%);\n padding: 3px 8px;\n border-radius: 6px;\n background: var(--dsw-alias-label-primary);\n color: var(--dsw-alias-bg-layer-3);\n font-size: 11px;\n line-height: 17px;\n white-space: nowrap;\n opacity: 0;\n pointer-events: none;\n transition: opacity .12s;\n}\n\n._70effa64_iconButton:hover::after,\n._70effa64_iconButton:focus-visible::after {\n opacity: 1;\n}\n\n\n._a0968257_iconButtonDanger:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover-danger);\n color: var(--dsw-alias-state-error-primary);\n}\n\n\n._28202c90_addButton {\n align-self: flex-start;\n}\n\n\n._747fd56d_primaryButton,\n._7e855445_secondaryButton {\n box-sizing: border-box;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 4px;\n height: 36px;\n padding: 0 14px;\n border: none;\n border-radius: 18px;\n font: inherit;\n font-size: 14px;\n line-height: 22px;\n cursor: pointer;\n}\n\n._747fd56d_primaryButton {\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n\n._747fd56d_primaryButton:hover:not(:disabled) {\n background: var(--dsw-alias-button-primary-hover);\n}\n\n._7e855445_secondaryButton {\n border: 1px solid var(--dsw-alias-border-l2);\n background: transparent;\n color: var(--dsw-alias-label-primary);\n}\n\n._7e855445_secondaryButton:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover-solid);\n}\n\n._747fd56d_primaryButton:disabled,\n._7e855445_secondaryButton:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n._747fd56d_primaryButton:focus-visible,\n._7e855445_secondaryButton:focus-visible {\n outline: none;\n box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);\n}\n\n\n._fbd045e0_statusBlock {\n display: flex;\n flex-direction: column;\n gap: 12px;\n padding: 12px 14px;\n margin-top: 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 12px;\n}\n\n._d4f70367_statusTitle {\n font-size: 12px;\n line-height: 18px;\n font-weight: 500;\n color: var(--dsw-alias-label-secondary);\n}\n\n\n._8ab300ed_statusLine {\n margin: 0;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-secondary);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n._e8cedbf5_statusLineLabel {\n font-weight: 500;\n color: var(--dsw-alias-label-secondary);\n}\n\n\n._b48dfcba_offNotice {\n margin: 0;\n padding: 12px;\n border: 1px dashed var(--dsw-alias-border-l3);\n border-radius: 8px;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-tertiary);\n text-align: center;\n}\n\n\n._ec8bd970_resetDialog {\n width: min(480px, 100%);\n}\n\n._8677165c_confirmDanger:not(:disabled) {\n border-color: var(--dsw-alias-state-error-primary);\n color: var(--dsw-alias-state-error-primary);\n}\n\n._8677165c_confirmDanger:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover-danger);\n}\n\n@media (prefers-reduced-motion: reduce) {\n ._70effa64_iconButton::after {\n transition: none;\n }\n}\n";
2096
2098
  const tagId$2 = "dsh-llm-fallbacks/FallbacksCard.module.css";
2097
2099
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$2) + "]") === null) {
@@ -2188,8 +2190,9 @@ window.__ModuleLoader__.load({
2188
2190
  * `roles.rules`, whose role field is a dropdown bound to the declared ids
2189
2191
  * + the built-in `inherit`, same-page live). Saving runs `validateDraft`
2190
2192
  * first — id format/reserved word/duplicates, undeclared rule role
2191
- * references, and illegal selectors block the write with a validation
2192
- * banner + inline red borders (never touching the store error path); a
2193
+ * references, illegal selectors, and a role with no chain entries (no
2194
+ * model config) block the write with a validation banner + inline red
2195
+ * borders / hints (never touching the store error path); a
2193
2196
  * non-empty `state.legacyKeys` renders the migration banner at the top of
2194
2197
  * the card body. The row editors keep their filled editorCard surface
2195
2198
  * inside the card, with `--dsw-alias-*` tokens throughout. The reset-
@@ -2283,6 +2286,7 @@ window.__ModuleLoader__.load({
2283
2286
  message: error.message
2284
2287
  }));
2285
2288
  }
2289
+ if ((role.chain ?? []).length === 0) errors.push(t("validation.roleChainRequired", { id: role.id }));
2286
2290
  }
2287
2291
  for (const entry of draft.rootChain) try {
2288
2292
  parseSelector(entry);
@@ -3180,9 +3184,9 @@ window.__ModuleLoader__.load({
3180
3184
  })
3181
3185
  ]
3182
3186
  }),
3183
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3187
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3184
3188
  className: FallbacksCard_module_css_default.chainSelectors,
3185
- children: row.selectors.map((selector, selectorIndex) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChainSelectorEditor, {
3189
+ children: [row.selectors.map((selector, selectorIndex) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChainSelectorEditor, {
3186
3190
  selector,
3187
3191
  catalog: catalogOf(state),
3188
3192
  configuredProviders: state.configuredProviders,
@@ -3194,7 +3198,10 @@ window.__ModuleLoader__.load({
3194
3198
  onRemove: () => {
3195
3199
  removeRoleSelector(index, selectorIndex);
3196
3200
  }
3197
- }, selectorIndex))
3201
+ }, selectorIndex)), row.selectors.every((selector) => selectorRowToRaw(selector) === "") && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3202
+ className: FallbacksCard_module_css_default.hint,
3203
+ children: t("validation.roleChainRequired", { id: row.id })
3204
+ })]
3198
3205
  }),
3199
3206
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3200
3207
  className: FallbacksCard_module_css_default.ruleGrid,
@@ -3560,7 +3567,7 @@ window.__ModuleLoader__.load({
3560
3567
  });
3561
3568
  }
3562
3569
  //#endregion
3563
- //#region \0dsh-css:/Users/bibi/workspace/ai/deepseek/dsh-llm-fallbacks/src/client/GeneralFallbacksRow.module.css.mjs
3570
+ //#region \0dsh-css:/home/runner/work/dsh-llm-fallbacks/dsh-llm-fallbacks/src/client/GeneralFallbacksRow.module.css.mjs
3564
3571
  const css$1 = "\n\n._440e1d7b_row {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 16px 0;\n border-bottom: 1px solid var(--dsw-alias-border-l2);\n}\n\n._a4f2ceec_rowText {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n padding-right: 48px;\n}\n\n._9865b509_title {\n font-size: 14px;\n font-weight: 400;\n line-height: 22px;\n color: var(--dsw-alias-label-primary);\n}\n\n\n._10a44713_summary {\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-tertiary);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n\n._35648278_badge {\n flex: none;\n padding: 2px 10px;\n border-radius: 9px;\n font-size: 12px;\n line-height: 18px;\n background: var(--dsw-alias-bg-module-platform);\n color: var(--dsw-alias-label-tertiary);\n}\n\n._3b9e8609_badgeEnabled {\n color: var(--dsw-alias-state-success-primary);\n}\n";
3565
3572
  const tagId$1 = "dsh-llm-fallbacks/GeneralFallbacksRow.module.css";
3566
3573
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]") === null) {
@@ -3684,8 +3691,8 @@ window.__ModuleLoader__.load({
3684
3691
  return typeof from?.provider === "string" && typeof from?.model === "string" && typeof to?.provider === "string" && typeof to?.model === "string";
3685
3692
  }
3686
3693
  //#endregion
3687
- //#region \0dsh-css:/Users/bibi/workspace/ai/deepseek/dsh-llm-fallbacks/src/client/ConversationFallbackSwitch.module.css.mjs
3688
- const css = "\n\n._ea99bbef_switchRow {\n display: flex;\n align-items: center;\n padding: 2px 0;\n font-size: 14px;\n line-height: 24px;\n}\n\n\n._02580bfd_switchTitle {\n flex: none;\n color: var(--dsw-alias-label-primary-dimmed);\n}\n\n._88b2d18b_switchSep {\n flex: none;\n width: 2px;\n height: 2px;\n margin: 0 8px;\n border-radius: 1px;\n background: var(--dsw-alias-label-caption);\n}\n\n\n._7c8c1b6f_switchSummary {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--dsw-alias-label-tertiary);\n}\n";
3694
+ //#region \0dsh-css:/home/runner/work/dsh-llm-fallbacks/dsh-llm-fallbacks/src/client/ConversationFallbackSwitch.module.css.mjs
3695
+ const css = "\n\n._ea99bbef_switchRow {\n display: flex;\n align-items: center;\n padding: 2px 0;\n font-size: 14px;\n line-height: 24px;\n}\n\n\n._02580bfd_switchTitle {\n flex: none;\n color: var(--dsw-alias-state-warn-primary);\n}\n\n._88b2d18b_switchSep {\n flex: none;\n width: 2px;\n height: 2px;\n margin: 0 8px;\n border-radius: 1px;\n background: var(--dsw-alias-label-caption);\n}\n\n\n._7c8c1b6f_switchSummary {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--dsw-alias-label-tertiary);\n}\n";
3689
3696
  const tagId = "dsh-llm-fallbacks/ConversationFallbackSwitch.module.css";
3690
3697
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
3691
3698
  const tag = document.createElement("style");
@@ -3753,13 +3760,13 @@ window.__ModuleLoader__.load({
3753
3760
  * Render one fallback switch as a compact system-style transcript line.
3754
3761
  *
3755
3762
  * Geometry follows the upstream chat system rows (the compaction boundary
3756
- * notice: dim title + separator + ellipsized summary — `chat/MessageItem
3757
- * .module.css:38-122`); every color resolves through a `--dsw-alias-*`
3758
- * token. A reason outside the current union renders raw (forward-compatible
3763
+ * notice: warning-toned title + separator + ellipsized summary —
3764
+ * `chat/MessageItem .module.css:38-122`); every color resolves through a
3765
+ * `--dsw-alias-*` token. A reason outside the current union renders raw (forward-compatible
3759
3766
  * durable log, same rule as the card/general row summaries). A malformed or
3760
3767
  * partial payload (version skew) degrades to the title-only line instead of
3761
- * throwing during interpolation — the transcript slot stays visible with a
3762
- * truthful "a switch happened" notice and no summary details.
3768
+ * throwing during interpolation — the transcript slot stays visible with the
3769
+ * warning-toned "model downgraded" title (T1 copy) and no summary details.
3763
3770
  * @param props - composed keyed seat props.
3764
3771
  * @returns the switch line element tree.
3765
3772
  */
@@ -80,6 +80,7 @@ export declare const zh: {
80
80
  'validation.roleIdDuplicate': string;
81
81
  'validation.ruleRoleUndeclared': string;
82
82
  'validation.ruleRoleRequired': string;
83
+ 'validation.roleChainRequired': string;
83
84
  'validation.selector': string;
84
85
  'legacy.banner': string;
85
86
  'catalog.empty': string;
@@ -208,6 +209,7 @@ export declare const en: {
208
209
  'validation.roleIdDuplicate': string;
209
210
  'validation.ruleRoleUndeclared': string;
210
211
  'validation.ruleRoleRequired': string;
212
+ 'validation.roleChainRequired': string;
211
213
  'validation.selector': string;
212
214
  'legacy.banner': string;
213
215
  'catalog.empty': string;
package/dist/config.d.ts CHANGED
@@ -106,11 +106,14 @@ export interface FallbacksConfigLogger {
106
106
  /**
107
107
  * Validate a fallbacks config (pure, warn-only — never throws, never
108
108
  * mutates): role id format/uniqueness/reserved word, rule role references
109
- * (declared ids + the built-in `'inherit'`), the `fallback` enum, and
110
- * `rootChain`/role-chain selector legality. `label`/`description` are free
111
- * text and are deliberately not validated. Each violation emits one
112
- * `llm-fallbacks: ...` warn and "does not take effect" — the config stays
113
- * usable (spec §4 / AC-4 warn-not-crash semantics).
109
+ * (declared ids + the built-in `'inherit'`), the `fallback` enum,
110
+ * `rootChain`/role-chain selector legality, and the role model-config
111
+ * requirement (a declared role with a missing/empty chain warns — a role
112
+ * without a model config is meaningless, plan fallbacks-feedback-round
113
+ * T2). `label`/`description` are free text and are deliberately not
114
+ * validated. Each violation emits one `llm-fallbacks: ...` warn and "does
115
+ * not take effect" — the config stays usable (spec §4 / AC-4
116
+ * warn-not-crash semantics).
114
117
  */
115
118
  export declare function validateFallbacksConfig(config: FallbacksConfig, logger: FallbacksConfigLogger): void;
116
119
  /**
package/dist/index.d.ts CHANGED
@@ -29,15 +29,51 @@
29
29
  */
30
30
  import type { Context } from '@deepseek-ai/cordis';
31
31
  import type { Session } from '@deepseek-ai/dsh-session';
32
- import { Config, type FallbacksConfig } from './config.ts';
32
+ import { Config, detectLegacyKeys, validateFallbacksConfig, type FallbacksConfig } from './config.ts';
33
+ import { resolveChain } from './chains.ts';
34
+ import { resolveRole } from './roles.ts';
33
35
  import { FallbackStateStore } from './state.ts';
34
36
  /** The plugin row id mounted by the profile bundle patch. */
35
37
  export declare const name = "llm-fallbacks";
38
+ /**
39
+ * Declarative service metadata (cordis `Plugin.Base.provide`, read by
40
+ * loaders/tooling). The actual registration happens in `apply()` via
41
+ * `ctx.provide('llm-fallbacks', …)` — the static array never registers
42
+ * anything by itself.
43
+ */
44
+ export declare const provide: readonly ["llm-fallbacks"];
45
+ /**
46
+ * The named cordis service `ctx.get('llm-fallbacks')` exposes while the
47
+ * plugin is applied: the pure-function library surface + `name`/`version`
48
+ * metadata. Deliberately NO runtime state (no stateStore, no event
49
+ * emitters, no filter helpers) — state stays observable via `fallbacks/switch`
50
+ * events and the library API.
51
+ */
52
+ export interface FallbacksService {
53
+ /** Matches the plugin `name`. */
54
+ name: 'llm-fallbacks';
55
+ /** Package.json version string (module-load snapshot). */
56
+ version: string;
57
+ resolveRole: typeof resolveRole;
58
+ resolveChain: typeof resolveChain;
59
+ validateFallbacksConfig: typeof validateFallbacksConfig;
60
+ detectLegacyKeys: typeof detectLegacyKeys;
61
+ }
62
+ declare module '@deepseek-ai/cordis' {
63
+ interface Context {
64
+ /** The fallbacks service while the plugin is applied; `undefined` otherwise. */
65
+ 'llm-fallbacks'?: FallbacksService;
66
+ }
67
+ }
36
68
  export { Config };
37
69
  /** The plugin's composition config — the `fallbacks` settings schema (spec §4). */
38
70
  export type Config = FallbacksConfig;
39
71
  export type { FallbackSwitchReason, FallbacksSwitchEventData } from './events.ts';
40
72
  export type { AgentFallbackState, FallbackStateStore, PendingSwitch, StepFailures } from './state.ts';
73
+ export { resolveRole, type AgentLike, type Origin, } from './roles.ts';
74
+ export { annotateCandidates, createCandidateFilter, hasWildcardEntry, resolveCandidate, resolveChain, resolveChainViews, selectCandidates, type AnnotatedCandidate, type CandidateFilterOptions, type CandidateSkipReason, type FailingModel, } from './chains.ts';
75
+ export { defaultFallbacksConfig, detectLegacyKeys, INHERIT_ROLE_ID, ROLE_ID_PATTERN, validateFallbacksConfig, type FallbacksConfig, type FallbacksConfigLogger, type FallbacksRole, type FallbacksRoles, type FallbacksRoleRule, type FallbackStrategy, type RevertPolicy, } from './config.ts';
76
+ export { parseSelector, SelectorError, type Selector, } from './selectors.ts';
41
77
  /**
42
78
  * @internal Test seam (T3 review Minor 3): the per-agent fallback state store
43
79
  * of the plugin applied to `ctx` — last apply wins. Not part of the plugin's
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createRequire } from "node:module";
1
2
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
3
  import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
3
4
  //#region \0rolldown/runtime.js
@@ -1081,6 +1082,7 @@ function validateFallbacksConfig(config, logger) {
1081
1082
  } catch (error) {
1082
1083
  logger.warn(`llm-fallbacks: ignoring invalid chain entry "${entry}" in role "${role.id}": ${error.message}`);
1083
1084
  }
1085
+ if ((role.chain ?? []).length === 0) logger.warn(`llm-fallbacks: role "${role.id}" has no model config — declare at least one chain entry, or use the built-in "inherit" rule target instead`);
1084
1086
  if (role.fallback !== void 0 && role.fallback !== "inherit-root" && role.fallback !== "none") logger.warn(`llm-fallbacks: role "${role.id}" has invalid fallback "${String(role.fallback)}" — expected "inherit-root" or "none"`);
1085
1087
  }
1086
1088
  for (const entry of config.rootChain) try {
@@ -1204,6 +1206,10 @@ function selectCandidates(all, wildcard, filter, modelExists) {
1204
1206
  }
1205
1207
  return surviving;
1206
1208
  }
1209
+ function resolveChain(roles, rootChain, role, provider, model, filter, modelExists, warn = console.warn) {
1210
+ const { all, wildcard } = resolveChainViews(roles, rootChain, role, provider, model, warn);
1211
+ return selectCandidates(all, wildcard, filter, modelExists);
1212
+ }
1207
1213
  function hasWildcardEntry(roles, rootChain, role) {
1208
1214
  const entries = buildRoleEntries(roles, rootChain, role);
1209
1215
  for (const entry of entries) try {
@@ -1650,6 +1656,8 @@ function registerFallbacksCommands(registry, controller, locale = "zh") {
1650
1656
  //#endregion
1651
1657
  //#region .build/host/index.js
1652
1658
  const name = "llm-fallbacks";
1659
+ const provide = ["llm-fallbacks"];
1660
+ const { version } = createRequire(import.meta.url)("../package.json");
1653
1661
  const stateStores = new WeakMap();
1654
1662
  function stateStore(ctx) {
1655
1663
  return stateStores.get(ctx);
@@ -1697,6 +1705,19 @@ function overrideConfig(seed, to) {
1697
1705
  function apply(ctx, config = defaultFallbacksConfig) {
1698
1706
  const logger = ctx.logger("llm-fallbacks");
1699
1707
  const entry = Config(config);
1708
+ try {
1709
+ ctx.provide("llm-fallbacks", {
1710
+ name: "llm-fallbacks",
1711
+ version,
1712
+ resolveRole,
1713
+ resolveChain,
1714
+ validateFallbacksConfig,
1715
+ detectLegacyKeys
1716
+ });
1717
+ } catch (error) {
1718
+ if (!(error instanceof Error) || !error.message.includes("has been registered")) throw error;
1719
+ ctx.logger("llm-fallbacks").debug("fallbacks service already registered — no service on this fiber (multi-fiber dedupe)");
1720
+ }
1700
1721
  let source = () => entry;
1701
1722
  validateFallbacksConfig(entry, logger);
1702
1723
  const legacyKeys = detectLegacyKeys(source());
@@ -1850,4 +1871,4 @@ function apply(ctx, config = defaultFallbacksConfig) {
1850
1871
  });
1851
1872
  }
1852
1873
  //#endregion
1853
- export { Config, apply, countRetryEvents, name, stateStore };
1874
+ export { Config, INHERIT_ROLE_ID, ROLE_ID_PATTERN, SelectorError, annotateCandidates, apply, countRetryEvents, createCandidateFilter, defaultFallbacksConfig, detectLegacyKeys, hasWildcardEntry, name, parseSelector, provide, resolveCandidate, resolveChain, resolveChainViews, resolveRole, selectCandidates, stateStore, validateFallbacksConfig };
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "dsh",
6
6
  "dsh-plugin"
7
7
  ],
8
- "version": "0.1.0-alpha.1",
8
+ "version": "0.1.0-alpha.2",
9
9
  "type": "module",
10
10
  "main": "./dist/index.js",
11
11
  "types": "./dist/index.d.ts",
@@ -46,6 +46,8 @@
46
46
  "scripts": {
47
47
  "build": "tsc -p tsconfig.build.json && tsdown && pnpm run build-client && tsc && node scripts/verify-dist.mjs",
48
48
  "build-client": "tsx scripts/build-client.ts",
49
+ "release:prepare": "tsx scripts/prepare-release.ts",
50
+ "release:validate": "tsx scripts/validate-release-version.ts",
49
51
  "test": "vitest run",
50
52
  "prepare": "pnpm run build"
51
53
  },
@@ -87,5 +89,9 @@
87
89
  "tsx": "^4.22.4",
88
90
  "typescript": "^5.9.3",
89
91
  "vitest": "^4.1.8"
92
+ },
93
+ "repository": {
94
+ "type": "git",
95
+ "url": "https://github.com/omdsh-dev/dsh-llm-fallbacks.git"
90
96
  }
91
97
  }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * prepare-release.ts — resolve the next version, assemble changelog fragments,
3
+ * insert the changelog section, bump package.json, archive fragments.
4
+ *
5
+ * Usage (from the repo root, via tsx — the package.json `release:prepare`
6
+ * script):
7
+ * pnpm release:prepare -- <version> # explicit version, used as-is
8
+ * pnpm release:prepare -- --patch # auto patch bump
9
+ *
10
+ * Exactly one of the two forms is required: running with no arguments exits
11
+ * with a usage hint instead of silently auto-bumping.
12
+ *
13
+ * Version resolution:
14
+ * - Explicit version must match /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/ and
15
+ * is used as-is (prerelease suffixes allowed, e.g. 0.1.0-alpha.2).
16
+ * - `--patch` auto bump:
17
+ * * current has a numeric prerelease tail (`X.Y.Z-pre.N`) -> bump only N
18
+ * (0.1.0-alpha.1 -> 0.1.0-alpha.2; never jumps to a stable release);
19
+ * * no prerelease -> patch + 1 (0.1.0 -> 0.1.1);
20
+ * * prerelease with a non-numeric tail -> exit 1, use an explicit
21
+ * version instead.
22
+ *
23
+ * What it does:
24
+ * 1. Reads `.changes/unreleased/*.md` fragments (README.md / .gitkeep are
25
+ * skipped). Optional frontmatter `category:` groups bullets under a
26
+ * `### <category>` heading (default: `Changed`); the body is one or more
27
+ * English bullet lines, rendered verbatim.
28
+ * 2. Inserts a `## [<version>] - <date>` section into CHANGELOG.md directly
29
+ * under `## [Unreleased]`.
30
+ * 3. Bumps the `version` field in package.json.
31
+ * 4. Moves consumed fragments to `.changes/archive/<version>/`.
32
+ *
33
+ * All paths are resolved relative to the current working directory, so the
34
+ * script works both from the repo root (`pnpm release:prepare`) and from a
35
+ * throwaway fixture directory (used by the release tooling verification).
36
+ *
37
+ * This script only edits the working tree. Commit + PR is the caller's job
38
+ * (the `release-prep` workflow commits and opens the `release vX.Y.Z` PR).
39
+ */
40
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
41
+ import { join } from 'node:path'
42
+ import { pathToFileURL } from 'node:url'
43
+ import { VERSION_RE } from './release-version.ts'
44
+
45
+ const CHANGES_DIR = '.changes'
46
+ const UNRELEASED_DIR = join(CHANGES_DIR, 'unreleased')
47
+ const ARCHIVE_DIR = join(CHANGES_DIR, 'archive')
48
+
49
+ const DEFAULT_CATEGORY = 'Changed'
50
+
51
+ type Fragment = {
52
+ file: string
53
+ category?: string
54
+ bullets: string[]
55
+ }
56
+
57
+ export function parseArgs(argv: string[]): { version?: string; autoBump: boolean } {
58
+ const rest = argv.slice(2).filter((a) => a !== '--')
59
+ let version: string | undefined
60
+ let autoBump = false
61
+ for (const arg of rest) {
62
+ if (VERSION_RE.test(arg)) version = arg
63
+ else if (arg === '--patch') autoBump = true
64
+ else if (arg.startsWith('-')) throw new Error(`Unknown argument: ${arg} (expected <version> or --patch)`)
65
+ else
66
+ throw new Error(
67
+ `Invalid version "${arg}". Expected X.Y.Z or X.Y.Z-pre.N (e.g. 0.1.0-alpha.2)`,
68
+ )
69
+ }
70
+ if (version !== undefined && autoBump) {
71
+ throw new Error('Pass either an explicit <version> or --patch, not both')
72
+ }
73
+ return { version, autoBump }
74
+ }
75
+
76
+ /**
77
+ * Auto patch bump. Deliberately NOT the naive split+parseInt mstar approach
78
+ * (which turns `0.1.0-alpha.1 --patch` into stable `0.1.1`): a prerelease
79
+ * version stays in its prerelease line, bumping only the numeric tail.
80
+ */
81
+ export function autoBumpPatch(current: string): string {
82
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(current)
83
+ if (!m) throw new Error(`Cannot auto-bump "${current}": not a parseable X.Y.Z[-pre] version`)
84
+ const [, major, minor, patch, pre] = m
85
+ if (pre === undefined) return `${major}.${minor}.${Number(patch) + 1}`
86
+ const tail = pre.split('.').at(-1)
87
+ if (tail === undefined || !/^\d+$/.test(tail)) {
88
+ throw new Error(
89
+ `Cannot auto-bump "${current}": prerelease tail "${pre}" is not numeric. ` +
90
+ 'Pass an explicit version instead (e.g. `pnpm release:prepare -- <version>`).',
91
+ )
92
+ }
93
+ const prefix = pre.slice(0, pre.length - tail.length) // "alpha." for "alpha.1"
94
+ return `${major}.${minor}.${patch}-${prefix}${Number(tail) + 1}`
95
+ }
96
+
97
+ export function parseFrontmatter(text: string): { fm: Record<string, string>; body: string } {
98
+ const fm: Record<string, string> = {}
99
+ if (!text.startsWith('---')) return { fm, body: text }
100
+ const end = text.indexOf('\n---', 3)
101
+ if (end === -1) return { fm, body: text }
102
+ const fmText = text.slice(3, end)
103
+ const body = text.slice(end + 4).replace(/^\n/, '')
104
+ for (const line of fmText.split('\n')) {
105
+ const m = line.match(/^([A-Za-z0-9 _-]+):\s*(.*)$/)
106
+ if (m) fm[m[1].trim()] = m[2].trim()
107
+ }
108
+ return { fm, body }
109
+ }
110
+
111
+ export function parseFragment(file: string, text: string): Fragment {
112
+ const { fm, body } = parseFrontmatter(text)
113
+ const bullets = body
114
+ .split('\n')
115
+ .map((line) => line.trimEnd())
116
+ .filter((line) => line.trim().length > 0)
117
+ return {
118
+ file,
119
+ category: fm.category?.trim() || undefined,
120
+ bullets,
121
+ }
122
+ }
123
+
124
+ export function readFragments(): Fragment[] {
125
+ if (!existsSync(UNRELEASED_DIR)) return []
126
+ return readdirSync(UNRELEASED_DIR)
127
+ .filter((f) => f.endsWith('.md') && f !== 'README.md')
128
+ .sort()
129
+ .map((f) => parseFragment(f, readFileSync(join(UNRELEASED_DIR, f), 'utf8')))
130
+ }
131
+
132
+ /** Bullets between the version header and the next section, grouped by category. */
133
+ export function buildSectionBody(frags: Fragment[]): string {
134
+ const groups: { category: string; bullets: string[] }[] = []
135
+ for (const frag of frags) {
136
+ const category = frag.category ?? DEFAULT_CATEGORY
137
+ let group = groups.find((g) => g.category === category)
138
+ if (!group) {
139
+ group = { category, bullets: [] }
140
+ groups.push(group)
141
+ }
142
+ group.bullets.push(...frag.bullets)
143
+ }
144
+ const lines: string[] = []
145
+ for (const group of groups) {
146
+ lines.push(`### ${group.category}`, '', ...group.bullets, '')
147
+ }
148
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()
149
+ }
150
+
151
+ /** Insert `## [<version>] - <date>` directly under the `## [Unreleased]` header. */
152
+ export function insertSection(changelog: string, version: string, date: string, body: string): string {
153
+ const unreleased = changelog.indexOf('## [Unreleased]')
154
+ if (unreleased === -1) throw new Error('CHANGELOG.md has no "## [Unreleased]" header')
155
+ const afterLine = changelog.indexOf('\n', unreleased) + 1
156
+ const tail = changelog.slice(afterLine).replace(/^\n+/, '')
157
+ const section = [`## [${version}] - ${date}`]
158
+ if (body.trim()) section.push('', body)
159
+ return `${changelog.slice(0, afterLine)}\n${section.join('\n')}\n\n${tail}`
160
+ }
161
+
162
+ export function bumpPackageJson(oldVersion: string, newVersion: string): void {
163
+ const path = 'package.json'
164
+ const text = readFileSync(path, 'utf8')
165
+ const re = new RegExp(`("version"\\s*:\\s*")${oldVersion.replace(/\./g, '\\.')}(")`)
166
+ if (!re.test(text)) throw new Error(`${path}: could not find version field "${oldVersion}"`)
167
+ writeFileSync(path, text.replace(re, `$1${newVersion}$2`))
168
+ }
169
+
170
+ export function archiveFragments(version: string, frags: Fragment[]): void {
171
+ if (!frags.length) return
172
+ const dest = join(ARCHIVE_DIR, version)
173
+ mkdirSync(dest, { recursive: true })
174
+ for (const frag of frags) {
175
+ renameSync(join(UNRELEASED_DIR, frag.file), join(dest, frag.file))
176
+ }
177
+ }
178
+
179
+ export function main(): void {
180
+ const { version: explicit, autoBump } = parseArgs(process.argv)
181
+ if (explicit === undefined && !autoBump) {
182
+ console.error('Usage: pnpm release:prepare -- <version> | pnpm release:prepare -- --patch')
183
+ console.error('Pass an explicit <version> (e.g. 0.1.0-alpha.2) or --patch for an auto bump.')
184
+ process.exit(1)
185
+ }
186
+ const pkg = JSON.parse(readFileSync('package.json', 'utf8')) as { version?: string }
187
+ const current = pkg.version
188
+ if (!current) throw new Error('package.json has no version')
189
+
190
+ const version = explicit ?? autoBumpPatch(current)
191
+ if (!VERSION_RE.test(version)) {
192
+ throw new Error(`Invalid version "${version}". Expected X.Y.Z or X.Y.Z-pre.N (e.g. 0.1.0-alpha.2)`)
193
+ }
194
+ const date = new Date().toISOString().slice(0, 10)
195
+
196
+ console.log(`Preparing release ${current} -> ${version}\n`)
197
+
198
+ const frags = readFragments()
199
+ console.log(`Fragments: ${frags.length}`)
200
+ for (const frag of frags) {
201
+ console.log(` - ${frag.file}${frag.category ? ` [category: ${frag.category}]` : ''}`)
202
+ }
203
+ if (!frags.length) console.log(' (no fragments — section will be empty)')
204
+
205
+ const body = buildSectionBody(frags)
206
+ const changelogPath = 'CHANGELOG.md'
207
+ const changelog = readFileSync(changelogPath, 'utf8')
208
+ writeFileSync(changelogPath, insertSection(changelog, version, date, body))
209
+ console.log(`changelog: ${changelogPath} (## [${version}] - ${date})`)
210
+
211
+ bumpPackageJson(current, version)
212
+ console.log('bump: package.json')
213
+
214
+ archiveFragments(version, frags)
215
+ console.log(`archive: ${frags.length} fragment(s) -> ${join(ARCHIVE_DIR, version)}`)
216
+
217
+ console.log(`\nDone. Next: commit and open PR "release v${version}".`)
218
+ console.log(`Validate with: pnpm release:validate -- v${version}`)
219
+ }
220
+
221
+ // Run only when executed directly (tsx scripts/prepare-release.ts) —
222
+ // importing the module (verification fixtures) must not start the release flow.
223
+ const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : ''
224
+ if (import.meta.url === entry) {
225
+ try {
226
+ main()
227
+ } catch (err) {
228
+ console.error(`\nprepare-release failed: ${err instanceof Error ? err.message : err}`)
229
+ process.exit(1)
230
+ }
231
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * release-version.ts — shared release-version constants for the release
3
+ * scripts (prepare-release.ts and validate-release-version.ts). Single home
4
+ * for the semver contract so the two scripts cannot drift apart.
5
+ */
6
+
7
+ /** Architect-approved loose semver: numeric core, optional prerelease suffix. */
8
+ export const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
@@ -0,0 +1,118 @@
1
+ /**
2
+ * validate-release-version.ts — gate: the package version matches the release
3
+ * tag and the tag is not already released.
4
+ *
5
+ * Usage (from the repo root, via tsx — the package.json `release:validate`
6
+ * script):
7
+ * pnpm release:validate -- v<version> # e.g. v0.1.0-alpha.2
8
+ *
9
+ * Checks (single-package repo — one version surface):
10
+ * 1. Tag format: `v` prefix + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.
11
+ * 2. package.json `version` equals the tag version (v prefix stripped).
12
+ * 3. Git tag `v<version>` does not exist yet (`git rev-parse` probe; exit 1
13
+ * with "already released" when it does). Outside a git repository the
14
+ * tag check is skipped with a note (fixture verification scenario).
15
+ *
16
+ * All paths are resolved relative to the current working directory, so the
17
+ * script works both from the repo root (`pnpm release:validate`) and from a
18
+ * throwaway fixture directory.
19
+ *
20
+ * The pure validation logic is exported (`validateReleaseVersion`,
21
+ * `tagExists`) so the committed vitest suite can cover the positive and
22
+ * negative cases; `main()` only runs when the file is executed directly.
23
+ */
24
+ import { execFileSync } from 'node:child_process'
25
+ import { readFileSync } from 'node:fs'
26
+ import { pathToFileURL } from 'node:url'
27
+ import { VERSION_RE } from './release-version.ts'
28
+
29
+ /**
30
+ * @returns true when the tag exists, false when it does not, null when the
31
+ * directory is not inside a git work tree.
32
+ */
33
+ export function tagExists(version: string, cwd = process.cwd()): boolean | null {
34
+ try {
35
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { stdio: 'ignore', cwd })
36
+ } catch {
37
+ return null
38
+ }
39
+ try {
40
+ execFileSync('git', ['rev-parse', '--verify', '--quiet', `refs/tags/v${version}`], { stdio: 'ignore', cwd })
41
+ return true
42
+ } catch {
43
+ return false
44
+ }
45
+ }
46
+
47
+ export type ValidationResult = {
48
+ ok: boolean
49
+ errors: string[]
50
+ notes: string[]
51
+ }
52
+
53
+ /**
54
+ * Pure gate used by `main()` and by the test suite: the version matches
55
+ * package.json and the git tag is free. `tagState` is the result of
56
+ * `tagExists` (true / false / null when not a git repo).
57
+ */
58
+ export function validateReleaseVersion(
59
+ version: string,
60
+ pkgVersion: string | undefined,
61
+ tagState: boolean | null,
62
+ ): ValidationResult {
63
+ const errors: string[] = []
64
+ const notes: string[] = []
65
+
66
+ if (pkgVersion !== version) {
67
+ errors.push(`MISMATCH package.json: tag v${version} => ${version}, package.json has ${pkgVersion ?? '<missing>'}`)
68
+ } else {
69
+ notes.push(`OK package.json: ${pkgVersion}`)
70
+ }
71
+
72
+ if (tagState === null) {
73
+ notes.push('note: not a git repository — skipping tag-exists check')
74
+ } else if (tagState) {
75
+ errors.push(`FAIL already released: git tag v${version} already exists`)
76
+ } else {
77
+ notes.push(`OK git tag v${version} does not exist`)
78
+ }
79
+
80
+ return { ok: errors.length === 0, errors, notes }
81
+ }
82
+
83
+ function main(): void {
84
+ // Strip a leading `--` (pnpm forwards `pnpm release:validate -- vX.Y.Z`
85
+ // as argv [`tsx`, `scripts/validate-release-version.ts`, `--`, `vX.Y.Z`])
86
+ // so direct `tsx scripts/validate-release-version.ts -- vX.Y.Z` matches the
87
+ // prepare-release argument handling.
88
+ const tag = process.argv.slice(2).find((a) => a !== '--')
89
+ if (!tag) {
90
+ console.error('Usage: tsx scripts/validate-release-version.ts v<version>')
91
+ console.error('Example: pnpm release:validate -- v0.1.0-alpha.2')
92
+ process.exit(1)
93
+ }
94
+
95
+ const version = tag.startsWith('v') ? tag.slice(1) : tag
96
+ if (!VERSION_RE.test(version)) {
97
+ console.error(`Invalid release tag "${tag}". Expected vX.Y.Z or vX.Y.Z-pre.N (e.g. v0.1.0-alpha.2).`)
98
+ process.exit(1)
99
+ }
100
+
101
+ const pkg = JSON.parse(readFileSync('package.json', 'utf8')) as { version?: string }
102
+ const result = validateReleaseVersion(version, pkg.version, tagExists(version))
103
+
104
+ for (const note of result.notes) console.log(note)
105
+ for (const error of result.errors) console.error(error)
106
+ if (!result.ok) {
107
+ console.error(`\nRelease tag ${tag} failed validation.`)
108
+ process.exit(1)
109
+ }
110
+ console.log(`\nAll checks passed for ${tag}.`)
111
+ }
112
+
113
+ // Run only when executed directly (tsx scripts/validate-release-version.ts) —
114
+ // importing the module (the committed test suite) must not run the gate.
115
+ const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : ''
116
+ if (import.meta.url === entry) {
117
+ main()
118
+ }