@shgroup/dsh-serenity-hooks 1.49.0 → 1.50.1

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/dsh.plugin.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "id": "dsh-serenity-hooks",
3
- "version": "1.49.0",
3
+ "version": "1.50.1",
4
4
  "main": "lib/index.js",
5
- "description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag;后两个按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 轨迹日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.5-rc.2(deepseek-ai/deepseek-harness)。",
5
+ "description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag;后两个按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 轨迹日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.7-rc.1(deepseek-ai/deepseek-harness)。",
6
6
  "engines": {
7
- "dsh": ">=0.1.5-rc.2"
7
+ "dsh": ">=0.1.7-rc.1"
8
8
  },
9
9
  "contributes": {
10
10
  "tools": [
@@ -82,7 +82,7 @@ export declare function buildRoundPrompt(opts: {
82
82
  * 使用 handyman 前必须先加载 eap 设计方案;并行策略(jobs 编排);提示词规范(详尽固定 EAP);
83
83
  * 阅读/文字编写类 handyman 内部也加载 eap。
84
84
  */
85
- export declare const HANDYMAN_GUIDE = "# handyman \u2014 Scale-Up Usage Guide (guide)\n\n## Two modes (v1.31.3; default = foreground)\n| | foreground (default) | background |\n|---|---|---|\n| What | one serial child agent runs the task ONCE and returns its final text | loop-validated worker: hard while-loop + random completion code |\n| Use when | you need exactly one result back now (per-case testers, single conversions) | long/unattended work needing anti-early-finish, resumability, or parallel jobs |\n| Loop / validation | none | stop-token is the ONLY completion condition; round cap (default 100); auto-restart (\u2264100) |\n| Progress file | none | AGENT_SESSIONS/handyman-<label>.md/.json (same label resumes) |\n| Parallel jobs | no (call once per task) | yes (jobs=[...], handyman.maxParallel default 10) |\n| Mechanism | host delegation service ctx.subagents.start(\"spawn\") + agentOptions | ctx.agents.create + internal while-loop |\n| Model source | CCC handyman.models whitelist (same for both) | same |\n\n## \u26A0\uFE0F Before using: load eap and design the plan\nBefore calling handyman, load eap (acc-eap skill) and design the \"scale-up handyman plan\" based on the EAP framework.\n\n### 1. Task decomposition (E\u2191 Explicit)\n- Split large tasks into explicit subtasks: each subtask defines goal / input / boundaries (what to do, what not to do) / acceptance criteria\n- Make dependencies explicit: dependent tasks run serially, independent ones can run in parallel (background mode)\n\n### 2. Prompt design (handyman's task parameter)\n- task must be detailed, fixed, and EAP-compliant: clear goal, drawn boundaries, decidable acceptance criteria\n- foreground mode: the child does NOT share your conversation \u2014 restate everything it needs in task\n- Anti-example \"handle this file\" \u2014 ambiguous; good example \"read <path>, extract all rows of the\u300C\u5173\u952E\u51B3\u7B56\u300Dtable,\n output a JSON array (fields id/conclusion/evidence), do not modify the original file\"\n- Reading/curating or text-writing work (extracting from files, summarizing, writing docs, generating text, etc.):\n the handyman-internal agent is also required to load eap and organize output per the EAP standard\n\n### 3. Model whitelist (CCC-configured, mandatory)\n- handyman only uses models listed in .opencode/serenity.json \"handyman.models\" \u2014 never arbitrary models\n- The CCC is expected to point this whitelist at LOW-COST models: handyman is the bulk-execution channel\n- background mode: recursive subagents inside a worker inherit the worker's model automatically (DSH native)\n- foreground mode: the child is started through the host delegation service with the resolved model\n\n### 3b. \uD83D\uDD34 Provider availability (per-machine \u2014 the whitelist is NOT enough)\n- The whitelist lives in the **CCC** (\"serenity.json\", it **travels with the repository**), but provider\n **adapters are registered per machine by the host** (profile config / settings namespace). An entry that\n works on one machine can fail on another with: \"no adapter registered for provider <p>\".\n- Before spawning, handyman verifies the provider of the resolved model against the adapters registered\n **on this machine**; if it is missing it reports the requested model, where it is configured, **the\n providers that ARE available here**, any declared-but-dormant route (with its settings namespace), and the\n exact fix \u2014 foreground puts this in \"diagnostic\", background throws it.\n- \u21D2 If you see \"has no adapter registered on this machine\": edit \".opencode/serenity.json\" \"handyman.models\"\n (and \"handyman.defaultModel\") to a provider listed as available, or activate/register the provider on this\n machine. **Do not retry the same call unchanged** \u2014 it fails identically.\n\n### 4. Parallel strategy (background mode only)\n- Independent subtasks can run in parallel via handyman(mode=\"background\", jobs=[...]): each job gets its own label + task + stop token + progress file\n- Concurrency safety guaranteed: unique sessionId (handyman-<label>-<uuid>), progress files isolated per label\n (AGENT_SESSIONS/handyman-<label>.json) \u2014 same label resumes, different labels never interfere\n- Parallel cap: handyman.maxParallel (default 10 \u2014 cheap models are cheap)\n- Aggregation: after each parallel job produces progress, the main agent merges (or spawns one aggregation handyman)\n- For programmable pipeline/phase orchestration at scale, use the platform's workflow tool instead\n\n## Completion criteria\n- foreground: the child's run settles; done=true only when stopReason=\"completed\" (otherwise read diagnostic)\n- background (osp loop standard): the only completion condition = the worker echoes this round's random verification\n code (stop token); dialogue round cap (default 100, osp fail-safe, forced stop beyond the cap, resumable);\n automatic restart on abnormal agent stop (\u2264100 restarts, anti-infinite-loop)\n\n## Waiting UI\n- The WebUI session-header Serenity detail card shows running background handymen's progress\n (label / round / last response), one line per parallel job, ~3s refresh (foreground mode writes no progress)\n";
85
+ export declare const HANDYMAN_GUIDE = "# handyman \u2014 Scale-Up Usage Guide (guide)\n\n## Two modes (v1.31.3; default = foreground)\n| | foreground (default) | background |\n|---|---|---|\n| What | child agent(s) run the task ONCE and return their final text (jobs=[...] \u21D2 parallel, cap 5) | loop-validated worker: hard while-loop + random completion code |\n| Use when | you need result(s) back now (per-case testers, single conversions, parallel fan-out) | long/unattended work needing anti-early-finish, resumability, or parallel jobs |\n| Loop / validation | none | stop-token is the ONLY completion condition; round cap (default 100); auto-restart (\u2264100) |\n| Progress file | none | AGENT_SESSIONS/handyman-<label>.md/.json (same label resumes) |\n| Parallel jobs | **yes** \u2014 jobs=[{task,label?,model?},...], **cap 5 (fixed)** | yes (jobs=[...], handyman.maxParallel default 10) |\n| Mechanism | host delegation service ctx.subagents.start(\"spawn\") + agentOptions | ctx.agents.create + internal while-loop |\n| Model source | CCC handyman.models whitelist (same for both) | same |\n\n## \u26A0\uFE0F Before using: load eap and design the plan\nBefore calling handyman, load eap (acc-eap skill) and design the \"scale-up handyman plan\" based on the EAP framework.\n\n### 1. Task decomposition (E\u2191 Explicit)\n- Split large tasks into explicit subtasks: each subtask defines goal / input / boundaries (what to do, what not to do) / acceptance criteria\n- Make dependencies explicit: dependent tasks run serially, independent ones can run in parallel (both modes)\n\n### 2. Prompt design (handyman's task parameter)\n- task must be detailed, fixed, and EAP-compliant: clear goal, drawn boundaries, decidable acceptance criteria\n- foreground mode: the child does NOT share your conversation \u2014 restate everything it needs in task\n- Anti-example \"handle this file\" \u2014 ambiguous; good example \"read <path>, extract all rows of the\u300C\u5173\u952E\u51B3\u7B56\u300Dtable,\n output a JSON array (fields id/conclusion/evidence), do not modify the original file\"\n- Reading/curating or text-writing work (extracting from files, summarizing, writing docs, generating text, etc.):\n the handyman-internal agent is also required to load eap and organize output per the EAP standard\n\n### 3. Model whitelist (CCC-configured, mandatory)\n- handyman only uses models listed in .opencode/serenity.json \"handyman.models\" \u2014 never arbitrary models\n- The CCC is expected to point this whitelist at LOW-COST models: handyman is the bulk-execution channel\n- background mode: recursive subagents inside a worker inherit the worker's model automatically (DSH native)\n- foreground mode: the child is started through the host delegation service with the resolved model\n\n### 3b. \uD83D\uDD34 Provider availability (per-machine \u2014 the whitelist is NOT enough)\n- The whitelist lives in the **CCC** (\"serenity.json\", it **travels with the repository**), but provider\n **adapters are registered per machine by the host** (profile config / settings namespace). An entry that\n works on one machine can fail on another with: \"no adapter registered for provider <p>\".\n- Before spawning, handyman verifies the provider of the resolved model against the adapters registered\n **on this machine**; if it is missing it reports the requested model, where it is configured, **the\n providers that ARE available here**, any declared-but-dormant route (with its settings namespace), and the\n exact fix \u2014 foreground puts this in \"diagnostic\", background throws it.\n- \u21D2 If you see \"has no adapter registered on this machine\": edit \".opencode/serenity.json\" \"handyman.models\"\n (and \"handyman.defaultModel\") to a provider listed as available, or activate/register the provider on this\n machine. **Do not retry the same call unchanged** \u2014 it fails identically.\n\n### 4. Parallel strategy (both modes)\n- Independent subtasks can run in parallel:\n \u00B7 **foreground** \u2014 handyman(jobs=[{task,label?,model?},...]) \u21D2 fan-out of custom-model subagents, **cap 5**,\n results come back **in the order you passed them** (label is only a display name here; per-job \"model\" allowed)\n \u00B7 **background** \u2014 handyman(mode=\"background\", jobs=[...]): each job gets its own label + task + stop token + progress file\n- Concurrency safety guaranteed: unique sessionId (handyman-<label>-<uuid>), progress files isolated per label\n (AGENT_SESSIONS/handyman-<label>.json) \u2014 same label resumes, different labels never interfere\n- Parallel cap: **foreground 5 (fixed)** / background handyman.maxParallel (default 10 \u2014 cheap models are cheap)\n- Aggregation: foreground returns all outputs in one result; background: after each parallel job produces progress, the main agent merges (or spawns one aggregation handyman)\n- For programmable pipeline/phase orchestration at scale, use the platform's workflow tool instead\n\n## Completion criteria\n- foreground: the child's run settles; done=true only when stopReason=\"completed\" (otherwise read diagnostic)\n- background (osp loop standard): the only completion condition = the worker echoes this round's random verification\n code (stop token); dialogue round cap (default 100, osp fail-safe, forced stop beyond the cap, resumable);\n automatic restart on abnormal agent stop (\u2264100 restarts, anti-infinite-loop)\n\n## Waiting UI\n- The WebUI session-header Serenity detail card shows running background handymen's progress\n (label / round / last response), one line per parallel job, ~3s refresh (foreground mode writes no progress)\n";
86
86
  /** handyman 运行状态(进度文件摘要;WebUI 等待界面数据源) */
87
87
  interface HandymanRunInfo {
88
88
  label: string;
@@ -61,8 +61,40 @@ type HostEventName = (typeof HOST_EVENT_NAMES)[number];
61
61
  * dsp 订阅的宿主事件清单(与 HOST_EVENT_NAMES 同源;impact/site 供 health 报告与文档)。
62
62
  */
63
63
  export declare const HOST_EVENTS: readonly HostEventContract[];
64
- /** dsp 被验证过的宿主版本范围(与 package.json peerDependencies 同源,单一真相源) */
65
- export declare const REQUIRED_HOST_RANGE = "^0.1.7-rc.1";
64
+ /**
65
+ * dsp **被验证过的宿主版本档**(单一真相源)—— 每档 = 一个半开区间 `[floor, ceiling)`。
66
+ *
67
+ * 🔴 为什么是"档"而不是一条 caret 范围(R↓:2026-09-28 0.2.0-rc.1 适配轮实测得出):
68
+ * - **宿主门的判据是真 semver**(`@deepseek-ai/dsh-app-boot:evaluatePluginCompatibility`
69
+ * = `semver.satisfies(runtime, range, { includePrerelease: true })`)⇒ **它支持 `||` 联合范围**;
70
+ * - 而 **dsp 这边是手写比较器**({@link compareSemver}),**不实现 semver 的 range 语义**:
71
+ * 旧写法"用正则从 `REQUIRED_HOST_RANGE` 剥前缀取 floor"在**联合范围上会取到垃圾**
72
+ * (`^0.1.7-rc.1 || ^0.2.0-rc.1` 的预发布捕获组 `(.*)` 会**吞掉 `||` 之后整段**)
73
+ * ⇒ 判定**变瞎**(既非"通过"也非"低于下限",而是不可解释的值)。
74
+ * ⇒ 故**档表是唯一被验证的真相**:`REQUIRED_HOST_RANGE`(给宿主门看的串)与
75
+ * `checkHostVersion`(给自己的手写比较器)**都从它派生**,两者不可能互相漂移。
76
+ *
77
+ * 🔴 两档的由来(**实测,非推断**;判据表见插件仓 `docs/dsh-0.2.0-rc.1-adaptation-plan.md` §7.4):
78
+ * - `^0.1.7-rc.1` 在 `0.2.0-rc.1` 上被宿主**整包拒绝 + 回滚**
79
+ * (逐字 `installation rejected: … incompatible with dsh 0.2.0-rc.1` + `restored package.json, …`);
80
+ * - 单档 `^0.2.0-rc.1` 会**同样硬拒本机正在跑的 `0.1.7-rc.2`**
81
+ * ⇒ 发布后 deploy 必须先升宿主,**而升级会打断本容器**;
82
+ * - ⇒ 双档是**唯一不把本机宿主堵死的写法**(两档在真 semver 下均 PASS)。
83
+ * 档上界取 `<0.2.0` / `<0.3.0`(即 `^0.2.0-rc.1` 的 caret 上界):第二档同时覆盖
84
+ * `0.2.0` 正式版与后续 `0.2.0-rc.N`。
85
+ */
86
+ export declare const VERIFIED_HOST_BANDS: readonly {
87
+ floor: string;
88
+ ceiling: string;
89
+ }[];
90
+ /**
91
+ * 与 `package.json` 的 `peerDependencies` **逐字同串**的宿主版本范围(由 {@link VERIFIED_HOST_BANDS} 派生)。
92
+ *
93
+ * 🔴 这个字符串**就是**宿主门(`evaluatePluginCompatibility`)拿去跑 `semver.satisfies` 的那个串
94
+ * ⇒ 它与档表**必须一致**("声明 union、判定单档"是一种自相矛盾)。
95
+ * 一致性由 `tests/host-contract.test.ts` 的**跨文件对账用例**钉住(读 package.json 逐字比对)。
96
+ */
97
+ export declare const REQUIRED_HOST_RANGE: string;
66
98
  interface HostContractIssue {
67
99
  id: string;
68
100
  kind: 'service' | 'member' | 'version';
@@ -80,13 +112,14 @@ export interface HostContractReport {
80
112
  /** 最小 semver 比较(无依赖):返回 -1 / 0 / 1;解析失败返回 null */
81
113
  export declare function compareSemver(a: string, b: string): number | null;
82
114
  /**
83
- * 宿主版本是否落在 dsp 被验证的范围(`REQUIRED_HOST_RANGE`)。
115
+ * 宿主版本是否落在 dsp **被验证过的档**({@link VERIFIED_HOST_BANDS})之内。
84
116
  * 无版本信息 → 视为未知(ok=false,detail 说明),不猜测。
85
117
  *
86
- * floor/ceiling **从 `REQUIRED_HOST_RANGE` 派生**(v1.31.6,0.1.5-rc.1 适配轮):
87
- * 此前 floor 是独立硬编码字面量——改 `REQUIRED_HOST_RANGE` 而忘改 floor 会造出
88
- * "常量说 0.1.5、判定用 0.1.2" 的双真相源。ceiling 无法从 caret 语义机械派生(手写比较器
89
- * 不实现 semver 的 caret 规则),故保留显式常量并在此声明其含义。
118
+ * 🔴 **逐档判**(v1.50.x,0.2.0-rc.1 适配轮):判据 = "∃ 档:`version >= floor ∧ version < ceiling`"。
119
+ * 曾经的做法是"从 `REQUIRED_HOST_RANGE` 剥前缀取一个 floor + 一个显式 ceiling"——
120
+ * **单档假设一旦不成立(改联合范围),那个正则就会取到垃圾**(详见 {@link VERIFIED_HOST_BANDS}
121
+ * 的注释)⇒ 故本函数**只读档表**,不再从范围串反推。
122
+ * ⚠️ 手写比较器**不实现 semver 的 range 语义**(本条只做"区间内/外"),这也是必须逐档判的原因。
90
123
  */
91
124
  export declare function checkHostVersion(version: string | null): {
92
125
  ok: boolean;
@@ -0,0 +1,218 @@
1
+ /**
2
+ * human-channel.ts — 人机信道的**机制层**(机器级;2026-09-26 owner 定案 ⇒ 方案 §2.6)
3
+ *
4
+ * ## 为什么单独一个模块(方案 §5-12)
5
+ * 今天"机制"与"渠道实现"混在 `weixin-bridge.ts` 里。P0-2 要立的这条不变量是**机器级**的 ——
6
+ * 它**跨 CCC**、且与"微信"无关(飞书桥/邮件桥同样要守)⇒ 抽到这里;`weixin-bridge` 只做接线。
7
+ *
8
+ * ## 本模块唯一的不变量
9
+ * 🔴 **一账号一 poller**:同一个**账号**(= 同一个 bot 身份)在**同一时刻只有一个轮询者**。
10
+ * 今天的形态是"**按 CCC 起桥**"(`for entry of listCccs ⇒ syncCccBridge`)⇒ 若两个 CCC 各自
11
+ * 配了**同一个**微信账号(同一份凭据),就会**两处轮询同一条消息** ⇒ 重复投递 + 重复落盘
12
+ * (方案 §3.6 的"顺带解决"就是这一条)。
13
+ *
14
+ * ## 🔴 账号的"身份"是什么(本模块最容易写错的一处)
15
+ * **不是 `accountId`** —— 它是**每 CCC 的本地标签**(今天两处都叫 `wechat-1` 完全可能是两个
16
+ * 不同的微信号)。跨 CCC 能辨认同一个 bot 的**唯一**依据是**凭据内容**(token)。
17
+ * ⇒ `accountIdentity()` 以 **token 的哈希**为身份;**没有 token** 时才退回
18
+ * `ccc:<root>|<accountId>`(那时身份里带了 root ⇒ 跨 CCC 不可能撞车,也就不会误判成"同一账号")。
19
+ *
20
+ * ## 边界
21
+ * 本模块做三件事(**都属机制**,与"哪个渠道"无关):
22
+ * ① **归属判定**(P0-2:谁能起 poller、谁被谁占着)—— 见下 §归属判定
23
+ * ② **订阅表查询 + 入站路由决策**(P0-3:`(账号, 用户) → (CCC, 角色)`)—— 见 §订阅表
24
+ * ③ **机器级留痕**(P0-3:未投递 / 目标不存在 / 表残缺)—— 见 §留痕。
25
+ * 🔴 为什么这些必须机器级:它们**没有目标 CCC** ⇒ 落进任何 CCC 都是"编一个"目标。
26
+ * **不碰**:轮询循环、凭据解析、会话投递 —— 那些是**渠道实现**,留在 `weixin-bridge.ts`。
27
+ * ⇒ 本模块**零 DSH 依赖**(只有 `node:crypto` / `node:fs`),可独立单测。
28
+ */
29
+ import type { HumanChannelSubscriptionSettings } from './settings-section.js';
30
+ /** 一个"想为该账号起 poller"的请求(来自某 CCC) */
31
+ export interface PollerRequest {
32
+ /** 发起方 CCC 根(绝对路径) */
33
+ root: string;
34
+ /** 该 CCC 本地账号键 */
35
+ accountId: string;
36
+ /** 账号的**机器级身份**(由 `accountIdentity()` 算得) */
37
+ identity: string;
38
+ }
39
+ /** 账号当前的拥有者 */
40
+ export interface AccountOwner {
41
+ root: string;
42
+ accountId: string;
43
+ }
44
+ /** 已归属表(identity → 拥有者);只读视图,供判定用 */
45
+ export type AccountOwners = ReadonlyMap<string, AccountOwner>;
46
+ export interface PollerClaimPlan {
47
+ /** 可以起 poller 的请求(去重后) */
48
+ claims: PollerRequest[];
49
+ /** 被跳过者 + **谁占着**(供响亮登记:被跳过不能是静默的) */
50
+ skipped: Array<{
51
+ request: PollerRequest;
52
+ ownedBy: AccountOwner;
53
+ }>;
54
+ }
55
+ /**
56
+ * 算账号的**机器级身份**。
57
+ *
58
+ * - 有 token ⇒ `token:<sha256 前 16 位>`(**跨 CCC 可辨同一 bot**;不落原文,日志/状态面可安全展示)
59
+ * - 无 token(未扫码绑定等)⇒ `ccc:<root>|<accountId>`(**退化为每 CCC 唯一** ⇒ 不会误判撞车)
60
+ * @param root CCC 根
61
+ * @param accountId 该 CCC 本地账号键
62
+ * @param token 凭据 token(可缺省)
63
+ */
64
+ export declare function accountIdentity(root: string, accountId: string, token: string | undefined): string;
65
+ /**
66
+ * 归属判定(**纯函数**:不改任何状态)。
67
+ *
68
+ * 规则三条(**同批**与**跨批**刻意用不同判据,别合并——合并会放过"同一 CCC 里两个本地键绑同一个 bot"):
69
+ * ① **同批**内同身份重复 ⇒ **一律跳过**(后者)—— 与是哪个 CCC 无关。实例:某 CCC 的账号表里
70
+ * `wechat-1` 与 `wechat-2` 绑的是同一个 bot ⇒ 若豁免"自己",它就会为同一个 bot 起**两个**循环。
71
+ * ② 身份已被**别的** CCC 占着 ⇒ 跳过(记 `ownedBy`)—— 这就是"一账号一 poller"的主判据。
72
+ * ③ 身份被**自己**占着 ⇒ **照常 claim**(热重建语义:`syncCccBridge` 会先释放自己的旧归属再重新
73
+ * claim;把"自己占着"也判成冲突会让**配置一变就再也起不来**)。
74
+ * @param requests 本批请求(顺序即优先级:先到先得)
75
+ * @param owners 当前归属表(`pollerOwners()`)
76
+ */
77
+ export declare function planPollerClaims(requests: readonly PollerRequest[], owners: AccountOwners): PollerClaimPlan;
78
+ /** 当前归属表(只读视图;供判定与状态面用) */
79
+ export declare function pollerOwners(): AccountOwners;
80
+ /** 落实一批 claim(调用方在**真正起了 poller 之后**调它) */
81
+ export declare function commitPollerClaims(plan: PollerClaimPlan): void;
82
+ /**
83
+ * 释放某 CCC 的**全部**归属(热重建/停止时先调它,再重新 claim)。
84
+ *
85
+ * 🔴 为什么"先全放再 claim"是安全的:`syncCccBridge` 内这一段是**同步**的(中间没有 await)
86
+ * ⇒ 不存在"刚放开就被别人抢走"的窗口;且**别人**在这次调用里本来就抢不到(它的请求会被
87
+ * 判成"被 <本 CCC> 占着"而跳过)⇒ 归属**粘在本 CCC**,不会在两个 CCC 之间来回漂。
88
+ * @param root 要释放的 CCC 根
89
+ * @returns 被释放的身份数(日志/判据用)
90
+ */
91
+ export declare function releasePollersOf(root: string): number;
92
+ /** 清空归属表(插件 dispose) */
93
+ export declare function releaseAllPollers(): void;
94
+ /** 测试专用:复位进程级归属表(生产零调用) */
95
+ export declare function __resetPollersForTest(): void;
96
+ /** 订阅表里**完好**的一行(四个字段全部非空) */
97
+ export interface SubscriptionHit {
98
+ account: string;
99
+ user: string;
100
+ ccc: string;
101
+ role: string;
102
+ }
103
+ /**
104
+ * 订阅表里的**残缺行**(缺字段)。
105
+ * 🔴 它**不是**"未命中"(见上);且**与是否命中无关** —— 表里只要有残缺行就要出声,
106
+ * 否则"命中了一条完好的"会让人以为整张表是干净的。
107
+ */
108
+ export interface SubscriptionDefect {
109
+ /** 行序(0 基)—— 报错要指得出是**哪一行** */
110
+ index: number;
111
+ /** 缺哪些字段(`account` / `user` / `ccc` / `role`) */
112
+ missing: string[];
113
+ /** 行里**已有**的字段(残缺行的可见部分:让人认得出是哪一行) */
114
+ present: Record<string, string>;
115
+ }
116
+ export interface SubscriptionLookup {
117
+ /** 命中的行(未命中 ⇒ `null`) */
118
+ hit: SubscriptionHit | null;
119
+ /** 表里残缺的行(**与是否命中无关**,一律要出声) */
120
+ defects: SubscriptionDefect[];
121
+ /** 表里**完好**的行数(供调用方区分"表是空的"与"表里没有你") */
122
+ usable: number;
123
+ }
124
+ /**
125
+ * 订阅表查询(**纯函数**)。
126
+ *
127
+ * 匹配两段式(**刻意对齐**旧 `matchWeixinRoute` 的 exact → `*` 兜底):
128
+ * ① `account` **精确** ∧ `user` **精确**
129
+ * ② `account` **精确** ∧ `user === '*'`(通配兜底)
130
+ * 🔴 `account` **不设通配**:它是机器级账号的**显式身份**(表里写的就是账号 id);
131
+ * 给账号也开 `*` 会让"这条订阅到底覆盖哪个 bot"变得不可读。
132
+ * 🔴 `user` 保留 `*`:这是**有意的**——存量 CCC 级 `routes` 用的就是 `{user:'*'}`,
133
+ * 迁移(P0-4)要能**逐字平移**它,否则"迁移"会悄悄改变行为。
134
+ *
135
+ * @param subscriptions 机器级订阅表(`readSimpleSettings().humanChannel.subscriptions`)
136
+ * @param account 收到的**账号 id**(poller 身份;不是 token)
137
+ * @param user 发信人 id
138
+ */
139
+ export declare function lookupSubscription(subscriptions: readonly HumanChannelSubscriptionSettings[], account: string, user: string): SubscriptionLookup;
140
+ /** 入站投递目标**从哪来**(判据用;也是"这条消息为什么被投到这里"的可重建依据) */
141
+ export type InboundRouteSource = 'machine' | 'ccc-fallback';
142
+ /** 未投递的原因(两档,措辞刻意不同 —— 见 `planInboundRoute`) */
143
+ export type InboundSkipReason = 'no-subscription' | 'no-match';
144
+ export interface InboundRoutePlan {
145
+ /** 投递目标(`null` ⇒ **不投递**) */
146
+ target: {
147
+ ccc: string;
148
+ role: string;
149
+ } | null;
150
+ /** 目标出处(`null` ⇔ `target === null`) */
151
+ source: InboundRouteSource | null;
152
+ /** 未投递的原因(`target !== null` ⇒ `null`) */
153
+ skipReason: InboundSkipReason | null;
154
+ /** 订阅表里残缺的行(**与是否命中无关**,一律要出声) */
155
+ defects: SubscriptionDefect[];
156
+ }
157
+ /**
158
+ * 入站路由决策(**纯函数**)。
159
+ *
160
+ * 顺序:**机器级订阅表优先** → 未命中时用 CCC 级回退。
161
+ *
162
+ * 🔴 **回退是过渡期的"不打断在用的微信"**(方案 §11.3 **Q5**):机器级账号表为空时,
163
+ * 桥仍按 CCC 起、路由也仍认 CCC 级 `routes`。机器级一旦配起来,回退传 `null`
164
+ * ⇒ **未命中 = 不投递**(不是"投给默认角色"——那正是方案 §3.1 明令禁止的)。
165
+ * @param input.subscriptions 机器级订阅表
166
+ * @param input.account 收到的账号 id
167
+ * @param input.user 发信人 id
168
+ * @param input.fallback CCC 级回退目标(`{ccc, role}`);无 ⇒ `null` / 省略
169
+ */
170
+ export declare function planInboundRoute(input: {
171
+ subscriptions: readonly HumanChannelSubscriptionSettings[];
172
+ account: string;
173
+ user: string;
174
+ fallback?: {
175
+ ccc: string;
176
+ role: string;
177
+ } | null;
178
+ }): InboundRoutePlan;
179
+ /**
180
+ * 留痕落点:`$DSH_HOME/human-channel-audit.jsonl`(缺省 `~/.dsh/human-channel-audit.jsonl`)。
181
+ *
182
+ * 🔴 **为什么是机器级而不是某个 CCC 的 `_weixin-logs/`**:本文件记的事件(未投递 /
183
+ * 目标不存在 / 表残缺)**恰恰是"没有目标 CCC"的那些** —— 落进任何一个 CCC 都是**编一个**目标。
184
+ * (投递成功的入站事件仍落**被路由到的那个 CCC** 的 `_weixin-logs/`,方案 §3.6 不变。)
185
+ * env `SERENITY_HUMAN_CHANNEL_AUDIT` 覆盖(测试注入;与 `SERENITY_HOOKS_CONFIG` 同款推导)。
186
+ */
187
+ export declare function humanChannelAuditPath(): string;
188
+ /**
189
+ * 留痕事件。
190
+ * 🔴 **刻意不记消息正文**:方案 §3.1 要的是"**谁**发的、**为什么**没投" —— 正文既不必要,
191
+ * 又会把人说的话抄进一个机器级文件(本该只落在他自己 CCC 的日志里)。
192
+ */
193
+ export type HumanChannelAuditEvent = {
194
+ kind: 'inbound-unrouted';
195
+ account: string;
196
+ user: string;
197
+ reason: InboundSkipReason;
198
+ detail: string;
199
+ } | {
200
+ kind: 'inbound-target-missing';
201
+ account: string;
202
+ user: string;
203
+ ccc: string;
204
+ role: string;
205
+ detail: string;
206
+ } | {
207
+ kind: 'subscription-defect';
208
+ account: string;
209
+ index: number;
210
+ missing: string[];
211
+ present: Record<string, string>;
212
+ };
213
+ /**
214
+ * 追加一条留痕。**永不抛** ——
215
+ * 留痕失败绝不能反过来打断投递(它是**旁路**,与 `invokeWeixinHook` 的"旁路容忍"同款)。
216
+ * 🔴 **不静默的是它记的那件事**,不是它自己:所以调用方仍要**另行 `console` 出声**。
217
+ */
218
+ export declare function appendHumanChannelAudit(event: HumanChannelAuditEvent): void;
package/lib/index.d.ts CHANGED
@@ -17,6 +17,80 @@ export declare const name = "dsh-serenity-hooks";
17
17
  /** 主动调用的服务;其余(agent 事件)随 harness 装配必然存在
18
18
  * (v1.28.0 适配 0.1.2-rc.1:+ 'settings'——B4 settings 服务由 provider 插件加载后才有) */
19
19
  export declare const inject: string[];
20
+ /**
21
+ * human-channel(人机信道)的**机器级**配置形状(2026-09-26 owner 定案 ⇒ 实现方案 §2.2)。
22
+ *
23
+ * ## 为什么落在这里
24
+ * **插件 Config 本身就是机器级的**(宿主 `serenity-hooks` 命名空间,一个 dsh 进程一份),
25
+ * 而 human-channel 的三张表(账号 / 订阅 / 主控)天然是**跨 CCC** 的 ⇒ 不必新造文件。
26
+ *
27
+ * ## 为什么只有一种拼写(与 `gatewayEnabled` / `gateway.enabled` 那对刻意不同)
28
+ * 那一对是「面板层扁平键」与「部署层嵌套段」的分工;human-channel 是**结构化表**,
29
+ * 面板整块读写它 ⇒ **多一种拼写就多一个会漂的真相源**。
30
+ *
31
+ * ## 各字段为什么一律不 `.required()`
32
+ * schemastery 的「不 `.required()` 即可选」⇒ 手写 Config / 部署 bundle 里可以整段不给。
33
+ * 🔴 **实测更正(2026-09-26,真 schemastery)**:经宿主 Loader 解析后**本段恒存在** ——
34
+ * `Config({}).humanChannel` = `{accounts:[],subscriptions:[],relay:{},sending:{allow:['*']}}`
35
+ * (子段对象被**物化**、数组取各自缺省)。⇒ 「没设过」在**解析面上不可观测**;那与
36
+ * 「面板层扁平键刻意无缺省」(`gatewayEnabled` 那一类,为的是让位给部署层)是**两回事**。
37
+ * ⇒ 由此两条纪律:① 缺省值**只在 schema 写一次**(读取面的缺省与它同值,不各写一套);
38
+ * ② 读取面仍要能处理「整段不存在」(手写 Config / 未走宿主解析的调用方)。
39
+ * ⚠️ 另实测:显式 `sending.allow: []` **穿过解析仍为 `[]`**(`.default()` 不吞显式空数组)——
40
+ * 这是「谁都不许」语义的地基。
41
+ *
42
+ * 🔴 **`relay.ccc` = 唯一主控**:人设(机器级),**CCC 不得自声明**(自声明 = 自己给自己发通行证)。
43
+ * 🔴 **`sending.allow` 缺省 `['*']`** = 所有 CCC 都能发给人(owner Q7 裁决);显式 `[]` = 谁都不许
44
+ * (**不是**"回退到 `*`" —— 见投影的 `??` 语义)。
45
+ */
46
+ /**
47
+ * human-channel **账号表的一行**(机器级)。
48
+ * 🔴 声明成 `type` 而**不是** `interface`:`type` 的对象字面量形态带**隐式索引签名**
49
+ * ⇒ 才满足 schemastery 内部 `ObjectS<X extends Dict>` 对 `Dict` 的约束(`interface` 不带)。
50
+ */
51
+ export type HumanChannelAccountConfig = {
52
+ /** 账号 id(机器内唯一;订阅表按它引用) */
53
+ id?: string;
54
+ /** 渠道实现名(注册表里的 id;`weixin` / 将来的 `feishu` / `mail`) */
55
+ channel?: string;
56
+ /** 该账号轮询开关(缺省 true —— 投影归一为定值布尔) */
57
+ enabled?: boolean;
58
+ /** 凭据(owner Q3:落插件 Config,机器级;面板可编辑) */
59
+ token?: string;
60
+ /** 渠道侧的用户标识(iLink 的 `ilink_user_id`) */
61
+ userId?: string;
62
+ };
63
+ /** human-channel **订阅表的一行**:`(账号, 用户) → (CCC, 角色)`(声明为 `type` 的理由同上) */
64
+ export type HumanChannelSubscriptionConfig = {
65
+ /** 账号 id(∈ `accounts[].id`) */
66
+ account?: string;
67
+ /** 渠道侧的用户标识(如 `userA@im.wechat`;`*` = 通配兜底) */
68
+ user?: string;
69
+ /** 目标 CCC(别名) */
70
+ ccc?: string;
71
+ /** 目标 skiff 角色名(∈ 该 CCC 的 `skiff.roles`) */
72
+ role?: string;
73
+ };
74
+ export interface HumanChannelConfig {
75
+ /** 账号表(`id` 是订阅表引用它的键;`channel` 是渠道实现名,如 `weixin`) */
76
+ accounts?: HumanChannelAccountConfig[];
77
+ /**
78
+ * 订阅表:`(账号, 用户) → (CCC, 角色)` —— **收到**谁的消息。
79
+ * 🔴 机器级**显式表**,**不得**由"该 CCC 有没有 skiff 角色"推断(owner 2026-09-26 更正):
80
+ * skiff 用途很多(传话员只是其一),推断会让**为别的目的建的 skiff 被静默误接上微信**。
81
+ */
82
+ subscriptions?: HumanChannelSubscriptionConfig[];
83
+ /** 主控(relay):**唯一**可做跨 CCC 动作的 CCC */
84
+ relay?: {
85
+ /** CCC 别名(空白 / 缺省 ⇒ 无主控,投影归一为 `null`) */
86
+ ccc?: string;
87
+ };
88
+ /** 出站(CCC → 人):谁能给人发消息 */
89
+ sending?: {
90
+ /** 白名单(`*` = 全体;缺省 `['*']`);显式 `[]` = 谁都不许 */
91
+ allow?: string[];
92
+ };
93
+ }
20
94
  /** 插件配置(cordis.yml 提供;进程级) */
21
95
  export interface Config {
22
96
  /** CCC 配置相对路径(运行时从根读取);缺省 .opencode/serenity.json(规范)+ .dsh/serenity.json(回退) */
@@ -82,10 +156,10 @@ export interface Config {
82
156
  visionPatch?: {
83
157
  enabled?: boolean;
84
158
  };
159
+ /** human-channel(人机信道;**机器级**;2026-09-26 owner 定案)—— 见 `HumanChannelConfig` */
160
+ humanChannel?: HumanChannelConfig;
85
161
  /** F1 双端口网关总开关(面板层;部署层同义键 = `gateway.enabled`) */
86
162
  gatewayEnabled?: Volatile<boolean | undefined>;
87
- /** F2 超限重建总开关(面板层;部署层同义键 = `rebuild.enabled`) */
88
- rebuildEnabled?: Volatile<boolean | undefined>;
89
163
  /** F2 触发阈值(K token;面板层;部署层同义键 = `rebuild.thresholdK`) */
90
164
  rebuildThresholdK?: Volatile<number | undefined>;
91
165
  /** F4 Skiff 调试服务总开关(面板层;部署层同义键 = `skiff.enabled`) */
@@ -103,6 +177,12 @@ export interface Config {
103
177
  /** 无人值守代理回复总闸(面板层;无部署层同义键;缺省关) */
104
178
  unattendedEnabled?: Volatile<boolean | undefined>;
105
179
  }
180
+ /** schemastery 的 schema 类型(别名只为可读;**名字取自全局命名空间**,见上方 TS2742 说明) */
181
+ type SchemaOf<S, T = S> = Schemastery<S, T>;
182
+ /** human-channel 账号行 schema(行形状 = `HumanChannelAccountConfig`) */
183
+ export declare const humanChannelAccountSchema: SchemaOf<HumanChannelAccountConfig>;
184
+ /** human-channel 订阅行 schema:`(账号, 用户) → (CCC, 角色)` */
185
+ export declare const humanChannelSubscriptionSchema: SchemaOf<HumanChannelSubscriptionConfig>;
106
186
  export declare const Config: z<Schemastery.ObjectS<NoInfer<{
107
187
  serenityConfigPaths: z<string[], string[], "defined">;
108
188
  tools: z<boolean, boolean, "defined">;
@@ -154,6 +234,33 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
154
234
  }>>, Schemastery.ObjectT<NoInfer<{
155
235
  enabled: z<boolean, boolean, "defined">;
156
236
  }>>, "plain">;
237
+ humanChannel: z<Schemastery.ObjectS<NoInfer<{
238
+ accounts: z<HumanChannelAccountConfig[], HumanChannelAccountConfig[], "defined">;
239
+ subscriptions: z<HumanChannelSubscriptionConfig[], HumanChannelSubscriptionConfig[], "defined">;
240
+ relay: z<Schemastery.ObjectS<NoInfer<{
241
+ ccc: z<string, string, "plain">;
242
+ }>>, Schemastery.ObjectT<NoInfer<{
243
+ ccc: z<string, string, "plain">;
244
+ }>>, "plain">;
245
+ sending: z<Schemastery.ObjectS<NoInfer<{
246
+ allow: z<string[], string[], "defined">;
247
+ }>>, Schemastery.ObjectT<NoInfer<{
248
+ allow: z<string[], string[], "defined">;
249
+ }>>, "plain">;
250
+ }>>, Schemastery.ObjectT<NoInfer<{
251
+ accounts: z<HumanChannelAccountConfig[], HumanChannelAccountConfig[], "defined">;
252
+ subscriptions: z<HumanChannelSubscriptionConfig[], HumanChannelSubscriptionConfig[], "defined">;
253
+ relay: z<Schemastery.ObjectS<NoInfer<{
254
+ ccc: z<string, string, "plain">;
255
+ }>>, Schemastery.ObjectT<NoInfer<{
256
+ ccc: z<string, string, "plain">;
257
+ }>>, "plain">;
258
+ sending: z<Schemastery.ObjectS<NoInfer<{
259
+ allow: z<string[], string[], "defined">;
260
+ }>>, Schemastery.ObjectT<NoInfer<{
261
+ allow: z<string[], string[], "defined">;
262
+ }>>, "plain">;
263
+ }>>, "plain">;
157
264
  gatewayEnabled: z<boolean, boolean, "volatile">;
158
265
  rebuildThresholdK: z<number, number, "volatile">;
159
266
  skiffEnabled: z<boolean, boolean, "volatile">;
@@ -214,6 +321,33 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
214
321
  }>>, Schemastery.ObjectT<NoInfer<{
215
322
  enabled: z<boolean, boolean, "defined">;
216
323
  }>>, "plain">;
324
+ humanChannel: z<Schemastery.ObjectS<NoInfer<{
325
+ accounts: z<HumanChannelAccountConfig[], HumanChannelAccountConfig[], "defined">;
326
+ subscriptions: z<HumanChannelSubscriptionConfig[], HumanChannelSubscriptionConfig[], "defined">;
327
+ relay: z<Schemastery.ObjectS<NoInfer<{
328
+ ccc: z<string, string, "plain">;
329
+ }>>, Schemastery.ObjectT<NoInfer<{
330
+ ccc: z<string, string, "plain">;
331
+ }>>, "plain">;
332
+ sending: z<Schemastery.ObjectS<NoInfer<{
333
+ allow: z<string[], string[], "defined">;
334
+ }>>, Schemastery.ObjectT<NoInfer<{
335
+ allow: z<string[], string[], "defined">;
336
+ }>>, "plain">;
337
+ }>>, Schemastery.ObjectT<NoInfer<{
338
+ accounts: z<HumanChannelAccountConfig[], HumanChannelAccountConfig[], "defined">;
339
+ subscriptions: z<HumanChannelSubscriptionConfig[], HumanChannelSubscriptionConfig[], "defined">;
340
+ relay: z<Schemastery.ObjectS<NoInfer<{
341
+ ccc: z<string, string, "plain">;
342
+ }>>, Schemastery.ObjectT<NoInfer<{
343
+ ccc: z<string, string, "plain">;
344
+ }>>, "plain">;
345
+ sending: z<Schemastery.ObjectS<NoInfer<{
346
+ allow: z<string[], string[], "defined">;
347
+ }>>, Schemastery.ObjectT<NoInfer<{
348
+ allow: z<string[], string[], "defined">;
349
+ }>>, "plain">;
350
+ }>>, "plain">;
217
351
  gatewayEnabled: z<boolean, boolean, "volatile">;
218
352
  rebuildThresholdK: z<number, number, "volatile">;
219
353
  skiffEnabled: z<boolean, boolean, "volatile">;
@@ -225,3 +359,4 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
225
359
  unattendedEnabled: z<boolean, boolean, "volatile">;
226
360
  }>>, "plain">;
227
361
  export declare function apply(ctx: Context, config: Config): void;
362
+ export {};