@shgroup/dsh-serenity-hooks 1.104.0 → 1.107.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2197 @@
1
+ import { l as hostInjected, r as cccRootForCwd, u as hostService } from "./ccc-roots-DPMwBywc.js";
2
+ import { c as matchBlacklist, d as readCccName$1, l as pathInside, n as SAFE_MODE_MARKER, o as isSafeModeOn, p as readSafeModeHiddenTools, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-BmpE3cQz.js";
3
+ import { F as isoLocal, g as listActiveTrajectories } from "./bind-file-B0TuOP89.js";
4
+ import { c as profileWriteWhitelist, s as profileToolWhitelist, u as ACC_VERSION } from "./rules-BLkYTPUy.js";
5
+ import { l as trajectoryCodeForSession, u as trajectoryConstraintForSession } from "./profile-BjBn_-be.js";
6
+ import { n as isImWiredTrajectory } from "./channels-Dty_NIl_.js";
7
+ import { REGISTERED_TOOLS } from "./invariant.js";
8
+ import { basename, dirname, join, relative, resolve } from "node:path";
9
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
10
+ import { execFile, execFileSync, spawnSync } from "node:child_process";
11
+ import { homedir } from "node:os";
12
+ import { promisify } from "node:util";
13
+ import { platform as platform$1 } from "node:process";
14
+ //#region src/im/endpoint.ts
15
+ /**
16
+ * im/endpoint.ts — 主动发送入口地址的**零依赖叶模块**(v1.30.9,S142)
17
+ *
18
+ * 为什么单独一个模块(R↓):地址的读者是 `msm-ops.buildMsmEnv`(把地址注入 MSM 子进程 env),
19
+ * 写者才是 `weixin-send-api`(监听器启停)。若 msm-ops 直接 import weixin-send-api,
20
+ * 静态依赖链会拖进整条微信桥栈(weixin-bridge → trajectory-session → `@deepseek-ai/dsh-llm`)——
21
+ * 后果实测:`acc-extras` / `ops` / `trajectory-constraint-admin` 三个测试套件因解析不到宿主包而**整文件加载失败**
22
+ * (它们只用到 MSM 注册表逻辑)。叶模块把「读」与「写」解耦:读者零依赖,写者自己承担重量。
23
+ *
24
+ * 同款先例:`host/effect.ts`(拆卸登记)、`trajectory/carrier.ts`(等待结算)。
25
+ */
26
+ let endpoint = null;
27
+ /** 设置当前入口地址(weixin-send-api 启停时调用;null = 未启动) */
28
+ function setWeixinSendEndpoint(value) {
29
+ endpoint = value;
30
+ }
31
+ /** 当前入口地址(形如 http://127.0.0.1:3082;未启动 → null) */
32
+ function weixinSendEndpoint() {
33
+ return endpoint;
34
+ }
35
+ //#endregion
36
+ //#region src/tools/config-docs.ts
37
+ /**
38
+ * 渲染**目录**(第一层)。
39
+ *
40
+ * 🔴 只调 `summary`,**绝不调 `render`** —— 目录若触发正文,就退化成"整份长文"。
41
+ * 这条不是性能优化,是**判据**:它保证 `docs` 的输出大小与章数成正比,与正文长度无关。
42
+ */
43
+ function renderDocsIndex(chapters) {
44
+ const lines = [
45
+ "═══ CCC 配置说明 · 目录 ═══",
46
+ "",
47
+ "说明类内容已超过单次返回的合适篇幅 ⇒ 先读目录,再按章取详情。",
48
+ "",
49
+ "用法:container_admin config docs <章节>",
50
+ ""
51
+ ];
52
+ for (const ch of chapters) lines.push(` ${ch.id.padEnd(14)}${ch.summary}`);
53
+ lines.push("");
54
+ lines.push("旧入口(msm catalog / msm ccc-config / config guide)**降为指针**,只指路、不再返回正文。");
55
+ return lines.join("\n");
56
+ }
57
+ /**
58
+ * 解析一个章节参数(第二层)。
59
+ *
60
+ * 不存在 ⇒ 返回 `null`,由调用方给出**带候选清单**的拒绝 ——
61
+ * "不知道这个章节" 与 "这一章是空的" 必须可区分(否则用户以为功能没有)。
62
+ */
63
+ function findDocsChapter(chapters, id) {
64
+ const want = id.trim().toLowerCase();
65
+ if (want === "") return null;
66
+ return chapters.find((c) => c.id.toLowerCase() === want) ?? null;
67
+ }
68
+ /**
69
+ * **指针文案**(v1.75.0,owner 裁 Q3「指针」)——被降级的旧入口共用这一份。
70
+ *
71
+ * ## 为什么要有"指针"这个形态(而不是直接删掉旧入口)
72
+ *
73
+ * 旧入口的名字(`msm catalog` / `msm ccc-config` / `config guide`)**已经写在 CCC 侧的
74
+ * 文档与提示词里**,硬删会让它们全部变成"未知动作",而且**没有任何机械信号**告诉我们
75
+ * 哪份文档已经说谎了(本仓 D151 同族)。
76
+ *
77
+ * ⇒ 保留名字、只换返回内容:调用方能**自己顺着指针找到正文**,而正文仍然只有一份。
78
+ *
79
+ * 🔴 **必须只有一处实现**:`container-admin.ts`(config 域)与 `msm/service.ts`(msm 域)
80
+ * 都要用它。两处各写一份文案 ⇒ 措辞漂移(本仓反复踩过的形态)。
81
+ *
82
+ * @param chapter 正文所在的章 id
83
+ * @param what 这个入口原来叫什么(进标题,让调用方一眼确认自己找对了)
84
+ */
85
+ function docsPointer(chapter, what) {
86
+ return [
87
+ `═══ ${what} — 正文已统一 ═══`,
88
+ "",
89
+ `正文在:container_admin config docs ${chapter}`,
90
+ "",
91
+ "为什么改(v1.75.0,owner 裁「指针」):这个内容此前有**两个**正文入口,",
92
+ "而它们返回同一份常量 ⇒ 两个真相源。现在「一个内容一个正文入口」,本入口只指路。",
93
+ "",
94
+ "索引(不知道还有哪些章):container_admin config docs"
95
+ ].join("\n");
96
+ }
97
+ //#endregion
98
+ //#region src/tools/msm/service.ts
99
+ /**
100
+ * tools/msm/service.ts — msm 纯操作层(acc_msm → msm 执行 + container_admin 管理面,v1.30;零 DSH 依赖)
101
+ *
102
+ * MSM(Mech & Semi-Mech)框架:exec 执行 / register|deregister|check 管理。
103
+ * 复用 CCC 的 mech-registry.json(v1 或数组格式)。cwd 钉在 CCC 根。
104
+ */
105
+ const execFileAsync = promisify(execFile);
106
+ const MSM_TIMEOUT_MS = 6e5;
107
+ const BUN_EXE_CANDIDATES = [
108
+ () => join(process.env["USERPROFILE"] ?? process.env["HOME"] ?? "", ".bun", "bin", "bun.exe"),
109
+ () => join(process.env["APPDATA"] ?? "", "npm", "node_modules", "bun", "bin", "bun.exe"),
110
+ () => join(process.env["LOCALAPPDATA"] ?? "", "npm", "node_modules", "bun", "bin", "bun.exe")
111
+ ];
112
+ let bunExeCache;
113
+ function bunExecutablePath() {
114
+ if (bunExeCache !== void 0) return bunExeCache;
115
+ if (platform$1 !== "win32") {
116
+ bunExeCache = "bun";
117
+ return bunExeCache;
118
+ }
119
+ for (const cand of BUN_EXE_CANDIDATES) try {
120
+ if (existsSync(cand())) {
121
+ bunExeCache = cand();
122
+ return bunExeCache;
123
+ }
124
+ } catch {}
125
+ bunExeCache = null;
126
+ return null;
127
+ }
128
+ /** CCC 名:从 .serenity 解析(review P2-2 统一收口到 kernel/ccc.ts readCccName——跳 # 注释/空行首非空行;此处保留导出名兼容内部调用) */
129
+ function readCccName(root) {
130
+ return readCccName$1(root);
131
+ }
132
+ /**
133
+ * path-arg 逃逸校验:根内 + symlink 防御(对齐 osp validatePathArgsFromTokens)。
134
+ * symlink 指向根外 → 拒绝(realpath 解析后与根前缀比对)。
135
+ */
136
+ function assertPathInsideRoot(root, value, flagName) {
137
+ const abs = resolve(root, value);
138
+ if (classifyPath(abs, root) === "outside") throw new Error(`Path escape blocked: --${flagName}=${value} escapes the CCC root`);
139
+ if (existsSync(abs)) try {
140
+ const real = realpathSync(abs);
141
+ if (classifyPath(real, root) === "outside") throw new Error(`Path escape blocked: --${flagName}=${value} resolves via symlink outside the root (${real})`);
142
+ } catch (e) {
143
+ if (e instanceof Error && e.message.includes("symlink")) throw e;
144
+ }
145
+ }
146
+ /**
147
+ * 业务子进程 env:注入 SERENITY_ROOT / SERENITY_CCC / SERENITY_VERSION(对齐 osp)
148
+ * + SERENITY_WEIXIN_API(v1.30.9:微信主动发送入口地址——MSM 脚本零硬编码端口;
149
+ * 未启动则不注入,脚本回退默认值)。
150
+ */
151
+ function buildMsmEnv(root) {
152
+ const env = {
153
+ ...process.env,
154
+ SERENITY_ROOT: root,
155
+ SERENITY_CCC: readCccName(root) ?? "",
156
+ SERENITY_VERSION: ACC_VERSION
157
+ };
158
+ const weixinApi = weixinSendEndpoint();
159
+ if (weixinApi) env.SERENITY_WEIXIN_API = weixinApi;
160
+ return env;
161
+ }
162
+ /**
163
+ * Windows 兼容(审计观察点 A):`.cmd` 不能直接被 CreateProcess 解析——
164
+ * `execFile('npx')` / `spawnSync('npx')` 在 Windows 必 ENOENT(需 shell 或显式 .cmd)。
165
+ * bun 无扩展名(bun.exe 可被 libuv 按 PATHEXT 解析),保持 'bun'。
166
+ */
167
+ const NPX_BIN = process.platform === "win32" ? "npx.cmd" : "npx";
168
+ /**
169
+ * ACC 能力目录(需求④ S142 用户拍板:"acc 配置各自做 guide 越来越多,整合成目录式使用指南")。
170
+ * 目录在前、详情各归各——**单一真相源**:本目录只索引"去哪个工具/子命令看详情",不复制详情
171
+ * (避免 container_admin/weixin-doctor 等 guide 全文重复 → 多真相源 → 失同步)。
172
+ * 后续新增功能只需在此加一行(低熵可演进)。
173
+ *
174
+ * 🔴 **2026-10-19 v1.75.0 起导出**:它是 `container_admin config docs catalog` 那一章的正文。
175
+ * `msm catalog` 动作**降为指针**(只指路,不再返回本常量)—— 否则同一份正文会有两个入口,
176
+ * 而"两个入口返回同一份内容"正是本目录自己反对的形态。
177
+ */
178
+ const ACC_CATALOG = `═══ ACC Usage Catalog ═══
179
+ Directory of ACC capabilities — each area lists where to go for details (guides live with their feature; this catalog only points).
180
+
181
+ ① 轨迹本体 → cognitive_trajectory(list/show/create/use + rebuild 超限重建 + send-now 即时投递 + send-later 未来一次性唤醒
182
+ + cro-guide CRO 编写指南)
183
+ 原 logbook 已并入(v1.33 用户裁决:废除 logbook 词);close/archive 删(归档走 container_fs mv),
184
+ health/qa/summary 分别并入 use / list,hook-develop-guide 并进 container_admin msm guide
185
+ ② 认知质量框架 → praxis(eap/neat/cce/ontology 四合一——可实践理论注入,section 渐进披露,无参即目录)
186
+ ontology(v1.52 D121:本体工程**引擎**——init/check/explain/verify/review/compliance;
187
+ 与 praxis ontology 的分工 = 那个是**方法文本**、这个是**能跑的那个**)
188
+ ③ 工具与执行 → container_fs(容器文件系统 15 子命令)/ container_git(git)/ dashboard(health 含注册表检查/time/wait)
189
+ acc_msm 已并入 msm(执行+发现)与 container_admin(管理)——msm("<name>", ["<args>"]);inspect=true 查用法
190
+ (v1.106.0 已删 exclusiveTools 机制:原专属工具 acc-diag **已并入 dashboard diag**)
191
+ ④ 轨迹约束与对外面 → container_admin config(轨迹约束四动作 guide/validate/apply/list——**原 role 域已并入 config**,旧名作过渡别名仍可用;说明类配置走**两层菜单** config docs 目录 / config docs <章节> 详情)
192
+ 外部程序化接入用官方 dsh --profile acp
193
+ + 现成 ACP 客户端;本插件不提供 ACP 面
194
+ ⑤ 自主与接入 → cognitive_trajectory send-now(跨轨迹的**即时**递送,**不排队**:live 目标在跑就当场注入当前轮、空闲则立即起轮;非 live 冷载入——等效于直接 wake;**有同步回执**)
195
+ cognitive_trajectory send-later(跨轨迹的"未来时刻 + 一条 message";fire-and-forget,无回执无回收)
196
+ weixin 微信桥(配置/扫码/路由/消息 hook——CCC 侧 weixin-doctor MSM:msm("weixin-doctor", ["guide"]))
197
+ ⑥ CCC 配置总览 → container_admin config docs config(按章详情——8 段全表)
198
+ 或 container_admin msm ccc-config(同一份正文的旧入口,保留)
199
+ ⑦ 注册表与安全 → mech-registry.json 由 container_admin msm register/deregister 管理(写保护——不可直接编辑)
200
+ dashboard health 输出 registry 完整性检查;损坏恢复指引见 health registry.issues
201
+ CCC 级开关:safeMode.blacklist(写路径黑名单)
202
+ / safeMode.hiddenTools(**安全模式开启期间**额外隐藏的工具,v1.51.4)
203
+ CCC 级 skill 供给:cognitiveTrajectory.skills(v1.44.0——**本 CCC 每条轨迹都带**,含 轨迹约束会话;与轨迹自己的 frontmatter 声明取并集)
204
+
205
+ 每区:一句话定位 + 详细入口(工具名 / guide 子命令)。详情永远以对应工具的 guide/ccc-config 为单一真相源。
206
+ `;
207
+ const MSM_GUIDE = `MSM Development Manual (Mech & Semi-Mech framework)
208
+
209
+ ## What it is
210
+ MSM = the executable-unit layer. Mech is pure TS with zero LLM reasoning; Semi-Mech is a TS framework + LLM decision points.
211
+ MSM is ACC's deterministic executable-unit layer — all shell/exec operations go through MSM and cannot be bypassed.
212
+
213
+ ## Registering a new MSM (container_admin msm register)
214
+ 1. Write the script under <skill>/scripts/ (runnable via tsx; must include a main() CLI guard with an import.meta.url check)
215
+ 2. container_admin msm register --domain msm --action register <name> --skill <s> --path <script path relative to root> --category <mech|semi-mech> --description <desc>
216
+ 3. Auto-writes mech-registry.json (preserving the original format) + git commit (commits only the registry file)
217
+ 4. Validation: path must be inside root, script must exist, name must be globally unique
218
+
219
+ ## flag schema (v1)
220
+ flags is a new-style object array for parameter validation and path-escape guarding:
221
+ [{"name":"output","type":"string","description":"output path"},
222
+ {"name":"target","type":"path","description":"operation target (type:path enables in-root validation + symlink defense)"},
223
+ {"name":"force","type":"boolean","description":"force mode","default":false}]
224
+ - {name, type} format — new style, type:"path" enables the path-escape guard
225
+ - {flag, description} format — old style, CLI flag description string
226
+ - On registration, flags are passed via container_admin msm register --flags '<json>'
227
+
228
+ ## Script conventions
229
+ - Top-of-file documentation: purpose / usage / exit codes
230
+ - Exit codes: 0 success / 1 user / 2 system / 3 operator (per ACC protocol classification)
231
+ - main() CLI guard (DC-M2): the script must start with
232
+ if (import.meta.url === \`file://\${process.argv[1]}\`) { main() }
233
+ or an equivalent isMain / require.main === check — so vitest imports never trigger top-level code
234
+ - A paired .test.ts or .spec.ts (DC-M1, vitest)
235
+ - Business subprocess environment: SERENITY_ROOT / SERENITY_CCC / SERENITY_VERSION injected
236
+
237
+ ## Interaction & confirmation conventions (no blocking confirmation)
238
+ - MSMs run in **user-less subprocesses** (spawn/execFile, 600s timeout) — **never** use
239
+ readline / prompt / process.stdin etc. to block waiting for user input (it would hang until killed by the timeout)
240
+ - When a second confirmation is needed: **directly return the confirmation request** (list the operation to perform and its impact + how to retry with the confirmation flag),
241
+ returning a non-zero exit code (or an explicit hint)
242
+ - After the agent confirms, **re-invoke the MSM with the confirmation flag** (e.g. --confirm / --yes / --force) to retry
243
+ - Standard mode (two-phase):
244
+ 1. First call: destructive/confirmation-requiring operation detected and no confirmation flag present → output the confirmation request (list operation/impact/rollback),
245
+ exit non-zero (e.g. 1 user), **perform no changes**
246
+ 2. After the agent evaluates, re-invoke: with the confirmation flag → execute and output the result
247
+ - Applies to: delete/overwrite/push/batch operations and other irreversible or wide-impact operations
248
+ - Anti-example (forbidden): readline waiting for user input, process.stdin.on('data') blocking waits
249
+ (MSMs have no stdin interaction channel — they hang until the 600s timeout)
250
+
251
+ ## Quality checks (container_admin msm check, DC-M1~M4)
252
+ DC-M1 has .test.ts/.spec.ts; DC-M2 has a main() guard (function main( / isMain / require.main === / import.meta.url);
253
+ DC-M3 bidirectional: scripts unregistered + registry references missing scripts; DC-M4 path-type flags marked type:"path"
254
+
255
+ ## Self-description (protocol flags, first argument only)
256
+ msm("<name>", ["--list"]) — list all MSMs
257
+ msm("<name>", ["--schema", "<n>"]) — view a specific MSM's parameter schema
258
+ msm("<name>", ["--format=json"]) — JSON output mode (remaining args passed through losslessly)
259
+ `;
260
+ /**
261
+ * CCC 配置参考(对齐 osp ccc-config action)。
262
+ *
263
+ * 🔴 **2026-10-19 起导出**:`container_admin config docs config` 的两层菜单把它作为
264
+ * 「config」章正文返回。**不另写一份**——同一段说明有第二份副本就会漂(本轨反复踩过)。
265
+ */
266
+ const CCC_CONFIG_REFERENCE = `═══ CCC Configuration Reference ═══
267
+
268
+ CCC-level features are configured in .opencode/serenity.json.
269
+ Below are all available configuration sections.
270
+
271
+ ── 1. sessionKeeper.threshold / sessionKeeper.sessionMdMaxKB ──
272
+ cognitive-trajectory-assistant(COGNITIVE-TRAJECTORY-ASSISTANT · CHECKPOINT)提醒机制的积分阈值(非 headless 主 agent)。
273
+ 按工具调用加权 + 耗时计分;达到阈值注入提醒,要求模型回复 ACK 码。
274
+
275
+ Config:
276
+ { "sessionKeeper": { "threshold": 150, "sessionMdMaxKB": 100 } }
277
+
278
+ 计分:write/edit = 3,task = 10,read/grep/glob/msm 等 = 1,时间 = 1/分钟
279
+ 默认:150
280
+
281
+ ▸ sessionMdMaxKB(v1.31.1 新增;v1.31.2 100→200;🔴 v1.84.0 200→100):
282
+ **活跃 SESSION.md 体积上限(KB,缺省 100;0 = 关闭)**。
283
+ 超过 → 注入 [COGNITIVE-TRAJECTORY-ASSISTANT · LOGBOOK COMPACTION] 提醒:暂停当前工作 →
284
+ 加载 eap(praxis eap)→ **按 EAP 重构整个 SESSION.md**(v1.84.0 起;旧版是四条重写原则)。
285
+ 判据 = EAP 三个变量:E↑ 变量/边界/有向关系全显式 | R↓ 带理由与被否项,长证据链外移到
286
+ references/*.md(SESSION.md 留链接)| S↑ 结构每次同样重建,不是时间流水账。
287
+ 两条硬约束随提醒给出:保留固定段落的**顺序与标题**(与系统提示词对齐)+ 保留顶部 frontmatter。
288
+ 超限后每次工具调用都提醒;连续 3 次未重写 → 升级强制语气;
289
+ 文件回到限内 → 自动停止(自愈)。轨迹提醒**不机械阻断工作**(与 rebuild 提醒同族)。
290
+
291
+ ── 2. localstore.gitTrack ──
292
+ localstore.json 的 git 策略:allow 可提交 / deny 禁提交(默认 deny)。
293
+ deny 时写入自动确保 .gitignore 含该文件(物理保证),container_git commit 会检查拒绝。
294
+
295
+ Config:
296
+ { "localstore": { "gitTrack": "allow" } }
297
+
298
+ ── 3. hooks.autoRestoreSession ──
299
+ 会话自动恢复(默认 true;受 events 门控——仅根会话 + 已有对话历史才恢复)。
300
+
301
+ Config:
302
+ { "hooks": { "autoRestoreSession": false } }
303
+
304
+ ── 4. safeMode(黑名单 + 开启期间隐藏的工具)──
305
+ safe-mode 由 WebUI 开关控制(写 .serenity-safe-on 标记);黑名单路径受 guards seam 拦截。
306
+
307
+ ▸ blacklist:写路径黑名单(同下 Config)。
308
+ ▸ hiddenTools(v1.51.4,owner 2026-10-09 令):**仅在安全模式开启期间**额外从模型工具
309
+ 清单隐藏的工具(ACC 基础表恒隐 bash)。打开 safe-mode 生效、**关掉即全部恢复**;
310
+ 改本键**热生效**(下一个 pre-step 重算,无需重启)。声明在 CCC(D23)——ACC 代码里
311
+ 不出现任何具体工具名。
312
+ ⚠️ 写一个**本 CCC 未注册**的名字会让**整次 restrict 失败**(DSH 契约逐字:"unknown
313
+ names … fail")⇒ 此时**退化为只隐藏基础表**并在日志响亮报错(绝不因一个错名丢掉
314
+ safe-mode 的核心隐藏)。诊断:\`AGENT_SESSIONS/.restrict-diag.json\` 的 \`denyByKey\`。
315
+ ⚠️ 隐藏 = **从 schema 移除**(agent 根本看不到该工具)⇒ 别把 skill/文档还在教的工具写进来。
316
+
317
+ Config:
318
+ { "safeMode": { "blacklist": [".secrets/"], "hiddenTools": ["workflow", "job_kill"] } }
319
+
320
+ ── 5. 轨迹约束.roles(F4 认知子集角色)──
321
+ 轨迹约束角色 = 全知全能 cognitive trajectory 的任意子集(CCC 定义)。每角色独立模型 + 双白名单
322
+ (MSM 白名单 msms[] 与非 MSM 工具白名单 tools[],白名单外全隐藏;skill 加载恒可用)。
323
+ cognitive trajectory 纪律子集(session/keeper/rebuild 参与项)默认全关 = 完全独立。
324
+ systemPrompt 内联 或 systemPromptFile(.md 引用,推荐)——角色会话的完整人格/边界提示词。
325
+ validate 校验 / apply 生效 / list 查看:container_admin config <guide|validate|apply|list>(原 role 域已并入 config,旧名作过渡别名仍可用)。
326
+
327
+ Config:
328
+ { "轨迹约束": { "roles": {
329
+ "qa": {
330
+ "model": "provider/model",
331
+ "msms": ["web-search", "vlm-describe"],
332
+ "tools": ["read", "grep", "glob"],
333
+ "systemPromptFile": ".opencode/skiff/qa.md"
334
+ } } } }
335
+
336
+ ── 6. cognitive_trajectory(轨迹 + 唤醒注册表,D58)──
337
+ CCC 的一条 cognitive trajectory = 持久身体(SESSION.md)+ 时间轴(AGENT_SESSIONS/ 目录 + 唤醒注册表)。
338
+ 一个 CCC 内可多条并行;条目全部由人类/agent 显式登记。
339
+ ⚠️ ACC 不再提供**周期自唤醒**(原 autopilot,2026-09-15 整段退场:机制/时钟/container_admin autopilot
340
+ 域/面板区块一并删除)。自主巡航改由 CCC 自己驱动(home-serenity 侧 msm autopilot-round 每轮自排下一轮,
341
+ 真相源 = S151 SESSION.md §1/§1b/§1c)。**本轮的 cognitiveTrajectory.autopilot 配置段保留**(见下表)——
342
+ 它现由 **CCC 侧脚本**读取,ACC 不再消费该段。
343
+ 唤醒注册表(cognitive_trajectory send-later):唤醒 = 未来时刻 + 一条 message,可唤醒自己或别的 cognitive trajectory;
344
+ 落点 AGENT_SESSIONS/wake-registry.json,中心调度器到点投递(fire-and-forget;无回执、无回收)。
345
+ 运行态诊断(ACC 负责人专用):acc-diag(① live 运行态 / ①b 唤醒时钟 / ③ 唤醒注册表)。
346
+
347
+ ▸ cognitiveTrajectory.skills(v1.44.0,**CCC 级 skill 供给**;owner 令 2026-09-20「要求实现我的诉求……
348
+ 注入是动态注入就行;create 先不触发吧;**轨迹约束也支持**」):
349
+ **本 CCC 的每条轨迹都带这些 skill**——声明的 skill 全文在该轨迹**被绑定期间**注入系统提示词
350
+ (**动态注入**:每请求重新求值、不缓存 ⇒ 改配置或改 frontmatter **即时生效**,无需重新 use、无需重启)。
351
+ 两条声明来源**并集**(不是二选一):本键 **cognitiveTrajectory.skills**(容器给的底座,**在前**)
352
+ + 轨迹自己 SESSION.md 顶部 frontmatter 的 skills:(这条轨迹额外的,**追加**;同名去重)。
353
+ ✅ **含 轨迹约束角色会话**(旁路的是 ACC 身份与纪律;skill 是"工作资料",照给)。
354
+ 守卫:名字先过安全校验才用于拼路径(防穿越)|**合并后**统一 32 KB 上限截断|找不到响亮写 [缺失]。
355
+ ⚠️ 与 create **无关**(create 刻意不夺绑定,U6)⇒ 注入的门是"**存在绑定**"(cognitive_trajectory use)。
356
+ 失败语义:未配置 / 字段缺失 / 配置损坏 ⇒ **空数组**(= 无 CCC 级供给;轨迹级声明照旧生效)。
357
+ 归属(D23):**机制在 ACC**(读取 + 合并 + 注入 + 守卫),**声明在 CCC**(本键)。
358
+
359
+ Config:
360
+ { "cognitiveTrajectory": {
361
+ "skills": ["acc-session", "cce"], // v1.44.0:本 CCC 的每条轨迹都带(含 轨迹约束会话)
362
+ "autopilot": { // 原 autopilotTrajectory 段,字段不变(旧键仍回退读)
363
+ "enabled": true,
364
+ "intervalHours": 2,
365
+ "session": "S151",
366
+ "biasProvider": "autopilot-bias.ts",
367
+ "topPrompt": "本轨迹核心目标/纪律/质量要求(CCC 自填,防漂移)",
368
+ "avoidWakeHours": { "start": 8, "end": 18 }
369
+ } } }
370
+
371
+ ── 7. weixin(微信桥 F4c-3,含消息记录 hook)──
372
+ CCC 级微信个人号接入(iLink 协议):dsh 一进程多 CCC,每 CCC 独立对接微信桥。
373
+ 账号/路由/开关在此文件;**bot_token 凭据在 CCC localstore credential scope**
374
+ (扫码绑定后自动写入,永不进 git 明文面)。
375
+ 路由 user → role:exact 优先,* 通配兜底;role 必须 ∈ 该 CCC 轨迹约束.roles。
376
+ 面板(WebUI 设置 → 微信桥)可扫码绑定/移除账号/编辑路由;msm("weixin-doctor", ["status"|"diag"|"verify"|"guide"]) 排查 + 大而全指南。凭据主动查看:localstore get WEIXIN_<ACCOUNT>_TOKEN。
377
+ ▸ 主动发送(v1.31.0:**ACC 工具 im-bridge**):调
378
+ im-bridge({channel:"weixin", action:"send", user:"<别名|id>", text:"<文本>"}) 给用户发消息——
379
+ **工具只能操作本会话 CCC**(无 ccc 参数,防误发他容器)→ weixin-bridge.sendProactiveText →
380
+ 成功后触发 outgoing hook(source="proactive")。文件用 action:"send-file" + file:"<CCC 内路径>"
381
+ (逃逸/缺失/超 20MB 拒绝)+ 可选 caption。**可见性**:本 CCC 配置了 IM 通道
382
+ (weixin.enabled=true)才出现在模型工具清单里;未配置 → 由 guards 逐 agent
383
+ tools.restrict({deny:['im-bridge']}) 移除(不是"看得到但被拒")。轨迹约束角色另需在
384
+ roles.<role>.tools 白名单列出 im-bridge。
385
+ 历史(v1.30.9~v1.30.x):CCC 侧 MSM weixin-send(走 loopback HTTP 入口 3082)——
386
+ v1.31.0 已退役(脚本删除 + 注册表注销);3082 入口保留给**非 agent 调用者**。
387
+
388
+ ▸ 🔴🔴 **出站只有一条路:agent 自己调 im-bridge**(2026-10-24 起,owner 令)。
389
+ 桥**永不**替你回发最终文本 —— 你说什么、说几条、什么时候说、要不要不说,全由你决定。
390
+ ⛔ 不要指望"我把话写进回答里就会自动发出去":**不会**。必须真的调工具。
391
+ ⇒ 每轮用户消息末尾都会附一行**已填好参数**的 im-bridge 调用模板(机制标记
392
+ [serenity:weixin-manual-output]),照它改 text 即可。
393
+ ⇒ 本轮结束仍未成功调用(send / send-file)→ 你会被打回(≤2 次,提示 = 标记 + 事实);
394
+ 达上限后**用户将收不到任何消息**(没有任何兜底转发)。
395
+ ⇒ 成功调用才算送达;失败调用不算(不会被当成本轮已发送)。
396
+ ⚠️ **前提**:本轨迹必须在 \`.opencode/serenity.json\` 的 \`im.routes\` 里被点名,否则 \`im-bridge\`
397
+ 对你不**可见**——那种情况下闸门不会打回你(改成一条响亮告警,指明"该轨迹无发送权"),
398
+ 因为打回一个你根本满足不了的要求只会空转。
399
+
400
+ ▸ **已退役(不要再配,配了也不生效)**:
401
+ · \`weixin.autoReplyWithLastMessage\`(v1.30.10~2026-10-24)= 桥替 agent 回发最终文本。
402
+ **被否理由**(owner 原话):「autoReply 并不合适,主动性的 imBridge 仍然是更好的」——
403
+ 它让 agent 失去"说几条/何时说/要不要不说"的决定权,而那正是主动发送的价值。
404
+ · \`weixin.fallbackOnNoSend\`(v1.30.17~2026-10-24)= 手动模式下 agent 没发成功时桥兜底转发。
405
+ 它只在上面那个开关 =false 时才有意义 ⇒ 一起退役。
406
+ ⚠️ 旧配置里残留这两个键**静默忽略**(不会报错,也不会生效)。
407
+
408
+ Config:
409
+ { "weixin": {
410
+ "enabled": true,
411
+ "hook": "scripts/weixin-message-hook.ts",
412
+ "accounts": [{ "accountId": "wechat-1", "name": "家庭助手", "enabled": true }],
413
+ "routes": [{ "user": "*", "role": "my-role" }]
414
+ } }
415
+
416
+ ▸ weixin.hook(消息记录 hook,v1.27.13):
417
+ 微信桥每收/发一条消息触发一次 CCC 自写脚本,由 CCC 自行持久化保存(存哪/存成什么
418
+ /是否入库全归 CCC——ACC 不绑定存储)。未配置 hook → 零变化(不触发)。
419
+
420
+ 触发(双向):
421
+ incoming = 用户 → bot:路由命中后、媒体落盘后触发(文本含语音转写;媒体带落盘 relPath)
422
+ outgoing = bot → 用户:发送成功后触发(reply = 用户实际收到的纯文本,已剥离 think)
423
+ · source="proactive" = agent 经 im-bridge 主动发起(**2026-10-24 起这是唯一值**)
424
+ · 历史值 reply / reply-fallback(桥自动回发 / 桥兜底转发)已随两个开关一起退役;
425
+ 存量日志里仍能看到它们,那是历史事实,不是当前行为
426
+ · file(仅 send-file 补记):{ "name": "报告.pdf", "size": 12345, "caption": "可选" }
427
+
428
+ 脚本约定(事件 JSON 单行经 stdin 传入;bun 优先 node 兜底):
429
+ #!/usr/bin/env bun 或 node script.js —— 读 process.stdin 整段 JSON.parse
430
+ const ev = JSON.parse(await new Response(process.stdin).text())
431
+ ev.event === 'incoming' | 'outgoing'
432
+
433
+ 事件 schema(**不含任何会话凭据**——context_token/bot_token/token/aes_key 均不出现):
434
+ { "event": "incoming", "ts": 1788359941488, "cccRoot": "/path/ccc",
435
+ "accountId": "wechat-1", "userId": "u1@im.wechat",
436
+ "sessionId": "skiff-weixin-xxx", "role": "my-role",
437
+ "message": { "text": "你好", "media": [{ "kind": "image", "relPath": "_tmp/weixin-inbound/<hash>/img_x.jpg" }] } }
438
+ { "event": "outgoing", "ts": ..., "cccRoot": ..., "accountId": ...,
439
+ "userId": ..., "sessionId": ..., "role": ...,
440
+ "reply": "已记录(纯文本)", "source": "proactive" }
441
+
442
+ 示例脚本(追加到按日文件——持久化归 CCC 自选:文件/DB/远端均可):
443
+ const fs = require('node:fs'); const p = '/path/ccc/AGENT_SESSIONS/_weixin-log.jsonl';
444
+ fs.appendFileSync(p, JSON.stringify(ev) + '\\n');
445
+
446
+ 执行语义(旁路容忍):异步 fire-and-forget + 15s 超时 kill + 失败仅日志——
447
+ 微信桥消息处理/回复不受 hook 影响;脚本须在 CCC 根内(路径逃逸拒绝)。
448
+ 媒体 relPath 指向 _tmp/weixin-inbound/(临时目录)——需持久保存媒体文件请自行 copy。
449
+
450
+ ── 8. (已删除)exclusiveTools(专属工具声明,v1.33 → v1.106.0 删除)──
451
+ 🔴 **本键已不存在**(owner 2026-11-01 裁「机制整体删」)。
452
+ 原语义:ACC 注册但默认对所有 CCC 隐藏的工具,只有在 CCC 配置里点名才可见(fail-closed)。
453
+ **删除理由**:那张表永远只有一个成员 acc-diag,而它已并入**常驻**的 dashboard 的 diag 动作。
454
+ 表空之后那个 filter 恒回空数组 ⇒ 机制**静默空转、且任何测试都证伪不了它**
455
+ (一个验不出死活的机制比没有它更坏:它提供"这里还有一层保护"的错觉)。
456
+
457
+ ▸ 要运行态诊断 ⇒ 直接用 dashboard 的 diag 动作(**无需任何声明,所有轨迹可见**)。
458
+ ▸ 旧配置里残留 exclusiveTools ⇒ **静默忽略**(schemastery 非 strict),不影响加载。
459
+ ▸ ⚠️ 连带后果(owner 已知并确认):dashboard 是常驻工具 ⇒ diag 段对**所有轨迹**可见,
460
+ 而它的 live 会话清单是**全部 CCC** 的(跨容器)。这是「方案 C」的既定代价,不是缺陷。
461
+ `;
462
+ function parseRegistry(raw) {
463
+ const data = JSON.parse(raw.replace(/^\uFEFF/, ""));
464
+ if (Array.isArray(data)) return data;
465
+ const entries = data.entries;
466
+ if (!Array.isArray(entries)) throw new Error("invalid registry: missing entries[]");
467
+ return entries;
468
+ }
469
+ /**
470
+ * 注册表单级化(需求⑤a S142 用户拍板:注册表只有一级——对齐 osp cccName 单聚合档):
471
+ * 所有 entry 集中写入 `.opencode/skills/<cccName>/references/mech-registry.json`
472
+ * (cccName = .serenity 首行,即顶层入口 skill 目录),skill 只作 entry 字段。
473
+ * 不再扫描/写入各 skill 目录的分散注册表(历史形态 v1.14~v1.27,register --skill
474
+ * 曾写入各自 skill 目录 —— 多真相源,废弃)。
475
+ */
476
+ function findRegistries(root) {
477
+ const cccName = readCccName(root);
478
+ if (!cccName) return [];
479
+ const aggregate = join(root, ".opencode", "skills", cccName, "references", "mech-registry.json");
480
+ return existsSync(aggregate) ? [aggregate] : [];
481
+ }
482
+ function loadMsmEntries(root) {
483
+ const byName = /* @__PURE__ */ new Map();
484
+ for (const regPath of findRegistries(root)) for (const entry of parseRegistry(readFileSync(regPath, "utf-8"))) if (!byName.has(entry.name)) byName.set(entry.name, entry);
485
+ return [...byName.values()];
486
+ }
487
+ function findEntry(root, name) {
488
+ return loadMsmEntries(root).find((e) => e.name === name) ?? null;
489
+ }
490
+ /**
491
+ * 扫描各 skill `scripts/` 下的**未注册候选**(DC-M3 正向基准,对齐 osp)。
492
+ *
493
+ * 🔴 **入口判据**(S151 轮 112 跨轨迹信号 → S142 实测精化,2026-09-17;取证见 S142 §0q):
494
+ * · **入口 ⟺ 有 shebang(`^#!`) ∨ 声明了 `function main(`** —— **`process.argv` 单独不算**。
495
+ * 反例(实测):`home-desk/scripts/lib/desk-dispatch.ts` 含 **5** 处 `process.argv`,
496
+ * 但无 shebang / 无 `main()`,且被 `desk-window.ts`、`desk-vision.ts` **import** ⇒ **它是库**。
497
+ * · **库 ⟺ 无 shebang ∧ 无 `main()` ∧ 被其它脚本 import** ⇒ **不是 MSM 候选,不上报**。
498
+ * ⚠️ **"有 `.test.ts`" 不是入口证据** —— 库**同样**有测试
499
+ * (`home-serenity/scripts/dev-kit/dev-kit.test.ts` 开头自述"**共享库单测**")。
500
+ * 📌 实现只测**前两条**(shebang / `main()`):第三条"被 import"是**描述性**的,不作判据 ——
501
+ * 因为**无 CLI 入口的文件本来就不可能被 `msm()` 调用**,上报它只会得到一条无法处置的欠账。
502
+ * 🔗 **与 DC-M2 自洽**:DC-M2 本就要求**已注册**脚本必须有 main() 守卫 ⇒ 把"无入口"的文件
503
+ * 排除出候选,与注册表的契约**同向**(两条判据不会互相打架)。
504
+ *
505
+ * 🔴 **本函数只扫 `scripts/` 一层(非递归),这是当前刻意的语义**(嵌套子域对容器不可见;
506
+ * 见 CCC `docs/t024-msm-skill-inventory.md` §3b)。**若将来改为递归,上面的判据就是护栏**:
507
+ * 嵌套候选实测 **8 个**(`dev-kit/` 5 + `home-desk/scripts/lib/` 3)**全部是库**——
508
+ * 不按判据过滤,一次递归就会制造 8 条假欠账,并可能诱使后续轮次把库注册成**假 MSM**。
509
+ * ⇒ **改递归前先读本条注释。**
510
+ */
511
+ function scanSkillScripts(root) {
512
+ const out = [];
513
+ const skillsDir = join(root, ".opencode", "skills");
514
+ if (!existsSync(skillsDir)) return out;
515
+ for (const skill of readdirSync(skillsDir)) {
516
+ const scriptsDir = join(skillsDir, skill, "scripts");
517
+ if (!existsSync(scriptsDir)) continue;
518
+ for (const f of readdirSync(scriptsDir)) {
519
+ const abs = join(scriptsDir, f);
520
+ let isFile = false;
521
+ try {
522
+ isFile = statSync(abs).isFile();
523
+ } catch {
524
+ continue;
525
+ }
526
+ if (!isFile) continue;
527
+ if (!/\.(ts|js|mjs)$/.test(f)) continue;
528
+ if (/\.(test|spec)\./.test(f)) continue;
529
+ if (!hasCliEntry(abs)) continue;
530
+ out.push(join(".opencode", "skills", skill, "scripts", f));
531
+ }
532
+ }
533
+ return out.sort();
534
+ }
535
+ /** 入口判据的机械实现(判据、反例与递归化护栏见 `scanSkillScripts` 注释) */
536
+ function hasCliEntry(absPath) {
537
+ let text;
538
+ try {
539
+ text = readFileSync(absPath, "utf-8");
540
+ } catch {
541
+ return true;
542
+ }
543
+ if (/^#!/.test(text)) return true;
544
+ return /\b(?:async\s+)?function\s+main\s*\(/.test(text);
545
+ }
546
+ /**
547
+ * **把 issue 列表压成「按检查码分组的一行摘要」**(D237:成功/失败类调用的返回精简)。
548
+ *
549
+ * ## 为什么需要它(owner 2026-10-24 令)
550
+ *
551
+ * > 「tool 的返回内容会影响 Agent 注意力……对于返回成功失败类的调用,如果成功要大幅精简;
552
+ * > 对于其它的调用,简练其结构,在确保信息有效的情况下降低大小」
553
+ *
554
+ * 实测症状(本仓真读数):`container_admin msm check` 返回 **36 条 issue,每条一个三字段对象**,
555
+ * 而其中 **M1 有 20 条、detail 逐字相同**(`no .test.ts / .spec.ts`)、**M3 有 16 条**(同样同 detail)。
556
+ * ⇒ 同一句话重复 20 遍**不携带额外信息**,只吃掉注意力。
557
+ *
558
+ * ## 判据(照 D237 分档)
559
+ *
560
+ * - **有 issue(失败面)⇒ 不省略任何一条**:失败条目是唯一能改变下一步的东西(D186「出声必须指名哪一半」)。
561
+ * 但可以**折叠结构**:同 `(check, detail)` 的条目合并成「一组 + 名字列表」,逐条信息一个字不丢。
562
+ * - **零 issue(成功面)⇒ 只留一行结论 + 计数**:通过的条目对下一步零信息量。
563
+ *
564
+ * @param report 原始报告(逐条 issue)
565
+ * @returns 压缩后的**面向 agent 的**结构(键名短、重复消除;entries 全保留只做分组)
566
+ */
567
+ function compactRegistryQuality(report) {
568
+ const byKey = /* @__PURE__ */ new Map();
569
+ for (const it of report.issues) {
570
+ const key = `${it.check}\u0000${it.detail}`;
571
+ const hit = byKey.get(key);
572
+ if (hit) hit.names.push(it.name);
573
+ else byKey.set(key, {
574
+ check: it.check,
575
+ detail: it.detail,
576
+ names: [it.name]
577
+ });
578
+ }
579
+ const groups = [...byKey.values()].sort((a, b) => a.check === b.check ? b.names.length - a.names.length : a.check < b.check ? -1 : 1).map((g) => ({
580
+ check: g.check,
581
+ detail: g.detail,
582
+ count: g.names.length,
583
+ names: g.names
584
+ }));
585
+ return {
586
+ checked: report.checked,
587
+ issueCount: report.issues.length,
588
+ groups
589
+ };
590
+ }
591
+ /**
592
+ * **注册表「质量契约」判据(DC-M1~M4)** —— 具名出口(C5 观察面归一,2026-09-15)。
593
+ *
594
+ * 此前这段判据**只存在于 `runMsm` 的 `case 'check'` 分支体内**(没有名字、无法从别处取用),
595
+ * 于是 `dashboard health` 只能另写一份 **不同判据** 的结构检查(`checkRegistryHealth`)。
596
+ * 抽成具名函数后,两条判据**各自有名字、各自可被取用、且仍然完全独立**——合并它们是错的:
597
+ * 本判据的主语是**条目契约**(有没有测试 / main() 守卫 / 双向注册 / path 型 flag),
598
+ * 结构判据的主语是**文件能否安全解析**。同一份注册表在两条判据下可以给出相反结论。
599
+ *
600
+ * 行为与抽取前**逐字一致**(`runMsm` 的 `check` 分支现只做 `return checkRegistryQuality(root)`)。
601
+ */
602
+ function checkRegistryQuality(root) {
603
+ const entries = loadMsmEntries(root);
604
+ const issues = [];
605
+ const norm = (p) => p.split("\\").join("/").toLowerCase();
606
+ const registeredPaths = new Set(entries.map((e) => norm(e.path)));
607
+ for (const scriptPath of scanSkillScripts(root)) if (!registeredPaths.has(norm(scriptPath))) issues.push({
608
+ name: scriptPath,
609
+ check: "M3",
610
+ detail: "script not registered in mech-registry"
611
+ });
612
+ for (const e of entries) {
613
+ const script = join(root, e.path);
614
+ const scriptExists = existsSync(script);
615
+ if (!scriptExists) issues.push({
616
+ name: e.name,
617
+ check: "M3",
618
+ detail: `script missing (${e.path})`
619
+ });
620
+ const testFileTs = script.replace(/\.ts$/, ".test.ts");
621
+ const testFileSpec = script.replace(/\.ts$/, ".spec.ts");
622
+ if (!existsSync(testFileTs) && !existsSync(testFileSpec)) issues.push({
623
+ name: e.name,
624
+ check: "M1",
625
+ detail: "no .test.ts / .spec.ts"
626
+ });
627
+ if (scriptExists) {
628
+ const src = readFileSync(script, "utf-8");
629
+ if (!(/function main\(/.test(src) || /\bisMain\b/.test(src) || /require\.main\s*===/.test(src) || /import\.meta\.url/.test(src))) issues.push({
630
+ name: e.name,
631
+ check: "M2",
632
+ detail: "no main() guard"
633
+ });
634
+ }
635
+ for (const f of e.flags ?? []) if ("name" in f && /path|file|dir/i.test(f.name) && f.type !== "path") issues.push({
636
+ name: e.name,
637
+ check: "M4",
638
+ detail: `flag --${f.name} should be type:"path"`
639
+ });
640
+ }
641
+ return {
642
+ checked: entries.length,
643
+ issues
644
+ };
645
+ }
646
+ function registryPathFor(root, _skill) {
647
+ const cccName = readCccName(root) ?? "unknown";
648
+ return join(root, ".opencode", "skills", cccName, "references", "mech-registry.json");
649
+ }
650
+ function writeRegistry(path, entries, isV1Wrapped = true) {
651
+ mkdirSync(dirname(path), { recursive: true });
652
+ const payload = isV1Wrapped ? JSON.stringify({
653
+ version: 1,
654
+ description: "MSM registry (managed by acc-msm / dsh-serenity-hooks)",
655
+ entries
656
+ }, null, 2) + "\n" : JSON.stringify(entries, null, 2) + "\n";
657
+ writeFileSync(path, payload, "utf-8");
658
+ }
659
+ function runMsm(root, args) {
660
+ switch (args.action) {
661
+ case "list": {
662
+ const entries = loadMsmEntries(root);
663
+ const cccName = readCccName(root) ?? "unknown";
664
+ const header = `(serenity-plugin v${ACC_VERSION}) CCC:${cccName} Root:${root}`;
665
+ if (entries.length === 0) return `${header}\n(no MSM registered)`;
666
+ const lines = entries.map((e) => {
667
+ const base = `${e.name} | ${e.skill ?? "-"} | ${e.category ?? "-"} | ${e.description ?? ""}`;
668
+ if (e.flags && e.flags.length > 0) return `${base} [flags: ${e.flags.map((f) => `--${f.name} <${f.type ?? "string"}>`).join(", ")}]`;
669
+ return base;
670
+ });
671
+ return `${header}\n` + lines.join("\n");
672
+ }
673
+ case "guide": return { guide: MSM_GUIDE };
674
+ case "catalog": return { catalog: docsPointer("catalog", "ACC 能力目录") };
675
+ case "exec": {
676
+ const { entry, businessArgs, fmtJson, hasHelp, protocol } = prepareExec(root, args);
677
+ const p = protocolResult(protocol);
678
+ if (p !== void 0) return p;
679
+ const baseOpts = {
680
+ cwd: root,
681
+ encoding: "utf-8",
682
+ timeout: MSM_TIMEOUT_MS,
683
+ stdio: [
684
+ "pipe",
685
+ "pipe",
686
+ "pipe"
687
+ ],
688
+ env: buildMsmEnv(root)
689
+ };
690
+ const bunBin = bunExecutablePath();
691
+ let r = bunBin ? spawnSync(bunBin, [entry.path, ...businessArgs], baseOpts) : {
692
+ error: { code: "ENOENT" },
693
+ status: null,
694
+ stdout: "",
695
+ stderr: "",
696
+ pid: 0,
697
+ signal: null
698
+ };
699
+ if (r.error && isBunMissing(r.error)) r = spawnSync(NPX_BIN, [
700
+ "tsx",
701
+ entry.path,
702
+ ...businessArgs
703
+ ], {
704
+ ...baseOpts,
705
+ shell: platform$1 === "win32"
706
+ });
707
+ return msmExecResult(entry.name, r.status ?? 2, r.stdout ?? "", r.stderr ?? "", fmtJson, hasHelp);
708
+ }
709
+ case "register": {
710
+ const name = args.name ?? "";
711
+ const { skill, path, category, description } = args;
712
+ if (!name) throw new Error("register requires name");
713
+ if (!path || !category || !description) throw new Error("register requires path/category/description");
714
+ const scriptAbs = resolve(root, path);
715
+ if (classifyPath(scriptAbs, root) === "outside") throw new Error(`MSM register: path "${path}" escapes CCC root "${root}"`);
716
+ if (!existsSync(scriptAbs)) throw new Error(`MSM script not found: "${path}"`);
717
+ if (loadMsmEntries(root).some((e) => e.name === name)) throw new Error(`MSM already registered: "${name}"`);
718
+ const regPath = registryPathFor(root, skill);
719
+ const raw = existsSync(regPath) ? readFileSync(regPath, "utf-8").replace(/^\uFEFF/, "") : "";
720
+ const isV1Wrapped = raw === "" ? true : !Array.isArray(JSON.parse(raw));
721
+ const entries = existsSync(regPath) ? parseRegistry(readFileSync(regPath, "utf-8")) : [];
722
+ let flags;
723
+ if (args.flags) try {
724
+ const parsed = JSON.parse(args.flags);
725
+ if (!Array.isArray(parsed)) throw new Error("flags must be a JSON array");
726
+ flags = parsed;
727
+ } catch (e) {
728
+ throw new Error(`register flags parse failed: ${e instanceof Error ? e.message : String(e)}`);
729
+ }
730
+ entries.push({
731
+ name,
732
+ path,
733
+ skill,
734
+ category,
735
+ description,
736
+ usage: args.usage ?? `msm ${name} [args...]`,
737
+ flags: flags ?? []
738
+ });
739
+ writeRegistry(regPath, entries, isV1Wrapped);
740
+ try {
741
+ const relRegistry = relative(root, regPath);
742
+ execFileSync("git", [
743
+ "add",
744
+ "--",
745
+ relRegistry
746
+ ], {
747
+ cwd: root,
748
+ stdio: "pipe"
749
+ });
750
+ execFileSync("git", [
751
+ "commit",
752
+ "-m",
753
+ `chore(msm): register ${name}`
754
+ ], {
755
+ cwd: root,
756
+ stdio: "pipe"
757
+ });
758
+ } catch {}
759
+ return {
760
+ registered: name,
761
+ registry: relative(root, regPath)
762
+ };
763
+ }
764
+ case "deregister": {
765
+ const name = args.name ?? "";
766
+ for (const regPath of findRegistries(root)) {
767
+ const dRaw = readFileSync(regPath, "utf-8").replace(/^\uFEFF/, "");
768
+ const isV1Wrapped = !Array.isArray(JSON.parse(dRaw));
769
+ const entries = parseRegistry(readFileSync(regPath, "utf-8"));
770
+ const idx = entries.findIndex((e) => e.name === name);
771
+ if (idx >= 0) {
772
+ entries.splice(idx, 1);
773
+ writeRegistry(regPath, entries, isV1Wrapped);
774
+ try {
775
+ const relRegistry = relative(root, regPath);
776
+ execFileSync("git", [
777
+ "add",
778
+ "--",
779
+ relRegistry
780
+ ], {
781
+ cwd: root,
782
+ stdio: "pipe"
783
+ });
784
+ execFileSync("git", [
785
+ "commit",
786
+ "-m",
787
+ `chore(msm): deregister ${name}`
788
+ ], {
789
+ cwd: root,
790
+ stdio: "pipe"
791
+ });
792
+ } catch {}
793
+ return { deregistered: name };
794
+ }
795
+ }
796
+ throw new Error(`MSM not registered: "${name}"`);
797
+ }
798
+ case "ccc-config": return docsPointer("config", "CCC 配置参考");
799
+ case "check": return compactRegistryQuality(checkRegistryQuality(root));
800
+ default: throw new Error(`Unknown action: ${args.action}`);
801
+ }
802
+ }
803
+ function prepareExec(root, args) {
804
+ const name = args.name ?? "";
805
+ const entry = findEntry(root, name);
806
+ if (!entry) throw new Error(`MSM not registered: "${name}"`);
807
+ const business = args.args ?? [];
808
+ if (business[0] === "--list") return {
809
+ entry,
810
+ businessArgs: [],
811
+ fmtJson: false,
812
+ hasHelp: false,
813
+ protocol: { list: loadMsmEntries(root).map((e) => ({
814
+ name: e.name,
815
+ category: e.category ?? null
816
+ })) }
817
+ };
818
+ if (business[0] === "--schema") {
819
+ const target = business[1];
820
+ const found = target ? loadMsmEntries(root).find((e) => e.name === target) : null;
821
+ if (!found) throw new Error(`MSM not registered: "${target}"`);
822
+ return {
823
+ entry,
824
+ businessArgs: [],
825
+ fmtJson: false,
826
+ hasHelp: false,
827
+ protocol: { schema: {
828
+ name: found.name,
829
+ path: found.path,
830
+ flags: (found.flags ?? []).map((f) => ({
831
+ name: f.name,
832
+ type: f.type ?? null,
833
+ description: f.description ?? null
834
+ }))
835
+ } }
836
+ };
837
+ }
838
+ const fmtJson = business[0] === "--format=json";
839
+ const businessArgs = fmtJson ? business.slice(1) : business;
840
+ const hasHelp = businessArgs.includes("--help") || businessArgs.includes("-h");
841
+ for (const flag of entry.flags ?? []) {
842
+ if (flag.type !== "path") continue;
843
+ const eq = businessArgs.find((a) => a.startsWith(`--${flag.name}=`));
844
+ if (eq) assertPathInsideRoot(root, eq.slice(flag.name.length + 3), flag.name);
845
+ else {
846
+ const idx = businessArgs.indexOf(`--${flag.name}`);
847
+ if (idx >= 0 && businessArgs[idx + 1]) assertPathInsideRoot(root, businessArgs[idx + 1], flag.name);
848
+ }
849
+ }
850
+ const script = resolve(root, entry.path);
851
+ if (classifyPath(script, root) === "outside") throw new Error(`MSM script escapes CCC root: "${entry.path}"`);
852
+ if (!existsSync(script)) throw new Error(`MSM script not found: "${entry.path}"`);
853
+ return {
854
+ entry: {
855
+ ...entry,
856
+ path: script
857
+ },
858
+ businessArgs,
859
+ fmtJson,
860
+ hasHelp
861
+ };
862
+ }
863
+ /** 协议结果扁平化:{list|schema} 包装 → 顶层值(兼容旧契约);非协议返回 undefined */
864
+ function protocolResult(protocol) {
865
+ if (!protocol) return void 0;
866
+ if ("list" in protocol) return protocol.list;
867
+ if ("schema" in protocol) return protocol.schema;
868
+ }
869
+ /** 失败 TIP(对齐 osp:业务 exit≠0 且未传 --help 时追加提示) */
870
+ function helpTip() {
871
+ return "\n[TIP] Pass \"--help\" as the first arg to see this MSM's usage and required flags.";
872
+ }
873
+ function msmExecResult(name, status, stdout, stderr, fmtJson, hasHelp = false) {
874
+ const tip = status !== 0 && !hasHelp ? helpTip() : "";
875
+ if (fmtJson) return status === 0 ? {
876
+ name,
877
+ exit: 0,
878
+ ok: true,
879
+ data: stdout.trim()
880
+ } : {
881
+ name,
882
+ exit: status,
883
+ ok: false,
884
+ error: (stderr.trim() || stdout.trim()) + tip
885
+ };
886
+ return {
887
+ name,
888
+ exit: status,
889
+ stdout,
890
+ stderr: stderr + tip
891
+ };
892
+ }
893
+ /**
894
+ * 异步执行 MSM(msm 工具主路径):
895
+ * 用 execFile + promisify + timeout(超时自动 kill),**不阻塞 Node 事件循环**。
896
+ * (同步 spawnSync 版会阻塞 web 事件循环 → MSM 脚本自请求 3080 时死锁,见 postmortem。)
897
+ */
898
+ /** bun 缺失的错误码集(Windows 兼容:无 bun 时 execFile('bun') 抛 EINVAL 而非 ENOENT,见 Windows 审计问题 5) */
899
+ const BUN_MISSING_CODES = /* @__PURE__ */ new Set([
900
+ "ENOENT",
901
+ "EINVAL",
902
+ "EPERM"
903
+ ]);
904
+ function isBunMissing(err) {
905
+ return typeof err.code === "string" && BUN_MISSING_CODES.has(err.code);
906
+ }
907
+ async function runMsmAsync(root, args) {
908
+ if (args.action !== "exec") return runMsm(root, args);
909
+ const { entry, businessArgs, fmtJson, hasHelp, protocol } = prepareExec(root, args);
910
+ const p = protocolResult(protocol);
911
+ if (p !== void 0) return p;
912
+ const bunBin = bunExecutablePath();
913
+ const baseOpts = {
914
+ cwd: root,
915
+ encoding: "utf-8",
916
+ timeout: MSM_TIMEOUT_MS,
917
+ maxBuffer: 67108864,
918
+ env: buildMsmEnv(root)
919
+ };
920
+ try {
921
+ if (bunBin) {
922
+ const r = await execFileAsync(bunBin, [entry.path, ...businessArgs], baseOpts);
923
+ return msmExecResult(entry.name, 0, r.stdout, r.stderr, fmtJson, hasHelp);
924
+ }
925
+ throw { code: "ENOENT" };
926
+ } catch (e) {
927
+ const err = e;
928
+ if (isBunMissing(err)) try {
929
+ const r = await execFileAsync(NPX_BIN, [
930
+ "tsx",
931
+ entry.path,
932
+ ...businessArgs
933
+ ], {
934
+ ...baseOpts,
935
+ shell: platform$1 === "win32"
936
+ });
937
+ return msmExecResult(entry.name, 0, r.stdout, r.stderr, fmtJson, hasHelp);
938
+ } catch (e2) {
939
+ const err2 = e2;
940
+ const status = err2.killed ? 124 : typeof err2.code === "number" ? err2.code : 2;
941
+ const stdout = err2.stdout ?? "";
942
+ const stderr = err2.killed ? `MSM timed out after ${MSM_TIMEOUT_MS}ms` : err2.stderr ?? err2.message ?? "";
943
+ return msmExecResult(entry.name, status, stdout, stderr, fmtJson, hasHelp);
944
+ }
945
+ const status = err.killed ? 124 : typeof err.code === "number" ? err.code : 2;
946
+ const stdout = err.stdout ?? "";
947
+ const stderr = err.killed ? `MSM timed out after ${MSM_TIMEOUT_MS}ms` : err.stderr ?? err.message ?? "";
948
+ return msmExecResult(entry.name, status, stdout, stderr, fmtJson, hasHelp);
949
+ }
950
+ }
951
+ //#endregion
952
+ //#region src/seams/tool-visibility.ts
953
+ /** 收集器:把各来源的 deny 合并成一个判决(**纯函数、无副作用**) */
954
+ function collectVisibilityDecision(sources) {
955
+ const deny = /* @__PURE__ */ new Set();
956
+ let hasError = false;
957
+ for (const s of sources) {
958
+ if (s.error !== void 0) hasError = true;
959
+ if (!s.ok) continue;
960
+ for (const name of s.deny) if (typeof name === "string" && name !== "") deny.add(name);
961
+ }
962
+ return {
963
+ deny: [...deny].sort(),
964
+ sources,
965
+ hasError
966
+ };
967
+ }
968
+ /** 判决签名(幂等判据:签名相同 ⇒ 目标态已达到 ⇒ **一次 restrict 都不发**) */
969
+ function decisionSignature(decision) {
970
+ return decision.deny.join(",");
971
+ }
972
+ /** `${sessionId}` → 已发射状态(**全模块唯一的一份**) */
973
+ const applied = /* @__PURE__ */ new Map();
974
+ /**
975
+ * 发射一次判决(**唯一的写点**)。
976
+ *
977
+ * 三条纪律:
978
+ * ① **幂等**:签名与当前已发射的一致 ⇒ 直接返回 `'unchanged'`,**不碰宿主**
979
+ * (这正是消除"工具清单闪动"的那一步)。
980
+ * ② **先撤后发**:宿主的 disposer 才是"撤销上一次",先撤再发才不会叠加旧集合。
981
+ * (旧集合是新集合的子集时叠不叠都看不出来,但**不是子集时**会残留 —— 必须撤。)
982
+ * ③ 🔴 **未注册的名字不许炸掉整次发射**:宿主契约"unknown names … fail"。
983
+ * 合并后集合里只要有一个不在册的名字,整次 restrict 就失败 ⇒ 六个机制**全都不生效**。
984
+ * ⇒ 策略 = **直接发 → 被拒时从宿主的错误文案里解析出它点名的未知名字 → 只剔那几个、其余重发一次**。
985
+ * 被剔掉的名字进 `dropped`(调用方决定要不要告警)。
986
+ * ⚠️ **不做 `tools.get` 预探活** —— 那个视图查不出 agent-plane 贡献的工具
987
+ * (`bash` / `schedule_*`),会把**在册的名字误判成未注册**(见下方发射段的实测记录)。
988
+ * 🔴 **也不按"谁注册的"降级** —— `REGISTERED_TOOLS` 只含 ACC 自己的 14 个,
989
+ * 用它当"在册面"会**整类丢掉宿主工具**(2026-10-24 真机缺陷,见发射段)。
990
+ * ⇒ 唯一合法的在册判据是**宿主自己的回答**:要么发一次成功,要么它说出哪些名字不认识。
991
+ *
992
+ * @returns `'unchanged'` / `'applied'` / `'failed'`(`failed` 时 `deny` 保持原样)
993
+ */
994
+ function applyVisibilityDecision(agent, sessionId, decision) {
995
+ const signature = decisionSignature(decision);
996
+ const existing = applied.get(sessionId);
997
+ if (existing !== void 0 && existing.signature === signature) return {
998
+ status: "unchanged",
999
+ dropped: []
1000
+ };
1001
+ if (existing !== void 0) {
1002
+ try {
1003
+ existing.dispose();
1004
+ } catch {}
1005
+ applied.delete(sessionId);
1006
+ }
1007
+ const tools = agent.ctx.tools;
1008
+ const dropped = [];
1009
+ const names = decision.deny;
1010
+ if (names.length === 0) {
1011
+ applied.set(sessionId, {
1012
+ signature,
1013
+ deny: [],
1014
+ dispose: () => {}
1015
+ });
1016
+ return {
1017
+ status: "applied",
1018
+ dropped
1019
+ };
1020
+ }
1021
+ try {
1022
+ const dispose = tools.restrict({ deny: [...names] });
1023
+ applied.set(sessionId, {
1024
+ signature,
1025
+ deny: names,
1026
+ dispose
1027
+ });
1028
+ return {
1029
+ status: "applied",
1030
+ dropped
1031
+ };
1032
+ } catch (firstError) {
1033
+ const message = errorText(firstError);
1034
+ const unknown = parseUnknownToolNames(message);
1035
+ if (unknown.length === 0) return {
1036
+ status: "failed",
1037
+ dropped,
1038
+ error: message
1039
+ };
1040
+ const unknownSet = new Set(unknown);
1041
+ const remaining = names.filter((n) => !unknownSet.has(n));
1042
+ dropped.push(...names.filter((n) => unknownSet.has(n)));
1043
+ if (remaining.length === 0) return {
1044
+ status: "failed",
1045
+ dropped,
1046
+ error: message
1047
+ };
1048
+ try {
1049
+ const dispose = tools.restrict({ deny: remaining });
1050
+ applied.set(sessionId, {
1051
+ signature: decisionSignature({
1052
+ ...decision,
1053
+ deny: remaining
1054
+ }),
1055
+ deny: remaining,
1056
+ dispose
1057
+ });
1058
+ return {
1059
+ status: "applied",
1060
+ dropped
1061
+ };
1062
+ } catch (secondError) {
1063
+ return {
1064
+ status: "failed",
1065
+ dropped,
1066
+ error: errorText(secondError)
1067
+ };
1068
+ }
1069
+ }
1070
+ }
1071
+ /** 取异常的可读文案(非 Error 也能取到 message;都没有则 String()) */
1072
+ function errorText(e) {
1073
+ if (e instanceof Error) return e.message;
1074
+ if (typeof e === "object" && e !== null && "message" in e && typeof e.message === "string") return e.message;
1075
+ return String(e);
1076
+ }
1077
+ /**
1078
+ * 从宿主的 `tools.restrict()` 错误文案里解析出**被点名的未知工具名**。
1079
+ *
1080
+ * 宿主逐字形态(`dsh-tools/lib/types/index.js` 的 `restrict()`):
1081
+ * ```
1082
+ * tools.restrict() names unknown global tool(s) "a", "b"; known global tools: x, y, z
1083
+ * ```
1084
+ * 单数时是 `tool`,复数时是 `tool(s)`——两种都匹配。
1085
+ *
1086
+ * 🔴 **只取分号之前那一段**:分号后是 `known global tools:` 清单,**不能**误当成未知名字。
1087
+ * 🔴 解析不出来就返回 `[]`(调用方据此走"如实失败、不记账"),**绝不猜**。
1088
+ */
1089
+ function parseUnknownToolNames(message) {
1090
+ const head = message.split(";")[0] ?? "";
1091
+ if (!/names unknown global tool/.test(head)) return [];
1092
+ const out = [];
1093
+ for (const m of head.matchAll(/"([^"]+)"/g)) {
1094
+ const name = m[1];
1095
+ if (name !== void 0 && name !== "") out.push(name);
1096
+ }
1097
+ return [...new Set(out)];
1098
+ }
1099
+ /** 会话销毁清理(防 per-会话 Map 无界增长;lifecycle 调用) */
1100
+ function forgetToolVisibility(sessionId) {
1101
+ const existing = applied.get(sessionId);
1102
+ if (existing === void 0) return;
1103
+ try {
1104
+ existing.dispose();
1105
+ } catch {}
1106
+ applied.delete(sessionId);
1107
+ }
1108
+ //#endregion
1109
+ //#region src/seams/guards.ts
1110
+ /**
1111
+ * guards.ts — 拦截缝:安全模式 + 路径守卫(P3 语义的机械层)
1112
+ *
1113
+ * 纯决策逻辑(decideGuard)与 DSH 注册(registerGuards)分离:
1114
+ * 前者零 DSH 依赖可单测,后者把决策接到 tools/pre-execute 瀑布 + ctx.tools.guard 终局。
1115
+ *
1116
+ * 对应 opencode-serenity-plugin 的 tool.execute.before 路径守卫 + bash 开关 + 黑名单。
1117
+ */
1118
+ /** 写类工具名(黑名单/治理文件只拦这些;读工具只做路径越界检查——对齐 osp) */
1119
+ const WRITE_TOOLS = /* @__PURE__ */ new Set([
1120
+ "write",
1121
+ "edit",
1122
+ "str_replace_editor",
1123
+ "container_fs",
1124
+ "bash",
1125
+ "touch"
1126
+ ]);
1127
+ /** container_fs 的写类子命令(mkdir/rm/mv/cp/touch);其余 9 子命令(root/resolve/exists/
1128
+ * list/tree/relative/reveal/info/find)为只读——只读子命令不查黑名单(同 read 语义)
1129
+ * 🔴 `append` 已于 v1.101.0 退役(owner 2026-10-26),故不在此集合。 */
1130
+ const CC_FS_WRITE_ACTIONS = /* @__PURE__ */ new Set([
1131
+ "mkdir",
1132
+ "rm",
1133
+ "mv",
1134
+ "cp",
1135
+ "touch"
1136
+ ]);
1137
+ /**
1138
+ * 凭据文件硬名单(数据面守卫,v1.26.3,S142 用户:localstore.json 需要在 read 工具的黑名单里):
1139
+ * **任何工具**(含 read/grep/glob/container_fs 只读子命令)命中这些相对 CCC 根的路径 → deny。
1140
+ *
1141
+ * 与 safeMode.blacklist(写黑名单,v1.18.5 决策只拦写)语义独立:
1142
+ * - 黑名单 = 用户可配置的写保护(REPOSITORIES/ 等只读参考源不误伤读)
1143
+ * - 凭据硬名单 = **结构性数据面边界**(不依赖 safe-mode 开关,凭据文件任何时候不可读)——
1144
+ * 对齐 CCE Bounded Space + wardn "structural guarantee, not policy":localstore.json
1145
+ * 含 API keys/tokens/SSH 密码,即使 prompt 纪律失败也无值可吐(机械保证)。
1146
+ */
1147
+ const SENSITIVE_CREDENTIAL_FILES = /* @__PURE__ */ new Set(["localstore.json"]);
1148
+ /**
1149
+ * MSM 注册表写保护(需求⑤b S142 用户拍板:"msm注册的文件需要写保护起来,避免CCC意外写坏搞崩自己"):
1150
+ * mech-registry.json 是 CCC 执行层地基(坏 → loadMsmEntries 抛 → container_admin/output-guard 全崩,
1151
+ * register 也崩 → 自锁)。**写 deny、读 allow**(与 localstore 读 deny 语义区分 R6——注册表是
1152
+ * 结构核心不是秘密,需被 output-guard/trajectory-constraint-admin 读取建 MSM 词表)。
1153
+ *
1154
+ * 单级化(需求⑤a)后**唯一合法注册表 = cccName 聚合档**
1155
+ * `.opencode/skills/<cccName>/references/mech-registry.json`(cccName = .serenity 首行)。
1156
+ * 历史 root 级 `mech-registry.json` 与各 skill 分散注册表已废弃:**不再保护**
1157
+ * (review P1:保护一个永不被读的文件 = 死锁——MSM 既不可见又不可删/迁移)。
1158
+ * 写工具命中聚合档 → deny(唯一合法写通道 = container_admin msm register/deregister
1159
+ * 内部 writeRegistry,走工具实现不经 pre-execute → 天然豁免);读工具放行。
1160
+ */
1161
+ function isProtectedRegistryRel(root, rel) {
1162
+ const lower = (s) => process.platform === "win32" ? s.toLowerCase() : s;
1163
+ const relCi = lower(rel);
1164
+ try {
1165
+ const marker = resolve(root, ".serenity");
1166
+ if (existsSync(marker)) {
1167
+ const line = readCccName$1(root);
1168
+ if (line) {
1169
+ if (relCi === lower(`.opencode/skills/${line}/references/mech-registry.json`)) return true;
1170
+ if (relCi === lower(`.opencode/skills/${line}/references`)) return true;
1171
+ }
1172
+ }
1173
+ } catch {}
1174
+ return false;
1175
+ }
1176
+ /** 判定工具是否为写类:普通工具按名;container_fs 复合工具按子命令 action */
1177
+ function isWriteTool(toolName, action) {
1178
+ if (!WRITE_TOOLS.has(toolName)) return false;
1179
+ if (toolName === "container_fs") return action !== void 0 && CC_FS_WRITE_ACTIONS.has(action);
1180
+ return true;
1181
+ }
1182
+ /**
1183
+ * 安全模式 + 黑名单 + P3 路径守卫的纯决策。
1184
+ * 对齐 opencode-serenity-plugin 标准:安全模式 = **bash 禁用** + 写入黑名单;
1185
+ * write/edit 等工具仅受路径逃逸与黑名单约束(不整体禁用)。
1186
+ * 优先级:safe-mode bash > 路径越界 > 黑名单命中。
1187
+ */
1188
+ function decideGuard(input) {
1189
+ const { root, toolName, safeModeOn, blacklist, pathArg, action } = input;
1190
+ if (input.trajectoryConstraint !== void 0 && input.trajectoryConstraint !== null) {
1191
+ const profile = input.trajectoryConstraint;
1192
+ if (toolName === "skill") return { kind: "allow" };
1193
+ if ((profile.msms ?? []).includes(toolName)) return {
1194
+ deny: `"${toolName}" is a registered MSM, not a direct tool in this trajectory — call it through the msm tool: msm("${toolName}", ["<args>"]) (pass "--help" or inspect=true for usage)`,
1195
+ kind: "deny"
1196
+ };
1197
+ const whitelist = profileToolWhitelist(profile);
1198
+ if (whitelist !== null && !whitelist.has(toolName)) return {
1199
+ deny: "tool not allowed in this 轨迹约束 trajectory",
1200
+ kind: "deny"
1201
+ };
1202
+ const writeScopes = profileWriteWhitelist(profile);
1203
+ if (writeScopes !== null && pathArg !== void 0 && isWriteTool(toolName, action)) {
1204
+ const abs = resolve(root, pathArg);
1205
+ if (!writeScopes.some((scope) => pathInside(resolve(root, scope), abs))) return {
1206
+ deny: `${toolName}: write outside this trajectory's declared write scope (allowed: ${writeScopes.length > 0 ? writeScopes.join(" / ") : "(none — \"writes\" is an empty array)"}); target: ${pathArg} — add the prefix to this trajectory's trajectory.json "writes" if the write is intended`,
1207
+ kind: "deny"
1208
+ };
1209
+ }
1210
+ }
1211
+ if (safeModeOn && toolName === "bash") return {
1212
+ deny: `bash: no such tool`,
1213
+ kind: "deny"
1214
+ };
1215
+ if (pathArg !== void 0) {
1216
+ const abs = resolve(root, pathArg);
1217
+ if (!pathInside(resolve(root), abs)) return {
1218
+ deny: `path escape blocked: "${pathArg}" escapes the CCC root`,
1219
+ kind: "deny"
1220
+ };
1221
+ const rel = relative(root, abs).split("\\").join("/");
1222
+ if (SENSITIVE_CREDENTIAL_FILES.has(rel)) return {
1223
+ deny: `access blocked: "${pathArg}" is a sensitive credential file (${rel})`,
1224
+ kind: "deny"
1225
+ };
1226
+ if (isWriteTool(toolName, action)) {
1227
+ if (rel === ".serenity-safe-on" || rel === ".serenity" || rel.startsWith(".serenity-safe-on/") || rel.startsWith(".serenity/")) return {
1228
+ deny: `CCC governance file "${rel}" is reserved for the user — agent must not write`,
1229
+ kind: "deny"
1230
+ };
1231
+ if (isProtectedRegistryRel(root, rel)) return {
1232
+ deny: rel.endsWith("/mech-registry.json") ? `mech-registry.json is ACC-managed (${rel}) — use container_admin msm register/deregister instead of writing it directly` : `"${rel}" is an ancestor of the ACC-managed mech-registry.json — removing/moving it would destroy the registry; the registry is managed by container_admin msm register/deregister`,
1233
+ kind: "deny"
1234
+ };
1235
+ const hit = matchBlacklist(rel, blacklist);
1236
+ if (hit) return {
1237
+ deny: hit.message ?? `blacklist blocked: "${pathArg}" matches rule "${hit.pattern}"`,
1238
+ kind: "deny"
1239
+ };
1240
+ }
1241
+ }
1242
+ return { kind: "allow" };
1243
+ }
1244
+ /**
1245
+ * 🔴 **工具可见性的唯一发射入口**(方案 C 的落地)。
1246
+ *
1247
+ * ## 它取代了什么
1248
+ *
1249
+ * 改之前有六条独立 sync,每条在自己的 per-会话 Map 里放一个 `restrict` disposer,
1250
+ * 且都由 `pre-step`(每步)与 `seed`(冷载入)各自调用 ⇒ 一次交互里可能发出多次
1251
+ * `tools.restrict` ⇒ 宿主每次发 `tools/change` ⇒ 模型侧看到工具清单**先进后出**
1252
+ * (实测症状:`新增 7 个` 紧跟 `移除 7 个`)。
1253
+ *
1254
+ * ## 为什么合并是对的(不是简化)
1255
+ *
1256
+ * 宿主 `ToolRuntime.restrict` 契约逐字:**Restrictions intersect**。
1257
+ * ⇒ N 次 `deny: Dᵢ` 的净效果 = `⋃Dᵢ`。先求并集只发一次,**结果相同**、只触发一次 `tools/change`。
1258
+ * 前提 = **只用 `deny`**(`allow` 的"交集"不满足并集律);本仓 18 处 restrict 全部只发 `deny`。
1259
+ *
1260
+ * ## 正确性怎么保住(这是本设计被质疑的那一点)
1261
+ *
1262
+ * 六源**各自独立**算自己的 deny 并**各自 catch**:某一个来源读配置抛错 ⇒ 它返回
1263
+ * `ok:false`(不贡献 deny),**其余五源照常生效**。⇒ 合并**不会**把一格的失败放大成六格。
1264
+ * 全量计算见 {@link computeVisibilitySources}(纯函数)。
1265
+ *
1266
+ * @param agent 目标会话
1267
+ * @param root CCC 根;`null` ⇒ 不在 CCC 内 ⇒ 隐藏**全部** ACC 工具
1268
+ */
1269
+ function syncToolVisibility(agent, root) {
1270
+ const sessionId = agent.session.id ?? "global";
1271
+ const sources = computeVisibilitySources(sessionId, root);
1272
+ const decision = collectVisibilityDecision(sources);
1273
+ if (root !== null && decision.hasError) {
1274
+ const bad = sources.filter((s) => s.error !== void 0);
1275
+ for (const s of bad) console.error(`[serenity-hooks] 工具可见性来源「${s.id}」判据失败(其余来源照常生效): ${s.error ?? ""}`);
1276
+ }
1277
+ const r = applyVisibilityDecision(agent, sessionId, decision);
1278
+ if (r.status === "failed") console.error(`[serenity-hooks] 工具可见性发射失败 (session=${sessionId}): ${r.error ?? ""}`);
1279
+ else if (r.dropped.length > 0) console.log(`[serenity-hooks] 工具可见性:跳过 ${r.dropped.length} 个未注册名(${r.dropped.join(", ")})`);
1280
+ }
1281
+ /**
1282
+ * 六源 → 各自的 deny 集合(**纯函数:只读配置、不碰宿主**)。
1283
+ *
1284
+ * 每条来源**自己负责自己的失败**:抛错就 catch 在这里,转成 `ok:false + error`。
1285
+ * ⛔ 绝不让一个来源的异常冒泡 —— 那会让另外五源一起失效(正是合并最容易犯的错)。
1286
+ */
1287
+ function computeVisibilitySources(sessionId, root) {
1288
+ const out = [];
1289
+ out.push(sourceOf("nonCcc", () => {
1290
+ if (root !== null) return { deny: [] };
1291
+ return { deny: [...REGISTERED_TOOLS] };
1292
+ }));
1293
+ if (root === null) {
1294
+ for (const id of [
1295
+ "safeMode",
1296
+ "imBridge",
1297
+ "hostSchedule",
1298
+ "trajectoryConstraint"
1299
+ ]) out.push({
1300
+ id,
1301
+ deny: [],
1302
+ ok: false
1303
+ });
1304
+ return out;
1305
+ }
1306
+ out.push(sourceOf("safeMode", () => {
1307
+ if (!isSafeModeOn(root)) return { deny: [] };
1308
+ let declared = [];
1309
+ try {
1310
+ declared = readSafeModeHiddenTools(root);
1311
+ } catch (e) {
1312
+ return {
1313
+ deny: [...SAFE_MODE_DENY_TOOLS],
1314
+ error: e.message
1315
+ };
1316
+ }
1317
+ return { deny: [.../* @__PURE__ */ new Set([...SAFE_MODE_DENY_TOOLS, ...declared])] };
1318
+ }));
1319
+ out.push(sourceOf("imBridge", () => isImWiredTrajectory(root, trajectoryCodeForSession(root, sessionId)) ? { deny: [] } : { deny: [IM_BRIDGE_TOOL_NAME] }));
1320
+ out.push(sourceOf("hostSchedule", () => ({ deny: [...HOST_SCHEDULE_TOOL_NAMES] })));
1321
+ out.push(sourceOf("trajectoryConstraint", () => {
1322
+ const profile = trajectoryConstraintForSession(root, sessionId);
1323
+ if (profile === null) return { deny: [] };
1324
+ const allowed = profileToolWhitelist(profile);
1325
+ if (allowed === null) return { deny: [] };
1326
+ return { deny: REGISTERED_TOOLS.filter((n) => !allowed.has(n)) };
1327
+ }));
1328
+ out.push(sourceOf("alwaysDeny", () => ({ deny: [...ALWAYS_DENY_TOOLS] })));
1329
+ return out;
1330
+ }
1331
+ /**
1332
+ * 把一个"可能抛错的 denies 计算"包成来源(**统一在这里 catch**)。
1333
+ * 失败 ⇒ `ok:false`:本来源不贡献 deny,**其余来源不受影响**。
1334
+ */
1335
+ function sourceOf(id, fn) {
1336
+ try {
1337
+ const r = fn();
1338
+ return {
1339
+ id,
1340
+ deny: r.deny,
1341
+ ok: true,
1342
+ ...r.error !== void 0 ? { error: r.error } : {}
1343
+ };
1344
+ } catch (e) {
1345
+ return {
1346
+ id,
1347
+ deny: [],
1348
+ ok: false,
1349
+ error: e.message
1350
+ };
1351
+ }
1352
+ }
1353
+ /** 从 exec 提取 agent 会话 cwd(CCC 根检测基准);无则回退进程 cwd */
1354
+ function resolveAgentCwd(exec) {
1355
+ return exec.agent?.session?.header?.cwd ?? process.cwd();
1356
+ }
1357
+ /**
1358
+ * 安全模式开启时从模型工具列表隐藏的工具 —— **基础表**(只隐藏 bash;write/edit 保留)。
1359
+ * 它**恒隐**、与 CCC 配置无关;CCC 声明的 `safeMode.hiddenTools` 是**附加**项(见下)。
1360
+ */
1361
+ const SAFE_MODE_DENY_TOOLS = ["bash"];
1362
+ /**
1363
+ * **常驻拒绝表** —— 与安全模式**无关**,任何 CCC、任何时刻都隐藏(v1.106.0,owner 2026-11-01 令)。
1364
+ *
1365
+ * 为什么与 `SAFE_MODE_DENY_TOOLS` 分开成两张表(而不是往那张里加一项):
1366
+ * 那张表的**名字即语义**——"安全模式期间的附加拒绝"。把常驻项塞进去会让"名字与行为不符",
1367
+ * 而这类错位**没有机械信号**(D151:退役/错位的开关零信号)。⇒ 两张表、两条来源,各说各的。
1368
+ *
1369
+ * ## 首个条目:`ask_user_question`
1370
+ *
1371
+ * owner 原话(2026-11-01):**「宁静号追求更高程度的自动化和轨迹协作,这种阻塞性的东西会降低效率」**
1372
+ * ⇒ 它会让一轮停下来等人回答,与"轨迹自驱 + 轨迹间协作"直接冲突。
1373
+ *
1374
+ * ⚠️ **边界(诚实标注,D255)**:
1375
+ * - 本表是**藏**(`tools.restrict({deny})` 让模型看不见),**不是禁** —— 它不阻止经别的路径调用。
1376
+ * 要真禁得住需在 tool-call 层拦截,那是另一件事,不在本次范围。
1377
+ * - 它**不依赖** `.serenity-safe-on`:安全模式关着也照样藏。
1378
+ * - 宿主若不认识某个名字,`applyVisibilityDecision` 会从它的错误里解析并只剔掉那几个
1379
+ * (⇒ 本表写错一个名字**不会**连累其它来源,也不会静默冻结)。
1380
+ */
1381
+ const ALWAYS_DENY_TOOLS = ["ask_user_question"];
1382
+ const safeModeRestrictions = /* @__PURE__ */ new Map();
1383
+ const restrictDiag = {
1384
+ lastKey: null,
1385
+ lastAttemptAt: null,
1386
+ lastSuccess: null,
1387
+ lastError: null,
1388
+ activeKeys: [],
1389
+ denyByKey: {}
1390
+ };
1391
+ function getRestrictDiagnostics() {
1392
+ return {
1393
+ ...restrictDiag,
1394
+ activeKeys: [...safeModeRestrictions.keys()],
1395
+ denyByKey: Object.fromEntries([...safeModeRestrictions].map(([k, v]) => [k, v.deny]))
1396
+ };
1397
+ }
1398
+ /** 条件可见的 IM 工具名(与 tools/im/channels.ts 的注册名一致) */
1399
+ const IM_BRIDGE_TOOL_NAME = "im-bridge";
1400
+ /**
1401
+ * 会话销毁清理(lifecycle 调用)。
1402
+ *
1403
+ * ⚠️ 方案 C 后撤销动作**只在** `forgetToolVisibility` 里做一次(那一次合并 restrict)。
1404
+ * 本函数保留为语义占位:重复撤销同一条 restrict 会抛错,故这里刻意不做动作。
1405
+ */
1406
+ function forgetImBridgeVisibility(_sessionId) {}
1407
+ /** 会话销毁清理(lifecycle 调用)——方案 C 后由统一清理撤销,这里保留语义占位。 */
1408
+ function forgetHostScheduleToolHiding(_sessionId) {}
1409
+ /**
1410
+ * 宿主 `dsh-tool-schedule` 注册的 4 个模型可见工具名(**逐字**)。
1411
+ *
1412
+ * 🔴 **为什么要在 CCC 内屏蔽**(owner 原话:「这和 CRO 冲突,要求在宁静号激活情况下屏蔽掉」):
1413
+ * 宁静号的唤醒/巡航走 `cognitive_trajectory send-later` → `AGENT_SESSIONS/wake-registry.json`
1414
+ * → 中心调度器 5min tick(D58)。宿主这套定时器是**另一个**真相源 ⇒ 两处记账、
1415
+ * CRO 判据失真、且模型可能顺手排一个"不在任何账本里"的唤醒。
1416
+ * ⚠️ 本清单**合并发射时不做探活**。⚠️ 现状缺口(v1.87.1 如实登记):宿主**没装**
1417
+ * `dsh-tool-schedule` 时,这 4 个名字会把整次 restrict 拒掉,而退档只保留 ACC 自己的名字
1418
+ * ⇒ 这一格的屏蔽**不生效**(旧实现同样不生效,**未回归**;但"顺带修好了"这句是假的,已删)。
1419
+ */
1420
+ const HOST_SCHEDULE_TOOL_NAMES = [
1421
+ "schedule_create",
1422
+ "schedule_list",
1423
+ "schedule_update",
1424
+ "schedule_delete"
1425
+ ];
1426
+ /** 会话销毁清理(lifecycle 调用)。
1427
+ * ⚠️ 方案 C 后撤销只在 `forgetToolVisibility` 做一次;本函数保留语义占位。
1428
+ * 留着它的价值:lifecycle 的调用点表达"这条会话有轨迹约束可见性状态要清",
1429
+ * 删掉调用点会让那件事**从代码里消失**(将来有人重加机制时找不到插点)。 */
1430
+ function forgetTrajectoryConstraintToolVisibility(_sessionId) {}
1431
+ /** 会话销毁清理(lifecycle 调用)——同上,语义占位。 */
1432
+ function forgetExclusiveToolsVisibility(_sessionId) {}
1433
+ /**
1434
+ * 非 CCC 会话的工具隐藏状态(sessionId → restrict disposer)。
1435
+ *
1436
+ * 🔴 **为什么需要它(缺陷根因)**:工具是 `apply()` 里 `ctx.tools.register(...)` 注册到**宿主级**的
1437
+ * ⇒ 它们出现在**每一个**会话的工具清单里,**与会话 cwd 无关**。其余各缝(身份注入 / guards /
1438
+ * keeper / output-guard / bootstrap)都已有 `if (!root) return` 这道门,
1439
+ * **唯独"工具可见性"这一层没有** ⇒ 在非 CCC 目录开会话时,模型仍看得见这 14 个工具,
1440
+ * 于是会去调 —— 调了才抛 `No CCC found`。**那是运行时报错,不是事先不可见**。
1441
+ *
1442
+ * 判据(owner 原话:「宁静号所有功能在非宁静号目录不生效」):
1443
+ * - **在 CCC 内** → 解除隐藏(本机制**一次 restrict 都不发**,既有行为零改变)
1444
+ * - **不在 CCC 内** → `restrict({ deny: [...REGISTERED_TOOLS] })` ⇒ 从模型工具清单移除
1445
+ *
1446
+ * ⚠️ **与 `syncExclusiveToolsVisibility` 的分工**:那个管"CCC 内哪个工具可见"(按 CCC 配置判);
1447
+ * 本函数管"**这个会话配不配看到任何 ACC 工具**"(按"在不在 CCC 内"判)。二者独立、可叠加。
1448
+ *
1449
+ * ⚠️ **必须逐个名字显式列出**:宿主的 `restrict` 对**未注册的工具名**会让**整次调用失败**
1450
+ * (`dsh-tools` 契约逐字:"Empty filters, unknown names … fail")⇒ 不能写通配。
1451
+ * 但 `REGISTERED_TOOLS` 是"插件**具备**的工具面",其中 **`diagram` 是按开关注册的**
1452
+ * (缺省关 ⇒ 缺省**并不在册**)⇒ 直接拿全表去 deny **可能整次失败**,那样就会
1453
+ * **静默退回"工具照样可见"**=缺陷没修好。故此处走**两档尝试**(见 `hideAllFor`)。
1454
+ */
1455
+ const nonCccRestrictions = /* @__PURE__ */ new Map();
1456
+ /** 会话销毁清理(lifecycle 调用;防 per-会话 Map 无界增长) */
1457
+ function forgetNonCccToolVisibility(sessionId) {
1458
+ const existing = nonCccRestrictions.get(sessionId);
1459
+ if (!existing) return;
1460
+ try {
1461
+ existing();
1462
+ } catch {}
1463
+ nonCccRestrictions.delete(sessionId);
1464
+ }
1465
+ /**
1466
+ * 从 exec 参数中提取常见路径字段(write/edit 工具);宽松读取。
1467
+ * container_fs 复合工具:path(单路径)/ paths(数组)/ src / dst——取第一个命中(越界检查
1468
+ * 逐路径由 decideGuard 单 pathArg 覆盖;多路径字段取首个,避免漏检主体路径)。
1469
+ */
1470
+ function extractPathArg(exec) {
1471
+ const args = exec.arguments;
1472
+ if (args === null || typeof args !== "object") return void 0;
1473
+ const a = args;
1474
+ for (const key of [
1475
+ "path",
1476
+ "file_path",
1477
+ "target",
1478
+ "src",
1479
+ "dst"
1480
+ ]) {
1481
+ const v = a[key];
1482
+ if (typeof v === "string") return v;
1483
+ }
1484
+ const paths = a["paths"];
1485
+ if (Array.isArray(paths) && paths.length > 0 && typeof paths[0] === "string") return paths[0];
1486
+ }
1487
+ /** 从 exec 参数提取 container_fs 子命令 action;无则 undefined */
1488
+ function extractAction(exec) {
1489
+ const args = exec.arguments;
1490
+ if (args === null || typeof args !== "object") return void 0;
1491
+ const a = args;
1492
+ return typeof a.action === "string" ? a.action : void 0;
1493
+ }
1494
+ /**
1495
+ * 注册守卫:tools/pre-execute 瀑布 + ctx.tools.guard 终局。
1496
+ * 两者都从 CCC 根实时读取配置(无状态、无缓存)。
1497
+ */
1498
+ function registerGuards(ctx, opts = {}) {
1499
+ const configPaths = opts.configPaths;
1500
+ const evaluate = (exec) => {
1501
+ const cwd = resolveAgentCwd(exec);
1502
+ const root = cccRootForCwd(cwd);
1503
+ if (!root) return { kind: "allow" };
1504
+ const safeModeOn = isSafeModeOn(root);
1505
+ const blacklist = readBlacklist(root, configPaths);
1506
+ const pathArg = extractPathArg(exec);
1507
+ const action = extractAction(exec);
1508
+ const sessionId = exec?.agent?.session?.id;
1509
+ const trajectoryConstraint = trajectoryConstraintForSession(root, sessionId);
1510
+ return decideGuard({
1511
+ root,
1512
+ toolName: exec.name,
1513
+ safeModeOn,
1514
+ blacklist,
1515
+ pathArg,
1516
+ action,
1517
+ trajectoryConstraint
1518
+ });
1519
+ };
1520
+ ctx.on("tools/pre-execute", async (exec, next) => {
1521
+ const d = evaluate(exec);
1522
+ if (d.deny) return {
1523
+ kind: "deny",
1524
+ reason: d.deny
1525
+ };
1526
+ return next();
1527
+ });
1528
+ ctx.tools.guard((exec) => {
1529
+ return evaluate(exec).deny;
1530
+ });
1531
+ }
1532
+ //#endregion
1533
+ //#region src/skills/opencode-scan.ts
1534
+ /**
1535
+ * opencode-scan.ts — opencode skill 标准扫描(纯逻辑,零 DSH 依赖)
1536
+ *
1537
+ * 兼容 opencode 的 skill 标准:`.opencode/skills/<name>/SKILL.md`
1538
+ * (frontmatter: name/description/whenToUse;结构含 references/、scripts/)。
1539
+ */
1540
+ /** 解析 --- 分隔的 YAML frontmatter(只需 name/description/whenToUse) */
1541
+ function parseFrontmatter(raw) {
1542
+ const text = raw.replace(/^\uFEFF/, "");
1543
+ const m = /^---\s*\n([\s\S]*?)\n---\s*\n?/.exec(text);
1544
+ if (!m) return {
1545
+ meta: {
1546
+ name: "",
1547
+ description: ""
1548
+ },
1549
+ content: text
1550
+ };
1551
+ const fm = m[1];
1552
+ const body = text.slice(m[0].length);
1553
+ const grab = (key) => {
1554
+ const line = fm.split("\n").find((l) => l.startsWith(`${key}:`));
1555
+ if (!line) return void 0;
1556
+ return line.slice(key.length + 1).trim().replace(/^['"]|['"]$/g, "");
1557
+ };
1558
+ const meta = {
1559
+ name: grab("name") ?? "",
1560
+ description: grab("description") ?? ""
1561
+ };
1562
+ const whenToUse = grab("whenToUse");
1563
+ if (whenToUse) meta.whenToUse = whenToUse;
1564
+ return {
1565
+ meta,
1566
+ content: body
1567
+ };
1568
+ }
1569
+ /** 扫描 CCC 的 .opencode/skills(一级目录 + SKILL.md 存在性) */
1570
+ function listOpencodeSkillDirs(root) {
1571
+ const dir = join(root, ".opencode", "skills");
1572
+ if (!existsSync(dir)) return [];
1573
+ return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => join(dir, e.name)).filter((d) => existsSync(join(d, "SKILL.md")));
1574
+ }
1575
+ /** 加载一个 opencode skill(frontmatter 解析 + 正文);读失败(EPERM/占用等)返回 null,单目录容错 */
1576
+ function loadOpencodeSkill(skillDir) {
1577
+ let raw;
1578
+ try {
1579
+ raw = readFileSync(join(skillDir, "SKILL.md"), "utf-8");
1580
+ } catch {
1581
+ return null;
1582
+ }
1583
+ const { meta, content } = parseFrontmatter(raw);
1584
+ return {
1585
+ name: meta.name,
1586
+ dir: skillDir,
1587
+ meta,
1588
+ content,
1589
+ path: join(skillDir, "SKILL.md")
1590
+ };
1591
+ }
1592
+ //#endregion
1593
+ //#region src/surface/status.ts
1594
+ /**
1595
+ * surface/status.ts — 状态与安全模式操作(纯逻辑,零 DSH 依赖,可独立单测)
1596
+ *
1597
+ * WebUI 停靠栏的数据源:ACC 版本 / CCC 根 / safe-mode 状态 / 黑名单 / keeper 阈值。
1598
+ * setSafeMode 直接读写 .serenity-safe-on 标记(守卫实时读取,写即生效)。
1599
+ */
1600
+ /** 宿主包名 —— 从运行入口反推包根时的**身份核对**用(见 {@link dshVersionFromEntry}) */
1601
+ const HOST_PACKAGE_NAME = "@deepseek-ai/dsh";
1602
+ /** 上溯层数上限:入口脚本在包内的深度是 `lib/`(1 层)或 `lib/x/y/`(≤3 层)⇒ 6 层足够,且**保证有界** */
1603
+ const MAX_ENTRY_ASCENT = 6;
1604
+ /**
1605
+ * 🔴 **布局无关档**:从**正在跑的那个入口脚本**反推宿主包根,读它的 `version`。
1606
+ *
1607
+ * 为什么必须补这一档(R↓:一处**静默失效**的实际缺陷;2026-09-28 docker bench 实测):
1608
+ * 下面那条静态候选链({@link readDshVersion} 的 ②)是**猜安装位置**——`npm_config_prefix` /
1609
+ * `APPDATA\npm` / `~/.npm-global`。一旦宿主装在**别处**(典型 = 容器镜像里的
1610
+ * `/usr/local/lib/node_modules/@deepseek-ai/dsh`),**三个候选全不中** ⇒ 返回 `null`
1611
+ * ⇒ `checkHostVersion(null)` 只给一条 **`required: false`** 的 issue ⇒ 报告 `ok` 仍为 `true`
1612
+ * ⇒ **版本门完全静默**(bench 逐字:`host contract degraded (1 optional): host version unknown`)。
1613
+ * ⚠️ 这类坏法**比"偶尔报警"更危险**:一条**永远为假**的判据看起来像"没报警 = 没问题"。
1614
+ *
1615
+ * 而"我们正跑在哪个宿主里"**本来就有直接证据**:`process.argv[1]`(= 启动本进程的那个脚本)。
1616
+ * 三条判据要点(每条都对应一个实测过的坑):
1617
+ * ① **先 `realpath`** —— 全局安装的 `dsh` 是**符号链接**(`/usr/local/bin/dsh` → `…/@deepseek-ai/dsh/lib/bin.js`),
1618
+ * 而 Node 的 `argv[1]` **保留调用路径、不解析符号链接** ⇒ 不 realpath 就永远上溯不到包根。
1619
+ * ② **核对包名**(`name === '@deepseek-ai/dsh'`)—— 否则在 vitest / npx 等入口下会误取**别人的**
1620
+ * `package.json`(这是本档**唯一**的假阳性来源)。
1621
+ * ③ **上溯有界**({@link MAX_ENTRY_ASCENT})—— 不设界的话,一个不在任何 dsh 包内的入口会一路爬到文件系统根。
1622
+ *
1623
+ * @param entry `process.argv[1]`(单测直接注入,无需替身)
1624
+ */
1625
+ function dshVersionFromEntry(entry) {
1626
+ if (typeof entry !== "string" || entry === "") return null;
1627
+ let dir;
1628
+ try {
1629
+ const real = realpathSync(entry);
1630
+ dir = statSync(real).isDirectory() ? real : dirname(real);
1631
+ } catch {
1632
+ return null;
1633
+ }
1634
+ for (let i = 0; i < MAX_ENTRY_ASCENT; i++) {
1635
+ try {
1636
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
1637
+ if (pkg.name === HOST_PACKAGE_NAME && typeof pkg.version === "string") return pkg.version;
1638
+ } catch {}
1639
+ const parent = dirname(dir);
1640
+ if (parent === dir) break;
1641
+ dir = parent;
1642
+ }
1643
+ return null;
1644
+ }
1645
+ /**
1646
+ * 读取已安装 DSH CLI 版本;读不到返回 null。
1647
+ *
1648
+ * **两档,顺序有理由**:
1649
+ * ① 🔴 **布局无关档**({@link dshVersionFromEntry})= **正在跑的就是它** —— 直接证据优先于"猜安装位置"。
1650
+ * ② **静态候选链**(跨平台历史档,保留):`npm_config_prefix` / `APPDATA\npm` / `~/.npm-global`
1651
+ * (Windows npm 全局装在 `%APPDATA%\npm`,见审计问题 13)。
1652
+ * ⚠️ 保留它是因为**本进程不一定是 dsh CLI**(单测、嵌入式、将来别的宿主进程)——
1653
+ * 那时"本机装了哪个版本"仍需一个答案,只是证据等级更低。
1654
+ */
1655
+ function readDshVersion() {
1656
+ const fromEntry = dshVersionFromEntry(process.argv[1]);
1657
+ if (fromEntry !== null) return fromEntry;
1658
+ const candidates = [];
1659
+ if (process.env.npm_config_prefix) candidates.push(join(process.env.npm_config_prefix, "lib", "node_modules", "@deepseek-ai", "dsh"));
1660
+ if (process.env.APPDATA) candidates.push(join(process.env.APPDATA, "npm", "node_modules", "@deepseek-ai", "dsh"));
1661
+ candidates.push(join(homedir(), ".npm-global", "lib", "node_modules", "@deepseek-ai", "dsh"));
1662
+ for (const p of candidates) try {
1663
+ const pkg = JSON.parse(readFileSync(join(p, "package.json"), "utf-8"));
1664
+ if (typeof pkg.version === "string") return pkg.version;
1665
+ } catch {}
1666
+ return null;
1667
+ }
1668
+ function getStatus(cwd, configPaths = DEFAULT_SERENITY_CONFIG_PATHS) {
1669
+ const root = cccRootForCwd(cwd);
1670
+ const restrict = getRestrictDiagnostics();
1671
+ const common = {
1672
+ accVersion: ACC_VERSION,
1673
+ dshVersion: readDshVersion(),
1674
+ nodeVersion: process.version
1675
+ };
1676
+ if (!root) return {
1677
+ root: null,
1678
+ ...common,
1679
+ safeModeOn: false,
1680
+ blacklist: [],
1681
+ threshold: null,
1682
+ trajectories: [],
1683
+ skills: [],
1684
+ restrict
1685
+ };
1686
+ const cfg = loadSerenityConfig(root, configPaths);
1687
+ return {
1688
+ root,
1689
+ ...common,
1690
+ safeModeOn: isSafeModeOn(root),
1691
+ blacklist: readBlacklist(root, configPaths),
1692
+ threshold: cfg.sessionKeeper?.threshold ?? null,
1693
+ trajectories: listActiveTrajectories(root),
1694
+ skills: listSkillSummaries(root),
1695
+ restrict
1696
+ };
1697
+ }
1698
+ /**
1699
+ * 技能清单(面板用;owner 2026-10-18 要求 skills 单独占一个 panel)。
1700
+ *
1701
+ * 只取**两个可文件系统验证的事实**:名字(目录名)+ 是否已建模(`skill.ttl` 存在)。
1702
+ * 为什么"是否建模"值得进面板:它是 skill 形态的**唯一二值区别**(有 `skill.ttl` = 增强型,
1703
+ * 无 = 普通 skill),而后者**完全正常** —— 所以面板把它呈现为**信息**,不是缺陷标记。
1704
+ *
1705
+ * ⚠️ 单目录读失败**不抛**(`loadOpencodeSkill` 返回 null 时照旧收入名字):
1706
+ * 面板是只读状态面,一个坏目录不该让整块面板消失。
1707
+ */
1708
+ function listSkillSummaries(root) {
1709
+ const out = [];
1710
+ for (const dir of listOpencodeSkillDirs(root)) {
1711
+ const name = basename(dir);
1712
+ out.push({
1713
+ name,
1714
+ modeled: existsSync(join(dir, "skill.ttl"))
1715
+ });
1716
+ }
1717
+ return out.sort((a, b) => a.name.localeCompare(b.name));
1718
+ }
1719
+ /** 切换安全模式(写/删标记文件);返回实际生效状态 */
1720
+ function setSafeMode(root, on) {
1721
+ const marker = resolve(root, SAFE_MODE_MARKER);
1722
+ if (on) {
1723
+ if (!existsSync(marker)) writeFileSync(marker, isoLocal() + "\n", "utf-8");
1724
+ } else rmSync(marker, { force: true });
1725
+ return { on: isSafeModeOn(root) };
1726
+ }
1727
+ //#endregion
1728
+ //#region src/host/contract.ts
1729
+ /** dsp 依赖的宿主服务与成员(对照 `_tmp/host-0.1.5-rc.1/`(`dsh-develop host-fetch` 产物)逐一核对) */
1730
+ const HOST_SERVICES = [
1731
+ {
1732
+ id: "tools",
1733
+ name: "tools",
1734
+ access: "injected",
1735
+ members: [{
1736
+ name: "register",
1737
+ kind: "function"
1738
+ }, {
1739
+ name: "guard",
1740
+ kind: "function"
1741
+ }],
1742
+ impact: "10 个 ACC 工具无法注册 / 机械守卫失效",
1743
+ required: true
1744
+ },
1745
+ {
1746
+ id: "sessions",
1747
+ name: "sessions",
1748
+ access: "injected",
1749
+ members: [
1750
+ {
1751
+ name: "list",
1752
+ kind: "function"
1753
+ },
1754
+ {
1755
+ name: "get",
1756
+ kind: "function"
1757
+ },
1758
+ {
1759
+ name: "create",
1760
+ kind: "function"
1761
+ }
1762
+ ],
1763
+ impact: "会话定位/rebuild/CCC 解析失效",
1764
+ required: true
1765
+ },
1766
+ {
1767
+ id: "agents",
1768
+ name: "agents",
1769
+ access: "injected",
1770
+ members: [{
1771
+ name: "create",
1772
+ kind: "function"
1773
+ }, {
1774
+ name: "get",
1775
+ kind: "function"
1776
+ }],
1777
+ impact: "轨迹约束无法创建或复用 agent",
1778
+ required: true
1779
+ },
1780
+ {
1781
+ id: "webServer",
1782
+ name: "webServer",
1783
+ access: "injected",
1784
+ members: [{
1785
+ name: "register",
1786
+ kind: "function"
1787
+ }, {
1788
+ name: "port",
1789
+ kind: "value"
1790
+ }],
1791
+ impact: "状态接口/网关端口发现失效",
1792
+ required: true
1793
+ },
1794
+ {
1795
+ id: "settings",
1796
+ name: "settings",
1797
+ access: "injected",
1798
+ members: [{
1799
+ name: "describe",
1800
+ kind: "function"
1801
+ }, {
1802
+ name: "update",
1803
+ kind: "function"
1804
+ }],
1805
+ impact: "设置面板不装配 → 所有开关静默 no-op;opencode 路由自动配置与多模态补丁失效",
1806
+ required: true
1807
+ },
1808
+ {
1809
+ id: "web",
1810
+ name: "web",
1811
+ access: "injected",
1812
+ members: [{
1813
+ name: "registerFetchProvider",
1814
+ kind: "function"
1815
+ }],
1816
+ impact: "web_fetch provider 无法注册 → fake-ip 网络下 web_fetch 不可用",
1817
+ required: false
1818
+ },
1819
+ {
1820
+ id: "systemPrompt",
1821
+ name: "systemPrompt",
1822
+ access: "injected",
1823
+ members: [],
1824
+ impact: "Induction(入口 skill 段)注入失效",
1825
+ required: true
1826
+ },
1827
+ {
1828
+ id: "sessionProjections",
1829
+ name: "sessionProjections",
1830
+ access: "injected",
1831
+ members: [{
1832
+ name: "snapshot",
1833
+ kind: "function"
1834
+ }],
1835
+ impact: "上下文压力读数失效 → rebuild 提醒不再触发",
1836
+ required: false
1837
+ },
1838
+ {
1839
+ id: "skills",
1840
+ name: "skills",
1841
+ access: "injected",
1842
+ members: [],
1843
+ impact: "opencode skill 兼容层(.opencode/skills 注册)失效",
1844
+ required: false
1845
+ },
1846
+ {
1847
+ id: "shellEnv",
1848
+ name: "shellEnv",
1849
+ access: "injected",
1850
+ members: [],
1851
+ impact: "DSH_SERENITY_* 环境事实注入失效",
1852
+ required: false
1853
+ },
1854
+ {
1855
+ id: "agentLoop",
1856
+ name: "agentLoop",
1857
+ access: "injected",
1858
+ members: [],
1859
+ impact: "子 agent 无法装配 preset(轨迹载体创建会退到全局工具层)",
1860
+ required: false
1861
+ },
1862
+ {
1863
+ id: "subagents",
1864
+ name: "subagents",
1865
+ access: "lazy",
1866
+ members: [{
1867
+ name: "startActivation",
1868
+ kind: "function",
1869
+ anyOf: "spawn-starter"
1870
+ }, {
1871
+ name: "start",
1872
+ kind: "function",
1873
+ anyOf: "spawn-starter"
1874
+ }],
1875
+ impact: "子 agent 委派不可用(cheap-worker 已下线 ⇒ 现无消费者,仅备将来)",
1876
+ required: false
1877
+ },
1878
+ {
1879
+ id: "sessionTitle",
1880
+ name: "sessionTitle",
1881
+ access: "lazy",
1882
+ members: [{
1883
+ name: "rename",
1884
+ kind: "function"
1885
+ }],
1886
+ impact: "会话命名 / rebuild 后重命名跳过(仅告警)",
1887
+ required: false
1888
+ },
1889
+ {
1890
+ id: "tokenMeter",
1891
+ name: "tokenMeter",
1892
+ access: "lazy",
1893
+ members: [{
1894
+ name: "estimateMessage",
1895
+ kind: "function"
1896
+ }],
1897
+ impact: "rebuild shadow-price 定价失效(计量不准)",
1898
+ required: false
1899
+ },
1900
+ {
1901
+ id: "workspaceRegistry",
1902
+ name: "workspaceRegistry",
1903
+ access: "lazy",
1904
+ members: [{
1905
+ name: "list",
1906
+ kind: "function"
1907
+ }, {
1908
+ name: "archivedSessionIds",
1909
+ kind: "value"
1910
+ }],
1911
+ impact: "CCC 自动发现回落失效 + **归档状态不可知**(已归档会话会被误判成活载体)",
1912
+ required: false
1913
+ },
1914
+ {
1915
+ id: "sessionPersistence",
1916
+ name: "sessionPersistence",
1917
+ access: "lazy",
1918
+ members: [{
1919
+ name: "list",
1920
+ kind: "function"
1921
+ }],
1922
+ impact: "CCC 自动发现回落失效(诊断面受限)",
1923
+ required: false
1924
+ },
1925
+ {
1926
+ id: "sessionController",
1927
+ name: "sessionController",
1928
+ access: "lazy",
1929
+ members: [{
1930
+ name: "resolveAgent",
1931
+ kind: "function"
1932
+ }],
1933
+ impact: "cognitive trajectory 冷会话唤醒不可用(只能唤醒已加载的 live 会话)——唤醒注册表投递降级",
1934
+ required: false
1935
+ },
1936
+ {
1937
+ id: "connection",
1938
+ name: "connection",
1939
+ access: "lazy",
1940
+ members: [{
1941
+ name: "authenticatedUrl",
1942
+ kind: "function"
1943
+ }, {
1944
+ name: "authorizeIndex",
1945
+ kind: "function"
1946
+ }],
1947
+ impact: "双端口网关无法换取 dsh cookie → 外部反代 401",
1948
+ required: false
1949
+ },
1950
+ {
1951
+ id: "storageDomain",
1952
+ name: "storageDomain",
1953
+ access: "lazy",
1954
+ members: [{
1955
+ name: "open",
1956
+ kind: "function"
1957
+ }],
1958
+ impact: "轨迹绑定无处可存 → §0L 整体不可用(须退回\"写会话日志\"备选方案)",
1959
+ required: false
1960
+ }
1961
+ ];
1962
+ /**
1963
+ * dsp **被验证过的宿主版本档**(单一真相源)—— 每档 = 一个半开区间 `[floor, ceiling)`。
1964
+ *
1965
+ * 🔴 为什么是"档"而不是一条 caret 范围(R↓:2026-09-28 0.2.0-rc.1 适配轮实测得出):
1966
+ * - **宿主门的判据是真 semver**(`@deepseek-ai/dsh-app-boot:evaluatePluginCompatibility`
1967
+ * = `semver.satisfies(runtime, range, { includePrerelease: true })`)⇒ **它支持 `||` 联合范围**;
1968
+ * - 而 **dsp 这边是手写比较器**({@link compareSemver}),**不实现 semver 的 range 语义**:
1969
+ * 旧写法"用正则从 `REQUIRED_HOST_RANGE` 剥前缀取 floor"在**联合范围上会取到垃圾**
1970
+ * (`^0.1.7-rc.1 || ^0.2.0-rc.1` 的预发布捕获组 `(.*)` 会**吞掉 `||` 之后整段**)
1971
+ * ⇒ 判定**变瞎**(既非"通过"也非"低于下限",而是不可解释的值)。
1972
+ * ⇒ 故**档表是唯一被验证的真相**:`REQUIRED_HOST_RANGE`(给宿主门看的串)与
1973
+ * `checkHostVersion`(给自己的手写比较器)**都从它派生**,两者不可能互相漂移。
1974
+ *
1975
+ * 🔴 两档的由来(**实测,非推断**;判据表见插件仓 `docs/dsh-0.2.0-rc.1-adaptation-plan.md` §7.4):
1976
+ * - `^0.1.7-rc.1` 在 `0.2.0-rc.1` 上被宿主**整包拒绝 + 回滚**
1977
+ * (逐字 `installation rejected: … incompatible with dsh 0.2.0-rc.1` + `restored package.json, …`);
1978
+ * - 单档 `^0.2.0-rc.1` 会**同样硬拒本机正在跑的 `0.1.7-rc.2`**
1979
+ * ⇒ 发布后 deploy 必须先升宿主,**而升级会打断本容器**;
1980
+ * - ⇒ 双档是**唯一不把本机宿主堵死的写法**(两档在真 semver 下均 PASS)。
1981
+ * 档上界取 `<0.2.0` / `<0.3.0`(即 `^0.2.0-rc.1` 的 caret 上界):第二档同时覆盖
1982
+ * `0.2.0` 正式版与后续 `0.2.0-rc.N`。
1983
+ *
1984
+ * 🆕 **第三档 `^0.2.1-rc.1`(v1.59.3 适配,2026-10-17)** —— 为什么非加不可:
1985
+ * - npm 的 `^0.2.0-rc.1` 对**预发布**只放行**同一 `major.minor.patch`** 的 prerelease
1986
+ * ⇒ **`0.2.1-rc.1` 不满足它**(patch 位不同)⇒ 宿主门会**整包拒绝安装**(D98 同族)。
1987
+ * - 而第二档的 `ceiling: '0.3.0'` 本来就"包含" 0.2.1 —— 那是**我们自己的手写比较器**的语义;
1988
+ * 两个判据(真 semver 门 vs 手写区间)在这里**给出相反答案**,故必须把第三档显式写出来,
1989
+ * 让**派生串**({@link REQUIRED_HOST_RANGE})里真的出现 `^0.2.1-rc.1`。
1990
+ * ⚠️ **加档不是收窄**:前两档原样保留(0.1.7 / 0.2.0 两代仍被声明支持)。
1991
+ */
1992
+ const VERIFIED_HOST_BANDS = [
1993
+ {
1994
+ floor: "0.1.7-rc.1",
1995
+ ceiling: "0.2.0"
1996
+ },
1997
+ {
1998
+ floor: "0.2.0-rc.1",
1999
+ ceiling: "0.3.0"
2000
+ },
2001
+ {
2002
+ floor: "0.2.1-rc.1",
2003
+ ceiling: "0.3.0"
2004
+ }
2005
+ ];
2006
+ /**
2007
+ * 与 `package.json` 的 `peerDependencies` **逐字同串**的宿主版本范围(由 {@link VERIFIED_HOST_BANDS} 派生)。
2008
+ *
2009
+ * 🔴 这个字符串**就是**宿主门(`evaluatePluginCompatibility`)拿去跑 `semver.satisfies` 的那个串
2010
+ * ⇒ 它与档表**必须一致**("声明 union、判定单档"是一种自相矛盾)。
2011
+ * 一致性由 `tests/host-contract.test.ts` 的**跨文件对账用例**钉住(读 package.json 逐字比对)。
2012
+ */
2013
+ const REQUIRED_HOST_RANGE = VERIFIED_HOST_BANDS.map((b) => `^${b.floor}`).join(" || ");
2014
+ /** 最小 semver 比较(无依赖):返回 -1 / 0 / 1;解析失败返回 null */
2015
+ function compareSemver(a, b) {
2016
+ const parse = (v) => {
2017
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.*))?$/.exec(v.trim());
2018
+ if (!m) return null;
2019
+ return [
2020
+ Number(m[1]),
2021
+ Number(m[2]),
2022
+ Number(m[3]),
2023
+ m[4] ?? ""
2024
+ ];
2025
+ };
2026
+ const pa = parse(a);
2027
+ const pb = parse(b);
2028
+ if (!pa || !pb) return null;
2029
+ for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
2030
+ const ra = pa[3];
2031
+ const rb = pb[3];
2032
+ if (ra === rb) return 0;
2033
+ if (ra === "") return 1;
2034
+ if (rb === "") return -1;
2035
+ return ra < rb ? -1 : 1;
2036
+ }
2037
+ /**
2038
+ * 宿主版本是否落在 dsp **被验证过的档**({@link VERIFIED_HOST_BANDS})之内。
2039
+ * 无版本信息 → 视为未知(ok=false,detail 说明),不猜测。
2040
+ *
2041
+ * 🔴 **逐档判**(v1.50.x,0.2.0-rc.1 适配轮):判据 = "∃ 档:`version >= floor ∧ version < ceiling`"。
2042
+ * 曾经的做法是"从 `REQUIRED_HOST_RANGE` 剥前缀取一个 floor + 一个显式 ceiling"——
2043
+ * **单档假设一旦不成立(改联合范围),那个正则就会取到垃圾**(详见 {@link VERIFIED_HOST_BANDS}
2044
+ * 的注释)⇒ 故本函数**只读档表**,不再从范围串反推。
2045
+ * ⚠️ 手写比较器**不实现 semver 的 range 语义**(本条只做"区间内/外"),这也是必须逐档判的原因。
2046
+ */
2047
+ function checkHostVersion(version) {
2048
+ if (version === null) return {
2049
+ ok: false,
2050
+ detail: `host version unknown (required ${REQUIRED_HOST_RANGE})`
2051
+ };
2052
+ let matched = false;
2053
+ let belowEveryFloor = true;
2054
+ let unparseable = false;
2055
+ for (const band of VERIFIED_HOST_BANDS) {
2056
+ const floor = compareSemver(version, band.floor);
2057
+ const ceiling = compareSemver(version, band.ceiling);
2058
+ if (floor === null || ceiling === null) {
2059
+ unparseable = true;
2060
+ break;
2061
+ }
2062
+ if (floor >= 0) {
2063
+ belowEveryFloor = false;
2064
+ if (ceiling < 0) {
2065
+ matched = true;
2066
+ break;
2067
+ }
2068
+ }
2069
+ }
2070
+ if (unparseable) return {
2071
+ ok: false,
2072
+ detail: `host version "${version}" not parseable (required ${REQUIRED_HOST_RANGE})`
2073
+ };
2074
+ if (matched) return {
2075
+ ok: true,
2076
+ detail: `host ${version} within ${REQUIRED_HOST_RANGE}`
2077
+ };
2078
+ if (belowEveryFloor) return {
2079
+ ok: false,
2080
+ detail: `host ${version} is below the verified floor (required ${REQUIRED_HOST_RANGE})`
2081
+ };
2082
+ return {
2083
+ ok: false,
2084
+ detail: `host ${version} is outside the verified range (required ${REQUIRED_HOST_RANGE})`
2085
+ };
2086
+ }
2087
+ /** 结构化读取宿主服务(lazy → ctx.get;injected → 直接属性,属性缺失再试 ctx.get) */
2088
+ function readService(ctx, name, access) {
2089
+ return access === "lazy" ? hostService(ctx, name) : hostInjected(ctx, name);
2090
+ }
2091
+ /**
2092
+ * 探针:核对运行中的宿主是否仍满足 dsp 依赖的服务与成员。
2093
+ *
2094
+ * @param ctx 宿主插件上下文(真实 cordis Context;测试可传结构化替身)
2095
+ * @param hostVersion 运行中宿主版本(`readDshVersion()` 结果;null = 未知)
2096
+ * @returns 报告(`ok:false` 时 `issues` 逐条给出缺失项与后果)
2097
+ */
2098
+ function probeHostContract(ctx, hostVersion = null) {
2099
+ const issues = [];
2100
+ let checked = 0;
2101
+ for (const svc of HOST_SERVICES) {
2102
+ checked += 1;
2103
+ let value;
2104
+ try {
2105
+ value = readService(ctx, svc.name, svc.access);
2106
+ } catch {
2107
+ value = void 0;
2108
+ }
2109
+ if (value === void 0 || value === null) {
2110
+ issues.push({
2111
+ id: svc.id,
2112
+ kind: "service",
2113
+ detail: `service "${svc.name}" not available (${svc.access})`,
2114
+ impact: svc.impact,
2115
+ required: svc.required
2116
+ });
2117
+ continue;
2118
+ }
2119
+ for (const member of svc.members) {
2120
+ checked += 1;
2121
+ const actual = value[member.name];
2122
+ if (!(member.kind === "function" ? typeof actual === "function" : actual !== void 0)) {
2123
+ if (member.anyOf !== void 0) {
2124
+ const siblings = svc.members.filter((m) => m.anyOf === member.anyOf);
2125
+ if (siblings.some((m) => {
2126
+ const v = value[m.name];
2127
+ return m.kind === "function" ? typeof v === "function" : v !== void 0;
2128
+ })) continue;
2129
+ issues.push({
2130
+ id: `${svc.id}.${member.anyOf}`,
2131
+ kind: "member",
2132
+ detail: `service "${svc.name}" is missing every ${member.kind} of "${member.anyOf}" (${siblings.map((m) => m.name).join(" | ")})`,
2133
+ impact: svc.impact,
2134
+ required: svc.required
2135
+ });
2136
+ continue;
2137
+ }
2138
+ issues.push({
2139
+ id: `${svc.id}.${member.name}`,
2140
+ kind: "member",
2141
+ detail: `service "${svc.name}" is missing ${member.kind} "${member.name}"`,
2142
+ impact: svc.impact,
2143
+ required: svc.required
2144
+ });
2145
+ }
2146
+ }
2147
+ }
2148
+ const version = checkHostVersion(hostVersion);
2149
+ if (!version.ok) issues.push({
2150
+ id: "host.version",
2151
+ kind: "version",
2152
+ detail: version.detail,
2153
+ impact: "契约未经该宿主版本验证(漂移风险)",
2154
+ required: false
2155
+ });
2156
+ return {
2157
+ ok: issues.every((i) => !i.required),
2158
+ checked,
2159
+ hostVersion,
2160
+ versionOk: version.ok,
2161
+ issues
2162
+ };
2163
+ }
2164
+ /**
2165
+ * 取宿主契约报告——**每次调用现探**(`ctx` 决定"问谁",**不缓存"答案"**)。
2166
+ *
2167
+ * 为什么**不缓存**(R↓:一个被实测推翻的设计,留档以免重犯):
2168
+ * v1.34.1 曾把 `apply()` 时的探针结果存为模块级快照,理由是"两者对**同一个已装载的宿主**
2169
+ * 问同一个问题,第二次计算的答案**不可能不同**,成本却是一次全服务遍历"。
2170
+ * **该前提被实测证伪**(本机 2026-09-15,v1.34.1 部署后):
2171
+ * - `apply()` 跑在装载早期,表中 `access:'lazy'` 的服务**尚未实例化**(`ctx.get` 返回 undefined)
2172
+ * ⇒ 启动探针把它们记为 `not available (lazy)`;
2173
+ * - 到 `dashboard health` 被调用时它们早已可用 ⇒ **现探给的是相反结论**。
2174
+ * - 实测差异:快照 = `checked 36 / issues 3`(含 `workspaceRegistry not available` + 后果
2175
+ * "工作区白名单下拉为空"),而**同一时刻** `GET /serenity/cccs` 正常返回 **4 个工作区**
2176
+ * (含 3 个无 live 会话的冷 CCC)⇒ 快照渲染出的是**假阴性**。
2177
+ * ⇒ 「两次探针」**不是重复计算,问的是两个时刻**;缓存答案 = 把装载瞬间的观测冒充运行态事实。
2178
+ *
2179
+ * 保留统一入口的收益(这才是 C5 的正确部分):取数口唯一——`container-status` / `kit-ops`
2180
+ * 只问这一个函数;将来若要"筛掉装载期噪声",只需改这一处。
2181
+ *
2182
+ * @param ctx 宿主上下文;**未给** ⇒ 返回 `null`(不猜、不暗跑探针)
2183
+ * @param hostVersion 运行中宿主版本(`readDshVersion()`;null = 未知)
2184
+ */
2185
+ function hostContractReport(ctx, hostVersion = null) {
2186
+ if (ctx === void 0) return null;
2187
+ return probeHostContract(ctx, hostVersion);
2188
+ }
2189
+ /** 单行摘要(启动告警用) */
2190
+ function summarizeHostContract(report) {
2191
+ if (report.ok && report.issues.length === 0) return `host contract ok (${report.checked} checks, host ${report.hostVersion ?? "unknown"})`;
2192
+ const required = report.issues.filter((i) => i.required);
2193
+ const optional = report.issues.filter((i) => !i.required);
2194
+ return `${required.length > 0 ? `host contract BROKEN (${required.length} required)` : `host contract degraded (${optional.length} optional)`}: ${report.issues.map((i) => i.detail).join("; ")}`;
2195
+ }
2196
+ //#endregion
2197
+ export { docsPointer as C, setWeixinSendEndpoint as E, runMsmAsync as S, renderDocsIndex as T, CCC_CONFIG_REFERENCE as _, setSafeMode as a, loadMsmEntries as b, forgetExclusiveToolsVisibility as c, forgetNonCccToolVisibility as d, forgetTrajectoryConstraintToolVisibility as f, ACC_CATALOG as g, forgetToolVisibility as h, readDshVersion as i, forgetHostScheduleToolHiding as l, syncToolVisibility as m, summarizeHostContract as n, listOpencodeSkillDirs as o, registerGuards as p, getStatus as r, loadOpencodeSkill as s, hostContractReport as t, forgetImBridgeVisibility as u, checkRegistryQuality as v, findDocsChapter as w, runMsm as x, findEntry as y };