@shgroup/dsh-serenity-hooks 1.77.0 → 1.78.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,2496 @@
1
+ import { n as agentCwdFor, r as cccRootForCwd, s as hostAgents } from "./ccc-roots-D3ty1C8P.js";
2
+ import { g as readUtf8, o as isSafeModeOn, p as readCognitiveTrajectorySkills, u as readBlacklist } from "./ccc-CyYJL4F2.js";
3
+ import { B as isoLocal, D as readActiveSessionMd, a as readTrajectoryConstraintAt, c as trajectoryConstraintForSession, d as unregisterTrajectorySession$1, j as sessionsRoot, l as registerTrajectorySession$1, p as DEFAULT_SESSION_SCOPE, u as trajectoryDirNameForSession } from "./profile-C7BuyZK7.js";
4
+ import { a as readGitTrack, i as localstorePath } from "./service-DDmHSglV.js";
5
+ import { _ as ACC_VERSION, d as constraintCognitiveTrajectory, m as profileMsmWhitelist, n as PLUGIN_SOURCE, o as readLastBound, p as createProfilePromptReader, u as buildTrajectoryBasePrompt } from "./message-source-BOG0piL8.js";
6
+ import { basename, dirname, join, resolve } from "node:path";
7
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
8
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
9
+ import { createHmac, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
10
+ //#region src/trajectory/prompt.ts
11
+ /**
12
+ * trajectory/prompt.ts — **轨迹提示词**(Trajectory Prompt):一条轨迹"是谁、按什么章程工作"
13
+ *
14
+ * ## 概念(owner 2026-10-19 定向,逐字)
15
+ *
16
+ * > 「参考会话的系统提示词,我们建立**轨迹提示词**概念来代替人格。架构上会好很多」
17
+ *
18
+ * **轨迹提示词** = 一条认知轨迹的**可编辑章程声明**,由 ACC 按固定顺序装配进系统提示词。
19
+ * 三个关键词各是一条判据:
20
+ * - **可编辑**:住在 CCC(盘上),改它**不发版**(D23 归属判据的直接推论)
21
+ * - **声明**:它声明**身份/章程/方法论**,**不声明能力**(能力面归 `trajectory-constraint.json` 白名单与工具 schema)
22
+ * - **固定顺序**:装配顺序由 ACC 定死(见 `seams/system-prompt.ts` 的 ⑧ 位)
23
+ *
24
+ * ## 为什么取代「人格」
25
+ *
26
+ * 「人格」把身份绑在"像不像一个人"上 ⇒ 因为叫人格,它被设计成**可以整体替换**
27
+ * (`persona` 置换 EAP 块,即 review F-05/R-14 那个悬置冲突)。而身份与章程**不该被替换,
28
+ * 只该被特化**。⇒ 本模块把"替换"改成"叠加":容器层与质量层永远保留,轨迹提示词只往上加。
29
+ *
30
+ * ## 落点(为什么在轨迹目录里)
31
+ *
32
+ * `AGENT_SESSIONS/<轨迹目录>/PROMPT.md`
33
+ *
34
+ * - **跟着轨迹走**:`archive` 时整个目录搬走,章程不会掉队(旧形态 `.opencode/skiff/<name>.md`
35
+ * 与轨迹**分居两处** —— 多条轨迹共用一份文件正是那个后果)。
36
+ * - **每条轨迹都能有**:不只是 轨迹约束。普通轨迹同样需要"我是谁、按什么章程"。
37
+ * - 与 `SESSION.md`(身体:延续/日志)、`trajectory-constraint.json`(约束面:能力)三件并列,
38
+ * 各管一件事,不合并(合并会让"改能力"与"改身份"互相牵连)。
39
+ *
40
+ * ## 兼容(不静默坏)
41
+ *
42
+ * `PROMPT.md` 不存在时**不报错**(普通轨迹常态)。轨迹约束的旧字段 `systemPromptFile`
43
+ * 在兼容期仍被读取——优先级见 {@link resolveTrajectoryPrompt}。
44
+ */
45
+ /** 轨迹提示词的固定文件名(**判据就是它存在**——不是目录名前缀,也不是配置里的一行) */
46
+ const TRAJECTORY_PROMPT_FILENAME = "PROMPT.md";
47
+ /** 轨迹目录的 PROMPT.md 绝对路径(不论存在与否——调用方自己判存在性) */
48
+ function trajectoryPromptPath(root, dirName) {
49
+ return join(sessionsRoot(root), dirName, TRAJECTORY_PROMPT_FILENAME);
50
+ }
51
+ /**
52
+ * 该轨迹**有没有**轨迹提示词(单一判据)。
53
+ *
54
+ * 用途:兼容期的"让位"判定 —— 若已有 `PROMPT.md`,轨迹约束 旧字段 `systemPromptFile`
55
+ * 必须**让位**,否则同一份章程会在两处各注入一遍(第 ⑧ 段 + `serenity-轨迹约束` 段)。
56
+ * 🔴 判据只有这一处:别在 轨迹约束那边自己拼路径再判(两处会漂)。
57
+ */
58
+ function hasTrajectoryPrompt(root, dirName) {
59
+ try {
60
+ return existsSync(trajectoryPromptPath(root, dirName));
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+ /**
66
+ * 轨迹提示词的**读取器**(长期热更新;改文件即生效,无需重启)。
67
+ *
68
+ * ## 为什么返回函数而不是字符串(R↓,实证代价)
69
+ *
70
+ * 旧实现把提示词**快照**进系统提示词段的闭包 ⇒ CCC 改了文件必须重启 dsh web 才生效
71
+ * (实测:改完 18KB 提示词,live agent 仍用旧文本)。现形态由系统提示词段的 `text()`
72
+ * **每轮调用**本读取器:按 **mtime + size** 缓存重读(命中缓存零 IO)。
73
+ *
74
+ * ## 三条失败语义(各治一个失败模式)
75
+ *
76
+ * - **文件不存在** ⇒ 返回空串(**不是错误**:普通轨迹没有章程是常态,且删掉即失效
77
+ * 必须是立即可见的——这就是"删掉 PROMPT.md 即恢复默认"的机制)
78
+ * - **读取失败** ⇒ 返回**上次成功内容**(⛔ 绝不返回空:空提示词会让轨迹失去章程),
79
+ * 并按去重标签告警一次
80
+ * - **首次就读不到** ⇒ 空串 + 告警一次(区分"本来没有"与"有但读不了")
81
+ *
82
+ * @param root CCC 根
83
+ * @param dirName 轨迹目录名(**不是编码**:目录名才是落盘位置)
84
+ */
85
+ function createTrajectoryPromptReader(root, dirName) {
86
+ const absPath = trajectoryPromptPath(root, dirName);
87
+ let cachedMtime = -1;
88
+ let cachedSize = -1;
89
+ let cachedText = "";
90
+ let warned = false;
91
+ return () => {
92
+ let st;
93
+ try {
94
+ st = statSync(absPath);
95
+ } catch (err) {
96
+ const code = err?.code;
97
+ cachedMtime = -1;
98
+ cachedSize = -1;
99
+ cachedText = "";
100
+ if (code !== "ENOENT" && !warned) {
101
+ warned = true;
102
+ console.warn(`[serenity-hooks] ✗ 轨迹提示词不可读(${absPath})——按"没有章程"处理: ${String(err?.message ?? err)}`);
103
+ }
104
+ return "";
105
+ }
106
+ try {
107
+ if (st.mtimeMs === cachedMtime && st.size === cachedSize) return cachedText;
108
+ const text = readUtf8(absPath).trim();
109
+ const isReload = cachedMtime !== -1;
110
+ cachedMtime = st.mtimeMs;
111
+ cachedSize = st.size;
112
+ cachedText = text;
113
+ warned = false;
114
+ if (isReload) console.info(`[serenity-hooks] ↻ 轨迹提示词已热重载(${TRAJECTORY_PROMPT_FILENAME},${text.length} 字符)`);
115
+ return text;
116
+ } catch (err) {
117
+ if (!warned) {
118
+ warned = true;
119
+ console.warn(`[serenity-hooks] ✗ 轨迹提示词读取失败(${absPath})——沿用上次成功内容: ${String(err?.message ?? err)}`);
120
+ }
121
+ return cachedText;
122
+ }
123
+ };
124
+ }
125
+ /**
126
+ * 装配用的**提示词段**(幂等:无内容 ⇒ 空串,宿主会丢弃空 section/空块)。
127
+ *
128
+ * 标记头 `=== Serenity Trajectory Prompt ===`:
129
+ * ① 让模型知道"下面是**这条轨迹自己的章程**",不是容器规则;
130
+ * ② 让诊断可以按块名 grep。
131
+ *
132
+ * 🔴 这段**追加**在容器层与质量层之后,**永不替换它们**——那正是本概念相对「人格」的核心修正。
133
+ */
134
+ function trajectoryPromptBlock(text) {
135
+ const body = text.trim();
136
+ if (body === "") return "";
137
+ return [
138
+ "",
139
+ "=== Serenity Trajectory Prompt ===",
140
+ body,
141
+ ""
142
+ ].join("\n");
143
+ }
144
+ //#endregion
145
+ //#region src/trajectory/carrier.ts
146
+ /**
147
+ * 等待 agent 空闲或销毁。
148
+ * @param ctx 宿主插件上下文(事件通道来源;**结构化访问**,故类型为 `unknown`,见文件头 I2 段)
149
+ * @param agent 目标 agent
150
+ * @returns 空闲、agent 销毁、或会话销毁时结算(永不 reject)
151
+ */
152
+ function waitAgentIdle(ctx, agent) {
153
+ return new Promise((resolve) => {
154
+ let settled = false;
155
+ const disposers = [];
156
+ const finish = () => {
157
+ if (settled) return;
158
+ settled = true;
159
+ for (const dispose of disposers) try {
160
+ dispose();
161
+ } catch {}
162
+ resolve();
163
+ };
164
+ const subscribe = (name, cb) => {
165
+ try {
166
+ const off = ctx.on?.(name, cb);
167
+ if (typeof off === "function") disposers.push(off);
168
+ } catch {}
169
+ };
170
+ subscribe("agent/status", (payload) => {
171
+ const p = payload;
172
+ if (p?.agent === agent && p?.status === "idle") finish();
173
+ });
174
+ subscribe("agent/disposed", (payload) => {
175
+ if (payload?.agent === agent) finish();
176
+ });
177
+ const sessionId = agent.session?.id;
178
+ subscribe("session/disposed", (session) => {
179
+ const id = session?.id;
180
+ if (typeof id === "string" && id === sessionId) finish();
181
+ });
182
+ if (agent.status === "idle") finish();
183
+ });
184
+ }
185
+ //#endregion
186
+ //#region src/skills/discovery.ts
187
+ /**
188
+ * skills/discovery.ts — CCC 入口 skill 自动发现(纯逻辑,零 DSH 依赖)
189
+ *
190
+ * 发现全部入口技能(原文全量,不截断)—— **现行三个来源**(原为四个,**来源 3 已于 2026-09-21 删除**):
191
+ * 0. **`.serenity` 记号文件内容 = 顶层入口 skill 名**(CCC 记号文件的权威语义)—— 最高优先
192
+ * 1. `.dsh/entry-skill` 指针文件(内容 = skill 名)—— 兼容旧约定
193
+ * 2. `.opencode/skills/*-serenity/SKILL.md` —— 自动扫描该 CCC 的顶层入口
194
+ * (命名模式 `*-serenity`)
195
+ * ~~3. `.dsh/skills/*-serenity/SKILL.md`(扫 ACC 装进 CCC 的技能副本)~~ —— 🔴 **已删**:
196
+ * owner 2026-09-21 令「连模板和安装命令一起删」(B 案)。**判据、实测证据与日期逐字记在
197
+ * `findEntrySkills()` 体内那一段注释里**——本头部只留结论,**不复述判据**(避免两处会漂的真相源)。
198
+ * 按名去重;顺序 = 记号文件 → 指针 → opencode 入口。
199
+ *
200
+ * 任何 CCC 都能自动注入其顶层入口 skill 全文(不硬编码名字)。
201
+ */
202
+ /** 顶层入口命名模式:目录名以 -serenity 结尾(home-serenity / tg-serenity / acc-serenity …) */
203
+ const SERENITY_SUFFIX = "-serenity";
204
+ function isSerenityEntry(name) {
205
+ return name.endsWith(SERENITY_SUFFIX);
206
+ }
207
+ /** 扫描某 skills 根下所有 `*-serenity` 目录(含 SKILL.md 的) */
208
+ function scanSerenityDirs(skillsRoot) {
209
+ if (!existsSync(skillsRoot)) return [];
210
+ return readdirSync(skillsRoot).filter((name) => isSerenityEntry(name)).map((name) => join(skillsRoot, name)).filter((dir) => statSync(dir).isDirectory() && existsSync(join(dir, "SKILL.md"))).sort();
211
+ }
212
+ /**
213
+ * 防穿越:skill 名必须是简单目录名(拒绝分隔符与 `..`/`.` 路径段)。
214
+ * v1.34.1:导出——轨迹 skill 声明(CCC 数据)的名字也要**先过这一关**才能用于拼路径
215
+ * (规格 `docs/cognitive-trajectory-skill-injection.md` §3「名字安全(必做)」)。
216
+ */
217
+ function isSafeSkillName(name) {
218
+ if (name.length === 0) return false;
219
+ if (name.includes("/") || name.includes("\\")) return false;
220
+ return !name.split(/[/\\]/).some((seg) => seg === ".." || seg === ".");
221
+ }
222
+ /**
223
+ * 按名在两个 skills 根下定位 SKILL.md(`.dsh/skills` 优先,其次 `.opencode/skills`)。
224
+ * v1.34.1:导出——轨迹 skill 注入复用本函数(不新造发现逻辑);不安全名在此**先行拒绝**
225
+ * (返回 null,且不发生任何以该名拼出的 fs 访问)。
226
+ */
227
+ function findSkillMd(root, name) {
228
+ if (!isSafeSkillName(name)) return null;
229
+ for (const base of [".dsh", ".opencode"]) {
230
+ const p = resolve(root, base, "skills", name, "SKILL.md");
231
+ if (existsSync(p)) return p;
232
+ }
233
+ return null;
234
+ }
235
+ /** 发现全部入口技能(原文全量,不截断) */
236
+ function findEntrySkills(root) {
237
+ const out = [];
238
+ const seen = /* @__PURE__ */ new Set();
239
+ const add = (path, source, forcedName) => {
240
+ if (!existsSync(path)) return;
241
+ const name = forcedName ?? basename(dirname(path));
242
+ if (seen.has(name)) return;
243
+ seen.add(name);
244
+ out.push({
245
+ name,
246
+ content: readFileSync(path, "utf-8"),
247
+ dir: dirname(path),
248
+ source
249
+ });
250
+ };
251
+ const marker = resolve(root, ".serenity");
252
+ if (existsSync(marker)) {
253
+ const name = readFileSync(marker, "utf-8").trim();
254
+ if (name) {
255
+ const md = findSkillMd(root, name);
256
+ if (md) add(md, "serenity-marker", name);
257
+ }
258
+ }
259
+ const pointer = resolve(root, ".dsh", "entry-skill");
260
+ if (existsSync(pointer)) {
261
+ const name = readFileSync(pointer, "utf-8").trim();
262
+ if (name) {
263
+ const md = findSkillMd(root, name);
264
+ if (md) add(md, "pointer", name);
265
+ }
266
+ }
267
+ for (const dir of scanSerenityDirs(resolve(root, ".opencode", "skills"))) add(join(dir, "SKILL.md"), "opencode");
268
+ return out;
269
+ }
270
+ function truncateContent(content, maxChars) {
271
+ if (content.length <= maxChars) return content;
272
+ return content.slice(0, maxChars) + `\n... (truncated, original length ${content.length} chars)`;
273
+ }
274
+ //#endregion
275
+ //#region src/skills/resource-index.ts
276
+ /**
277
+ * resource-index.ts — **两层披露树的第二层**:skill 参考件索引(纯逻辑,零 DSH 依赖)
278
+ *
279
+ * 设计真相源:`docs/serenity-skill-design.md` §4.2(六条硬性规则)+ §5.1(目录布局)。
280
+ * 一句话:**把 skill 的参考件"有什么"告诉模型,但不把"是什么"塞进去** ——
281
+ * 正文留给第三层(模型按需 `read`)。
282
+ *
283
+ * 为什么由我们生成(R↓,读宿主取证):宿主 `@deepseek-ai/dsh-skill` 的 `renderSkillContent()`
284
+ * (`lib/index.js` L57-81)**已经**产出 `<skill_resources>` 块,并逐字写着
285
+ * "Load referenced resources only as needed." ⇒ **两层模型是宿主自己给的**;而
286
+ * `SkillSummary` / `SkillDefinition` **没有任何列举子文件的位**(只有 `resourceBase` 一个基址)
287
+ * ⇒ 宿主刻意留给 provider 的正是**这一半:枚举**。
288
+ *
289
+ * 六条硬性规则(逐条对应设计 §4.2,改这里先改那里):
290
+ * ① **只枚举,不注入正文** —— 索引里**绝不**出现任何参考文件的正文;
291
+ * ② **确定性排序** = 相对路径的 **UTF-8 字节序**(🔴 **不用 mtime**:mtime 随检出漂移,
292
+ * 会让每请求的注入文本抖动;对照 `src/cro/core.ts:101` 那份**落盘快照**用 {name,bytes,mtime} ——
293
+ * **同一件事两处的取舍相反,那个差异是判据不是巧合**);
294
+ * ③ **有界 + 响亮** —— 条数/字符各有上限,超出**必须显式写出还剩多少项**,**绝不静默截断**;
295
+ * ④ **摘要可复现** —— 取该文件首个 `#` 标题(剥 markdown 记号);取不到 ⇒ 留空,**不编**;
296
+ * ⑤ **本体文件特殊标注** —— `skill.ttl` 存在 ⇒ 该行标 🧬 + 顶部加一句"已建模";
297
+ * ⑥ **范围 = `references/` 一级条目 + `skill.ttl`** —— 子目录**给名字 + 计数、不展开、不读正文**
298
+ * (🆕 2026-10-11 由**真实输入**逼出的修订:原写"不进子目录",实测 `references/` 下确实有内容目录,
299
+ * 只报"有 N 个子目录"不给名字 = **不可行动**;折中仍在裁决 Q2「只披露名字、不注入正文」的意图内)。
300
+ *
301
+ * 🔴 本文件**只认 `<skillDir>/skill.ttl`**(owner 裁决 Q6:静默、只认固定名)。
302
+ * `references/*-domain.ttl`(旧名)一律当**普通参考件**:不特殊对待、不告警 —— 那 14 份由 CCC 自行改名。
303
+ * 🔴 「未建模」不是异常(裁决 Q6 / 判据 J9):无 `skill.ttl` 的**普通 skill** 照常产出索引,
304
+ * **零告警、零误差** —— 增强必须对普通 skill 完全向后兼容。
305
+ *
306
+ * 🔴 **两个面,两个入口**(2026-10-12 增;owner 裁决 O2)—— 同源、但成本形态不同:
307
+ * · **载入面** `buildResourceIndex()` —— 完整索引(名字 + 首标题 + 字节),带 `.md` 文件头读取;
308
+ * 在三条路径上各附一次:入口段(`system-prompt.ts` order −50)/轨迹段(`trajectory/skills.ts`
309
+ * order −45)/目录 provider 的 `get()`(`opencode-skills.ts`)。
310
+ * · **目录面** `buildResourceNameSuffix()` —— **只有名字**,**不读任何正文**;只挂一处:
311
+ * 目录 provider 的 `list()`(`available_skills` 的 `SkillSummary.description`)。
312
+ * 🔴 为什么目录面在这里:`SkillSummary` 契约里**没有列资源的位**(只有 name/description/whenToUse)
313
+ * ⇒ `description` 是唯一位置,且必须**自动生成**。
314
+ * ⇒ 两个面**共用** `collectEntries()`(范围 = 规则⑥只写一遍)。
315
+ */
316
+ /** 第二层占位口:本 skill 的本体约束文件(固定名,设计 §5.1) */
317
+ const SKILL_ONTOLOGY_FILE = "skill.ttl";
318
+ /** 参考件根(一级;不进子目录) */
319
+ const REFERENCE_DIR = "references";
320
+ /** 摘要长度上限(防单行超长标题把表格撑坏) */
321
+ const EXCERPT_MAX_CHARS = 60;
322
+ /** 读文件头部字节数上限:摘标题只值读这么些(不为一行标题读整个大文件) */
323
+ const HEAD_BYTES = 4096;
324
+ /** 目录计数上限:防病态大树把一次注入拖慢(超出 ⇒ 报 `≥N`,仍然响亮) */
325
+ const DIR_SCAN_MAX_FILES = 1e3;
326
+ /**
327
+ * 递归统计目录下的文件数与总字节(**只统计,不读内容**)。
328
+ * 为什么递归:设计原写"不进子目录",而**真实输入**显示 `references/` 下确有内容目录
329
+ * (`home-rhetoric/references/corpus-subject-own`/`family-profiles/references/baby-handlebook`/
330
+ * `home-serenity/references/diagram-lab`)⇒ 只报"有 N 个子目录"而**不给名字**是不可行动的。
331
+ * 折中:**给名字 + 计数,仍不展开、仍不读正文**(= 裁决 Q2「只披露名字、不注入正文」的意图内)。
332
+ */
333
+ function scanDirStats(dir) {
334
+ let files = 0;
335
+ let bytes = 0;
336
+ let capped = false;
337
+ const walk = (d) => {
338
+ if (capped || files >= DIR_SCAN_MAX_FILES) {
339
+ capped = true;
340
+ return;
341
+ }
342
+ let names = [];
343
+ try {
344
+ names = readdirSync(d);
345
+ } catch {
346
+ return;
347
+ }
348
+ for (const name of names) {
349
+ if (files >= DIR_SCAN_MAX_FILES) {
350
+ capped = true;
351
+ return;
352
+ }
353
+ const abs = join(d, name);
354
+ let isDir = false;
355
+ let size = 0;
356
+ try {
357
+ const st = statSync(abs);
358
+ isDir = st.isDirectory();
359
+ size = st.size;
360
+ } catch {
361
+ continue;
362
+ }
363
+ if (isDir) walk(abs);
364
+ else {
365
+ files++;
366
+ bytes += size;
367
+ }
368
+ }
369
+ };
370
+ walk(dir);
371
+ return {
372
+ files,
373
+ bytes,
374
+ capped
375
+ };
376
+ }
377
+ /** 按 UTF-8 **字节序**比较两个相对路径(规则②;不是 locale 比较、不是 mtime)。
378
+ * 参数放宽到 `{ key }`:**未加水**的条目与**已加水**的条目共用同一个比较器 ⇒ 排序规则只写一遍。 */
379
+ function byByteOrder(a, b) {
380
+ return Buffer.compare(a.key, b.key);
381
+ }
382
+ /**
383
+ * 千分位分组(规则②的延伸:**不依赖 locale**)。
384
+ * 为什么不用 `toLocaleString()`:它随宿主 locale 变 ⇒ 同一目录在不同机器上产出**不同的注入文本**
385
+ * (本仓已有一条「locale 稳健性」的挂账,不再添一处)。
386
+ */
387
+ function groupThousands(n) {
388
+ return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
389
+ }
390
+ /**
391
+ * 取 markdown 首个 `#` 标题作摘要(规则④)。
392
+ *
393
+ * 细节(都由"可复现"推导):
394
+ * · **跳过 YAML frontmatter**(`---` 开头到下一 `---`)—— 否则题名行会被当成正文标题;
395
+ * · 只读文件**头部** `HEAD_BYTES`(一行标题不值得读整个大文件);
396
+ * · 剥掉行内记号(`*` `` ` `` `_` `#`)与首尾空白,压平内部连续空白;
397
+ * · 取不到 ⇒ `''`(**留空,不编** —— 编一个摘要等于让索引说假话);
398
+ * · 超长截断时**留省略号**(与规则③同一精神:截断要看得见)。
399
+ */
400
+ function firstHeading(file) {
401
+ let head;
402
+ try {
403
+ head = readFileSync(file, "utf-8").slice(0, HEAD_BYTES);
404
+ } catch {
405
+ return "";
406
+ }
407
+ const lines = head.split(/\r?\n/);
408
+ let start = 0;
409
+ if (lines[0]?.trim() === "---") {
410
+ for (let i = 1; i < lines.length; i++) if (lines[i]?.trim() === "---") {
411
+ start = i + 1;
412
+ break;
413
+ }
414
+ }
415
+ for (let i = start; i < lines.length; i++) {
416
+ const m = /^#{1,6}\s+(.*)$/.exec(lines[i] ?? "");
417
+ if (!m) continue;
418
+ const clean = (m[1] ?? "").replace(/[*`_]/g, "").replace(/\s+/g, " ").trim();
419
+ if (clean === "") continue;
420
+ return clean.length > EXCERPT_MAX_CHARS ? `${clean.slice(0, 59)}…` : clean;
421
+ }
422
+ return "";
423
+ }
424
+ /**
425
+ * 收集第二层的**条目**(范围 = 规则⑥:`references/` 一级条目 + 固定名本体)。
426
+ *
427
+ * 🔴 **只做 `readdir` / `stat`,不读任何文件正文** —— 两个消费者(第二层索引 / 目录面的名字清单)
428
+ * **共用这一份范围定义**:规则⑥只该有一个家,各写一遍必然漂移。
429
+ * 🔴 返回已按**字节序**排好(规则②:确定性,不用 mtime)。
430
+ */
431
+ function collectEntries(skillDir) {
432
+ const entries = [];
433
+ const refDir = join(skillDir, REFERENCE_DIR);
434
+ if (existsSync(refDir)) {
435
+ let names = [];
436
+ try {
437
+ names = readdirSync(refDir).sort();
438
+ } catch {
439
+ names = [];
440
+ }
441
+ for (const name of names) {
442
+ const abs = join(refDir, name);
443
+ let isDir = false;
444
+ try {
445
+ isDir = statSync(abs).isDirectory();
446
+ } catch {
447
+ continue;
448
+ }
449
+ const rel = `${REFERENCE_DIR}/${name}`;
450
+ entries.push({
451
+ rel,
452
+ kind: isDir ? "dir" : "file",
453
+ abs,
454
+ ontology: false,
455
+ key: Buffer.from(rel, "utf-8")
456
+ });
457
+ }
458
+ }
459
+ const ontoAbs = join(skillDir, SKILL_ONTOLOGY_FILE);
460
+ if (existsSync(ontoAbs)) entries.push({
461
+ rel: SKILL_ONTOLOGY_FILE,
462
+ kind: "file",
463
+ abs: ontoAbs,
464
+ ontology: true,
465
+ key: Buffer.from(SKILL_ONTOLOGY_FILE, "utf-8")
466
+ });
467
+ entries.sort(byByteOrder);
468
+ return entries;
469
+ }
470
+ /**
471
+ * 给条目**加水**(字节 + 摘要)—— 这两项都要读盘,因此与"有哪些条目"分开。
472
+ * 读不到 ⇒ `null`(宁可少列,也不列出读不到的项)。
473
+ */
474
+ function hydrate(entry) {
475
+ if (entry.kind === "dir") {
476
+ const { files, bytes, capped } = scanDirStats(entry.abs);
477
+ return {
478
+ rel: entry.rel,
479
+ kind: "dir",
480
+ bytes,
481
+ excerpt: `子目录 · ${capped ? `≥${groupThousands(files)}` : groupThousands(files)} 个文件`,
482
+ ontology: false,
483
+ key: entry.key
484
+ };
485
+ }
486
+ let bytes;
487
+ try {
488
+ bytes = statSync(entry.abs).size;
489
+ } catch {
490
+ return null;
491
+ }
492
+ return {
493
+ rel: entry.rel,
494
+ kind: "file",
495
+ bytes,
496
+ excerpt: entry.rel.endsWith(".md") ? firstHeading(entry.abs) : "",
497
+ ontology: entry.ontology,
498
+ key: entry.key
499
+ };
500
+ }
501
+ /** 收集候选(`references/` 一级条目 + 固定名本体),加好水并按字节序排好 */
502
+ function collectItems(skillDir) {
503
+ const items = [];
504
+ for (const entry of collectEntries(skillDir)) {
505
+ const item = hydrate(entry);
506
+ if (item !== null) items.push(item);
507
+ }
508
+ return items;
509
+ }
510
+ /**
511
+ * 生成第二层索引正文。
512
+ *
513
+ * @param skillDir skill 目录绝对路径
514
+ * @param options 上限覆盖(缺省用本模块常量)
515
+ * @returns 索引正文;**无可列项 ⇒ `''`**(规则⑥的一般化:`references/` 与 `skill.ttl` 都没有
516
+ * ⇒ 整块不产出 —— 空标题是噪声)。调用方按"空串不产出该块"处理。
517
+ */
518
+ function buildResourceIndex(skillDir, options = {}) {
519
+ const maxItems = options.maxItems ?? 40;
520
+ const maxChars = options.maxChars ?? 4096;
521
+ const items = collectItems(skillDir);
522
+ if (items.length === 0) return "";
523
+ const modeled = items.some((i) => i.ontology);
524
+ const shown = items.slice(0, maxItems);
525
+ const hidden = items.length - shown.length;
526
+ const lines = ["### 本 skill 的参考件(第二层 · 按需读取)", "> 这里只有**清单**,没有正文:需要时用 `read` 打开下列路径(相对本 skill 目录)。"];
527
+ if (modeled) lines.push("> 🧬 **本 skill 已建模**:文字内容以 `skill.ttl` 为总约束。");
528
+ lines.push("", "| 文件 | 摘要 | 字节 |", "|---|---|---|");
529
+ for (const item of shown) {
530
+ const label = item.kind === "dir" ? `${item.rel}/` : item.rel;
531
+ const excerpt = item.ontology ? "🧬 本 skill 的本体约束(已建模)" : item.excerpt;
532
+ lines.push(`| ${label} | ${excerpt} | ${groupThousands(item.bytes)} |`);
533
+ }
534
+ if (hidden > 0) lines.push("", `…**还有 ${hidden} 项未列出**(本层单次上限 ${maxItems} 项)。`);
535
+ const body = lines.join("\n");
536
+ if (body.length <= maxChars) return body;
537
+ return `${body.slice(0, maxChars)}\n…(第二层索引已按 ${groupThousands(maxChars)} 字符上限截断,原文 ${groupThousands(body.length)} 字符)`;
538
+ }
539
+ /**
540
+ * 收集**只有名字**的第二层清单(目录面专用)。
541
+ *
542
+ * 为什么单独一个入口(而不是复用 `buildResourceIndex`):**成本形态不同** ——
543
+ * 索引要给每个 `.md` 读文件头取摘要(规则④),而目录面是**每请求 × 每个 skill** 都要算的
544
+ * ⇒ 这条路径**一个字节正文都不读**(只 `readdir`/`stat`)。
545
+ * 范围仍与索引**同源**(共用 `collectEntries` ⇒ 规则⑥只有一个家)。
546
+ */
547
+ function buildResourceNames(skillDir, options = {}) {
548
+ const maxItems = options.maxItems ?? 40;
549
+ const entries = collectEntries(skillDir);
550
+ const shown = entries.slice(0, maxItems);
551
+ return {
552
+ names: shown.map((e) => e.kind === "dir" ? `${basename(e.rel)}/` : basename(e.rel)),
553
+ hidden: entries.length - shown.length
554
+ };
555
+ }
556
+ /**
557
+ * 目录面后缀:把名字清单拼成**可直接接在 `description` 后面**的一段文本。
558
+ *
559
+ * 🔴 **自动生成,绝不手写**(手写必与文件树漂);无可列项 ⇒ `''`(**普通 skill 零噪声** —— 判据 J9)。
560
+ * 🔴 **有界 + 响亮**(规则③):超上限时写出"另有 N 项",**绝不静默截断**。
561
+ */
562
+ function buildResourceNameSuffix(skillDir, options = {}) {
563
+ const { names, hidden } = buildResourceNames(skillDir, options);
564
+ if (names.length === 0) return "";
565
+ const tail = hidden > 0 ? ` …另有 ${hidden} 项` : "";
566
+ return `(参考件:${names.join(" / ")}${tail})`;
567
+ }
568
+ //#endregion
569
+ //#region src/trajectory/skills.ts
570
+ /**
571
+ * trajectory/skills.ts — 轨迹 skill 声明解析 + 注入内容装配(纯逻辑,零 DSH 依赖)
572
+ *
573
+ * 接口真相源:`docs/cognitive-trajectory-skill-injection.md` v1.0(§2 数据契约 / §3 注入契约)。
574
+ * 一句话:**轨迹自己声明它要挂哪些 skill**(`SESSION.md` 顶部 YAML frontmatter 的 `skills:`),
575
+ * ACC 在**绑定期间**把这些 skill 的 `SKILL.md` 全文注入系统提示词。
576
+ *
577
+ * 分工(R↓:为什么解析与注册分开):
578
+ * · 本模块 = **纯文本层**(解析 / 装配 / 安全判定)——可脱离 DSH 单测;
579
+ * · `seams/system-prompt.ts` 的 `registerCognitiveTrajectorySkillSection` = **宿主接入层**
580
+ * (per-agent `ctx.systemPrompt.section`),只做门与注册,不再解析文本。
581
+ *
582
+ * 边界(规格 §2.2):**不解析** `skills` 以外任何键的语义(CCC 可自由使用,ACC 视而不见);
583
+ * **不改写** frontmatter(ACC 只读)。
584
+ */
585
+ /** 全文注入总长上限(字符数;规格 §3「缺省 32 KB」——防上下文爆炸) */
586
+ const COGNITIVE_TRAJECTORY_SKILLS_MAX_CHARS = 32768;
587
+ /** 缺失标记(规格 §3/§6-3 的验收锚点:任何"拿不到全文"的情况都必须带它,绝不静默)
588
+ * 🔴 2026-09-25(S142 分批清死代码·第 1 批):**降为模块内常量**——全仓唯一消费者在本文件,
589
+ * 导出只服务测试(测试用的是字面量 `'[缺失]'`)⇒ 导出面无意义。 */
590
+ const SKILL_MISSING_MARK = "[缺失]";
591
+ /** 统一缺失提示(规格 §3 逐字约定:`[缺失] <name>(未找到该 skill)`) */
592
+ function missingNotice(name) {
593
+ return `${SKILL_MISSING_MARK} ${name}(未找到该 skill)`;
594
+ }
595
+ /**
596
+ * 抽取文件顶部 YAML frontmatter 区(规格 §2.1)。
597
+ *
598
+ * 规则:**第一行必须是 `---`**;到下一个 `---` 行为止是 frontmatter 区。
599
+ * 首行不是 `---`,或没有闭合 `---` ⇒ 视为**无 frontmatter**(返回 null)。
600
+ * 边界判断(R↓):未闭合块按"无 frontmatter"处理而非吞掉整个文件——半个声明块是格式错误,
601
+ * 猜它的意图等于把坏数据当好消息(宁可零注入,也不注入来路不明的半截列表)。
602
+ *
603
+ * @param content SESSION.md 全文
604
+ * @returns frontmatter 区各行(不含两条 `---` 定界行);无 → null
605
+ */
606
+ function extractFrontmatter(content) {
607
+ const lines = content.split(/\r?\n/);
608
+ if (lines[0] !== "---") return null;
609
+ for (let i = 1; i < lines.length; i++) if (lines[i] === "---") return lines.slice(1, i);
610
+ return null;
611
+ }
612
+ /** 行内数组值 `[a, b, c]`(仅当首尾为方括号时视为行内数组;否则返回 null) */
613
+ function parseInlineArray(value) {
614
+ const trimmed = value.trim();
615
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
616
+ return trimmed.slice(1, -1).split(",").map((s) => s.trim()).filter((s) => s !== "");
617
+ }
618
+ /**
619
+ * 解析声明(规格 §2.1,**最小实现,不引 YAML 依赖**)。
620
+ *
621
+ * 只认 `skills:` 键;未知键一律忽略(向前兼容,不报错)。两种值形态都支持:
622
+ * ① 行内数组 `skills: [home-rhetoric, acc-eap]`
623
+ * ② 缩进列表(`skills:` 后各起一行 ` - a`)
624
+ * 名字:去首尾空白、空项丢弃、**保持书写顺序**(注入顺序 = 书写顺序)、同名去重(保留首次位置)。
625
+ *
626
+ * 未定义形态(不在规格的两种值形态内,如 `skills: a` 裸标量)⇒ **视为无声明**:
627
+ * 本函数只认契约里写明的两种形态,猜第三种等于自造契约(规格 §2.1 只列了 ①②)。
628
+ *
629
+ * @param sessionMd SESSION.md 全文
630
+ * @returns 声明的 skill 名(按书写顺序去重);无 frontmatter / 无 `skills` 键 / 值为空 → `[]`
631
+ */
632
+ function parseDeclaredSkills(sessionMd) {
633
+ const fm = extractFrontmatter(sessionMd);
634
+ if (!fm) return [];
635
+ const out = [];
636
+ const seen = /* @__PURE__ */ new Set();
637
+ const push = (name) => {
638
+ const clean = name.trim();
639
+ if (clean === "" || seen.has(clean)) return;
640
+ seen.add(clean);
641
+ out.push(clean);
642
+ };
643
+ for (let i = 0; i < fm.length; i++) {
644
+ const line = fm[i] ?? "";
645
+ const m = /^skills\s*:(.*)$/.exec(line);
646
+ if (!m) continue;
647
+ const rest = (m[1] ?? "").trim();
648
+ const inline = parseInlineArray(rest);
649
+ if (inline) {
650
+ for (const name of inline) push(name);
651
+ continue;
652
+ }
653
+ if (rest !== "") continue;
654
+ for (let j = i + 1; j < fm.length; j++) {
655
+ const item = /^\s*-\s*(.*)$/.exec(fm[j] ?? "");
656
+ if (!item) break;
657
+ push(item[1] ?? "");
658
+ }
659
+ }
660
+ return out;
661
+ }
662
+ /** SESSION.md 绝对路径(绑定记录存的是绝对路径;非绝对态按 CCC 根解析——防御旧数据) */
663
+ function absoluteMdPath(root, mdPath) {
664
+ return mdPath.startsWith(root) ? mdPath : resolve(root, mdPath);
665
+ }
666
+ /**
667
+ * 注册门(规格 §3「无绑定 / 无声明 ⇒ 完全不注册该 section」)。
668
+ *
669
+ * 返回 true 的两种情况:
670
+ * · 文件在且**有非空声明** → 注册(有内容可注入)
671
+ * · 文件**不在 / 读不到** → 同样注册——此时 section 文本是响亮的 `[缺失]` 提示
672
+ * (规格 §6-6 要求"不静默";不注册就等于静默)
673
+ * 其余(文件在但无 frontmatter / 无 `skills` 键 / 值为空)⇒ false,不注册、不产生噪声。
674
+ */
675
+ function hasCognitiveTrajectorySkillsDeclaration(root, mdPath) {
676
+ const abs = absoluteMdPath(root, mdPath);
677
+ if (!existsSync(abs)) return true;
678
+ try {
679
+ return parseDeclaredSkills(readFileSync(abs, "utf-8")).length > 0;
680
+ } catch {
681
+ return true;
682
+ }
683
+ }
684
+ /** 去空白 + 丢空项 + 同名去重(保留首次出现位置)——两条声明来源共用同一规范化 */
685
+ function normalizeNames(names) {
686
+ const out = [];
687
+ const seen = /* @__PURE__ */ new Set();
688
+ for (const raw of names) {
689
+ const clean = raw.trim();
690
+ if (clean === "" || seen.has(clean)) continue;
691
+ seen.add(clean);
692
+ out.push(clean);
693
+ }
694
+ return out;
695
+ }
696
+ /**
697
+ * 合并两条声明来源(v1.44.0):**CCC 级在前,轨迹级追加**,同名去重取首次位置。
698
+ *
699
+ * 两条来源(owner 令 2026-09-20):
700
+ * · **CCC 级** = `.opencode/serenity.json` 的 `cognitiveTrajectory.skills`(本 CCC 所有轨迹共有的底座);
701
+ * · **轨迹级** = 该轨迹 `SESSION.md` frontmatter 的 `skills:`(这条轨迹额外的)。
702
+ *
703
+ * 为什么是**并集**而不是二选一(R↓):C1 设计当初否决"纯 CCC 全局"的理由是"会**丢掉
704
+ * per-trajectory**";并集把两侧都保住——容器给底座,单条轨迹仍可加自己的额外项。
705
+ * 为什么 CCC 级**在前**:底座先立、增量随后,与"入口身份块 order=-50 → 本 section order=-45"
706
+ * 的既有先后观感一致。
707
+ */
708
+ function mergeSkillNames(cccSkills, trajSkills) {
709
+ return normalizeNames([...cccSkills, ...trajSkills]);
710
+ }
711
+ /**
712
+ * 装配注入正文(规格 §3):按**合并后的**顺序拼接各 skill 的 `SKILL.md` 全文,每段带来源抬头
713
+ * `=== skill: <name> ===`;拿不到全文的一律以 `[缺失]` 响亮提示占位(**不静默**)。
714
+ *
715
+ * 安全(规格 §3 🔒):名字来自 CCC 数据(frontmatter / CCC 配置)⇒ **先过 `isSafeSkillName`**;
716
+ * 不安全 ⇒ 按缺失处理,且不调用 `findSkillMd` ⇒ **不发生任何以该名拼出的 fs 访问**。
717
+ *
718
+ * 长度守卫:复用已导出的 `truncateContent`,**在合并之后**统一截断(两条来源共享同一上限,
719
+ * 不是各给 32 KB——否则上限会随来源数翻倍)。
720
+ *
721
+ * @param root CCC 根
722
+ * @param mdPath 轨迹的 SESSION.md 路径(**来自绑定的 `mdPath`**,不用 label 拼)
723
+ * @param maxChars 总长上限(字符)
724
+ * @param cccSkills CCC 级声明(`readCognitiveTrajectorySkills`;缺省空 = 只有轨迹级,行为与 v1.43 一致)
725
+ * @param onInjectedNames 观测回调(本次请求实际带上的 skill 名;**纯观测**,供用量统计用——
726
+ * 回调抛错被吞,**不影响注入正文**)
727
+ * @returns 注入正文;两条来源皆无声明 → `''`(空串 = 宿主不产出该 section 块);
728
+ * SESSION.md 缺失/读失败 → 响亮提示(若同时有 CCC 级声明,则提示在前、正文随后——
729
+ * **提示不因"别处有内容"而被吞掉**)
730
+ */
731
+ function buildCognitiveTrajectorySkillsSection(root, mdPath, maxChars = COGNITIVE_TRAJECTORY_SKILLS_MAX_CHARS, cccSkills = [], onInjectedNames) {
732
+ const abs = absoluteMdPath(root, mdPath);
733
+ let trajNames = [];
734
+ let notice = "";
735
+ if (!existsSync(abs)) notice = `${SKILL_MISSING_MARK} ${abs}(轨迹的 SESSION.md 不存在——skill 声明无从读取;若刚重建/移动过轨迹,请确认绑定记录)`;
736
+ else try {
737
+ trajNames = parseDeclaredSkills(readFileSync(abs, "utf-8"));
738
+ } catch (err) {
739
+ notice = `${SKILL_MISSING_MARK} ${abs}(SESSION.md 读取失败:${err instanceof Error ? err.message : String(err)})`;
740
+ }
741
+ const names = mergeSkillNames(cccSkills, trajNames);
742
+ if (names.length === 0) return notice;
743
+ if (onInjectedNames) try {
744
+ onInjectedNames(names);
745
+ } catch {}
746
+ const blocks = [];
747
+ for (const name of names) {
748
+ if (!isSafeSkillName(name)) {
749
+ blocks.push(`${SKILL_MISSING_MARK} ${name}(名字不安全,已拒绝解析路径)`);
750
+ continue;
751
+ }
752
+ const file = findSkillMd(root, name);
753
+ if (!file) {
754
+ blocks.push(missingNotice(name));
755
+ continue;
756
+ }
757
+ try {
758
+ const index = buildResourceIndex(dirname(file));
759
+ const body = readFileSync(file, "utf-8");
760
+ blocks.push(`=== skill: ${name} ===\n${index ? `${body}\n\n${index}` : body}`);
761
+ } catch {
762
+ blocks.push(missingNotice(name));
763
+ }
764
+ }
765
+ const body = blocks.join("\n\n");
766
+ return truncateContent(notice ? `${notice}\n\n${body}` : body, maxChars);
767
+ }
768
+ //#endregion
769
+ //#region src/surface/usage.ts
770
+ /**
771
+ * surface/usage.ts — ACC 用量统计(**skill 加载次数** + **MSM 执行次数**;落该 CCC 的 `_tmp`)
772
+ *
773
+ * ## 所有者令(本模块的存在理由)
774
+ *
775
+ * 「我需要对 ACC 增加一个统计功能,包括 **skill 加载次数统计,msm 执行次数统计**;
776
+ * 要求这个统计**自动生成在 CCC 的 `_tmp` 内的指定文件**」
777
+ * +(对齐时确认)「**skill 加载**两个口径**都记、用字段区分**」「**只记名字、不记参数**」
778
+ * 「**重启后读旧文件续算**」。
779
+ *
780
+ * ## 它解决什么麻烦(白话)
781
+ *
782
+ * 在此之前,「**哪些 skill 真的被加载过、哪些 MSM 真的在跑**」在 ACC 侧**一个数都没有** ——
783
+ * 要判断「这东西还有人用吗」,只能靠翻会话记录**猜**。
784
+ * ⇒ 本模块让每个 CCC 留下一份**纯计数**账本:**用了哪个、多少次、第一次和最近一次是什么时候**。
785
+ * 用途 = 清理/精简的**依据**(没有人用的东西与天天在用的东西,不再靠猜)。
786
+ *
787
+ * ## 🔴 铁律(与 `cro/log.ts` 同族)
788
+ *
789
+ * **本模块每个导出函数都不抛错**:读盘 / 解析 / 写盘失败一律返回结构化失败,
790
+ * 由调用方只记一行日志(或什么都不做)⇒ **统计的问题绝不影响工具执行与既有链路**。
791
+ * 调用点在 `tools/post-execute` 的中途 ⇒ **更不能抛**(否则等于用统计打断会话)。
792
+ *
793
+ * ## 🔴 只记名字(所有者令;也是本模块唯一的"不入盘"约束)
794
+ *
795
+ * **绝不写 `arguments`**:MSM 参数里可能有凭据 / 路径 / 正文。
796
+ * ⇒ 本模块只保留**计数键**(skill 名 / MSM 名),并对其做**长度与数量上限**(见常量),
797
+ * 使文件规模**结构上有界**(不需要轮转,也不会被异常名字撑爆)。
798
+ *
799
+ * ## 🔴 重启后必须续算(所有者令)
800
+ *
801
+ * 宿主会被重启(本容器实测 24h 内 3 次)⇒ 计数**不能只活在内存里**:
802
+ * 每次写盘 = **读旧文件 → 在旧值上自增 → 原子写**(`tmp + rename`,与 `wake/registry.ts` /
803
+ * `cro/log.ts` 同款形态)⇒ **进程重启后计数自然延续**,无需额外的 flush/落盘时机。
804
+ *
805
+ * ## 口径(两个字段,勿合并)
806
+ *
807
+ * | 字段 | 含义 | 喂它的地方 |
808
+ * |---|---|---|
809
+ * | `skill.loads` | 模型**主动**调宿主 `skill` 工具(把某 skill 的 `SKILL.md` 全文载入) | `tools/post-execute`(工具名 = `skill`) |
810
+ * | `skill.injections` | ACC **每请求注入**的 skill 段(`cognitiveTrajectory.skills` / 轨迹 frontmatter 声明) | `seams/system-prompt.ts` 的 cognitive-trajectory-skills section 求值回调 |
811
+ * | `msm` | `msm` 工具的一次执行,按 **MSM 名**分桶 | `tools/post-execute`(工具名 = `msm`) |
812
+ *
813
+ * 🔴 **两者差好几个数量级**:`loads` 是模型的**动作**(稀疏),`injections` 是**请求的属性**(稠密)。
814
+ * 合并成一个数就**再也读不出**「这个 skill 是被人主动用的,还是只是被配上了」。
815
+ */
816
+ /** 落点目录(所有者令:CCC 的 `_tmp` 内) */
817
+ const USAGE_STATS_SUBDIR = "_tmp";
818
+ /** 固定文件名(所有者令「指定文件」;对齐时我取的名字,owner 未反对 ⇒ 定为常量) */
819
+ const USAGE_STATS_FILENAME = "acc-usage.json";
820
+ /** 每个桶的键数上限(**结构上界**,见文件头"只记名字"节) */
821
+ const USAGE_MAX_KEYS_PER_BUCKET = 1e3;
822
+ const USAGE_STATS_VERSION = 1;
823
+ /** 统计文件绝对路径(**纯函数**) */
824
+ function usageStatsPath(root) {
825
+ return join(root, USAGE_STATS_SUBDIR, USAGE_STATS_FILENAME);
826
+ }
827
+ /** 空账本(**纯函数**) */
828
+ function emptyUsageStats() {
829
+ return {
830
+ version: USAGE_STATS_VERSION,
831
+ updatedAt: isoLocal(0),
832
+ skill: {
833
+ loads: {},
834
+ injections: {}
835
+ },
836
+ msm: {}
837
+ };
838
+ }
839
+ /** 单条计数形状校验(容忍手改:字段缺失/类型不符 ⇒ 丢弃该条,不阻断整档) */
840
+ function asCounter(value) {
841
+ const c = value;
842
+ if (!c || typeof c !== "object") return null;
843
+ if (typeof c.count !== "number" || !Number.isFinite(c.count) || c.count < 0) return null;
844
+ const firstAt = typeof c.firstAt === "string" ? c.firstAt : "";
845
+ const lastAt = typeof c.lastAt === "string" ? c.lastAt : firstAt;
846
+ return {
847
+ count: Math.floor(c.count),
848
+ firstAt,
849
+ lastAt
850
+ };
851
+ }
852
+ /** 桶校验(非对象 ⇒ 空桶;逐键过滤非法条) */
853
+ function asBucket(value) {
854
+ const out = {};
855
+ if (!value || typeof value !== "object") return out;
856
+ for (const [k, v] of Object.entries(value)) {
857
+ const c = asCounter(v);
858
+ if (c) out[k] = c;
859
+ }
860
+ return out;
861
+ }
862
+ /**
863
+ * 读取账本(**永不抛**;读坏 ⇒ 空账本 + 返回 error 文本,**不静默吞掉**)。
864
+ * 文件不存在 = 正常初态(`error: null` 且空账本)。
865
+ */
866
+ function loadUsageStats(root) {
867
+ const path = usageStatsPath(root);
868
+ if (!existsSync(path)) return {
869
+ stats: emptyUsageStats(),
870
+ error: null
871
+ };
872
+ try {
873
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
874
+ const skill = parsed.skill ?? {};
875
+ return {
876
+ stats: {
877
+ version: USAGE_STATS_VERSION,
878
+ updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : isoLocal(0),
879
+ skill: {
880
+ loads: asBucket(skill.loads),
881
+ injections: asBucket(skill.injections)
882
+ },
883
+ msm: asBucket(parsed.msm)
884
+ },
885
+ error: null
886
+ };
887
+ } catch (err) {
888
+ return {
889
+ stats: emptyUsageStats(),
890
+ error: `用量统计解析失败(${String(err?.message ?? err)})`
891
+ };
892
+ }
893
+ }
894
+ /** 键是否可记(非空 + 不超长;**不截断**——截断会造出一个不存在的名字) */
895
+ function isRecordableUsageName(name) {
896
+ return typeof name === "string" && name !== "" && [...name].length <= 120;
897
+ }
898
+ /**
899
+ * 在桶上自增一格(**纯函数**:不改入参,返回新桶)。
900
+ * 新键超桶上限 ⇒ **不新增**(但仍自增已有键)⇒ 键数**结构上有界**。
901
+ */
902
+ function bumpBucket(bucket, name, at, maxKeys = USAGE_MAX_KEYS_PER_BUCKET) {
903
+ const prev = bucket[name];
904
+ if (!prev && Object.keys(bucket).length >= maxKeys) return bucket;
905
+ const next = prev ? {
906
+ count: prev.count + 1,
907
+ firstAt: prev.firstAt || at,
908
+ lastAt: at
909
+ } : {
910
+ count: 1,
911
+ firstAt: at,
912
+ lastAt: at
913
+ };
914
+ return {
915
+ ...bucket,
916
+ [name]: next
917
+ };
918
+ }
919
+ /** 从工具入参里取名字(首个命中的键胜出;取不到 ⇒ `null`) */
920
+ function nameFromArgs(args, keys) {
921
+ if (args === null || typeof args !== "object") return null;
922
+ const a = args;
923
+ for (const k of keys) if (isRecordableUsageName(a[k])) return a[k];
924
+ return null;
925
+ }
926
+ /** `msm` 工具的名字键(`msm(name, args)` 的第一个参数) */
927
+ const MSM_NAME_KEYS = ["name"];
928
+ /** `skill` 工具的名字键(宿主 `skill` 工具用 `name`;另两个是防御性容错) */
929
+ const SKILL_NAME_KEYS = [
930
+ "name",
931
+ "skill",
932
+ "skillName"
933
+ ];
934
+ const NOTHING = {
935
+ ok: true,
936
+ error: null,
937
+ recorded: null,
938
+ name: null,
939
+ wrote: false
940
+ };
941
+ /**
942
+ * 读 → 自增 → 原子写(**本模块唯一写盘通道;永不抛**)。
943
+ *
944
+ * @param root CCC 根
945
+ * @param mutate 在已载入的账本上做一次自增(返回值 = 本次记录的结论;返回 `NOTHING` 则不写盘)
946
+ * @param nowMs 当前毫秒(可注入 ⇒ 便于测 firstAt/lastAt)
947
+ */
948
+ function writeThrough(root, mutate, nowMs = Date.now()) {
949
+ const path = usageStatsPath(root);
950
+ try {
951
+ const at = isoLocal(nowMs);
952
+ const loaded = loadUsageStats(root);
953
+ const outcome = mutate(loaded.stats, at);
954
+ if (outcome.recorded === null) return outcome;
955
+ loaded.stats.updatedAt = at;
956
+ mkdirSync(join(root, USAGE_STATS_SUBDIR), { recursive: true });
957
+ const tmp = `${path}.tmp`;
958
+ writeFileSync(tmp, `${JSON.stringify(loaded.stats, null, 2)}\n`, "utf-8");
959
+ renameSync(tmp, path);
960
+ return {
961
+ ...outcome,
962
+ ok: true,
963
+ error: null,
964
+ wrote: true
965
+ };
966
+ } catch (err) {
967
+ return {
968
+ ok: false,
969
+ error: `用量统计写入失败(${String(err?.message ?? err)})`,
970
+ recorded: null,
971
+ name: null,
972
+ wrote: false
973
+ };
974
+ }
975
+ }
976
+ /**
977
+ * 记录一次工具调用的用量(**`tools/post-execute` 的喂入口**)。
978
+ *
979
+ * 只认两类工具名;其余一律 `NOTHING`(**不产生文件、不产生噪声**)。
980
+ * 🔴 **不看 `arguments` 的其余内容**——只取名字键。
981
+ */
982
+ function recordToolUsage(root, toolName, args, nowMs = Date.now()) {
983
+ if (toolName === "msm") {
984
+ const name = nameFromArgs(args, MSM_NAME_KEYS);
985
+ if (!name) return NOTHING;
986
+ return writeThrough(root, (stats, at) => {
987
+ stats.msm = bumpBucket(stats.msm, name, at);
988
+ return {
989
+ ok: true,
990
+ error: null,
991
+ recorded: "msm",
992
+ name,
993
+ wrote: false
994
+ };
995
+ }, nowMs);
996
+ }
997
+ if (toolName === "skill") {
998
+ const name = nameFromArgs(args, SKILL_NAME_KEYS);
999
+ if (!name) return NOTHING;
1000
+ return writeThrough(root, (stats, at) => {
1001
+ stats.skill.loads = bumpBucket(stats.skill.loads, name, at);
1002
+ return {
1003
+ ok: true,
1004
+ error: null,
1005
+ recorded: "skill.load",
1006
+ name,
1007
+ wrote: false
1008
+ };
1009
+ }, nowMs);
1010
+ }
1011
+ return NOTHING;
1012
+ }
1013
+ /**
1014
+ * 记录一次 **skill 注入**(每个请求的 skill 段装配时调用)。
1015
+ *
1016
+ * ⚠️ 与 `recordToolUsage` 不同,本函数的调用频度 = **请求频度**(稠密):
1017
+ * 调用方应**只在真的注入了非空内容时**调用(空 section 由宿主丢弃 ⇒ 不产生噪声)。
1018
+ * 一次调用只写一次盘(`names` 内的多个 skill 合并在**同一次**读改写里)。
1019
+ */
1020
+ function recordSkillInjections(root, names, nowMs = Date.now()) {
1021
+ const usable = names.filter((n) => isRecordableUsageName(n));
1022
+ if (usable.length === 0) return NOTHING;
1023
+ return writeThrough(root, (stats, at) => {
1024
+ let bucket = stats.skill.injections;
1025
+ for (const n of usable) bucket = bumpBucket(bucket, n, at);
1026
+ stats.skill.injections = bucket;
1027
+ return {
1028
+ ok: true,
1029
+ error: null,
1030
+ recorded: "skill.injection",
1031
+ name: usable.join(","),
1032
+ wrote: false
1033
+ };
1034
+ }, nowMs);
1035
+ }
1036
+ /** 验证窗口:当前时步 ± 1(容忍时钟漂移/生成延迟) */
1037
+ const TOTP_WINDOW = 1;
1038
+ /** 输出位数(标准 6 位) */
1039
+ const TOTP_DIGITS = 6;
1040
+ const B32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
1041
+ /** base32(无填充,大小写不敏感)→ 字节;非法字符抛错。 */
1042
+ function base32Decode(input) {
1043
+ const clean = input.toUpperCase().replace(/[\s=]/g, "");
1044
+ if (clean.length === 0) throw new Error("empty base32");
1045
+ const out = [];
1046
+ let bits = 0;
1047
+ let value = 0;
1048
+ for (const ch of clean) {
1049
+ const idx = B32_ALPHABET.indexOf(ch);
1050
+ if (idx === -1) throw new Error(`invalid base32 char: ${ch}`);
1051
+ value = value << 5 | idx;
1052
+ bits += 5;
1053
+ if (bits >= 8) {
1054
+ out.push(value >>> bits - 8 & 255);
1055
+ bits -= 8;
1056
+ }
1057
+ }
1058
+ return Uint8Array.from(out);
1059
+ }
1060
+ /** 一个时步的 TOTP code(6 位,前导零保留)。counter = floor(epochSeconds / step) */
1061
+ function totpCode(secretBase32, counter) {
1062
+ const key = base32Decode(secretBase32);
1063
+ const msg = Buffer.alloc(8);
1064
+ msg.writeBigUInt64BE(BigInt(counter));
1065
+ const digest = createHmac("sha1", Buffer.from(key)).update(msg).digest();
1066
+ const offset = digest[digest.length - 1] & 15;
1067
+ const bin = (digest[offset] & 127) << 24 | digest[offset + 1] << 16 | digest[offset + 2] << 8 | digest[offset + 3];
1068
+ return String(bin % 10 ** TOTP_DIGITS).padStart(TOTP_DIGITS, "0");
1069
+ }
1070
+ /** 当前 epoch 秒 */
1071
+ function nowEpochSeconds() {
1072
+ return Math.floor(Date.now() / 1e3);
1073
+ }
1074
+ /**
1075
+ * 校验用户输入的 code(允许 ±TOTP_WINDOW 时步漂移,防重放窗口内同 code 复用由
1076
+ * 调用方按账号记录最近成功 counter 实现——本函数只做纯算法校验)。
1077
+ * @returns 命中的 counter(用于防重放);不匹配返回 null。
1078
+ */
1079
+ function verifyTotpCode(secretBase32, code, nowSeconds = nowEpochSeconds()) {
1080
+ if (!/^\d{6}$/.test(code)) return null;
1081
+ const current = Math.floor(nowSeconds / 30);
1082
+ for (let offset = -1; offset <= TOTP_WINDOW; offset++) if (totpCode(secretBase32, current + offset) === code) return current + offset;
1083
+ return null;
1084
+ }
1085
+ //#endregion
1086
+ //#region src/kernel/ports.ts
1087
+ /**
1088
+ * 集中端口表。
1089
+ * A=宿主主面(插件不监听)/ B=网关(可配,默认关)/ C=微信发送(默认开,仅 loopback)
1090
+ * / D=轨迹约束调试(默认关)。
1091
+ */
1092
+ const FACE_PORTS = {
1093
+ main: {
1094
+ port: 3080,
1095
+ host: null,
1096
+ defaultEnabled: true,
1097
+ meaning: "宿主 DSH 主 WebUI + 插件 /serenity/* 路由面(**插件不起此 listener**,端口自宿主读)"
1098
+ },
1099
+ gateway: {
1100
+ port: 3081,
1101
+ host: "0.0.0.0",
1102
+ defaultEnabled: false,
1103
+ meaning: "双端口网关(外部登录 + 全量反代宿主主面;host/port 走 plugin 全局配置)"
1104
+ },
1105
+ weixinSend: {
1106
+ port: 3082,
1107
+ host: "127.0.0.1",
1108
+ defaultEnabled: true,
1109
+ meaning: "微信主动发送面(容器外进程 → POST /send;与 3080 显式解耦,不经网关)"
1110
+ }
1111
+ };
1112
+ FACE_PORTS.main.port;
1113
+ /** 网关端口默认值(面 B) */
1114
+ const GATEWAY_PORT = FACE_PORTS.gateway.port;
1115
+ /** 微信主动发送端口默认值(面 C) */
1116
+ const WEIXIN_SEND_PORT = FACE_PORTS.weixinSend.port;
1117
+ /**
1118
+ * **机制端口**词表(output-guard 用:agent 在**用户可见输出**里提及内部端口即打回)。
1119
+ *
1120
+ * 由 {@link FACE_PORTS} 机械派生 ⇒ 与面清单**永不漂移**。
1121
+ * ⚠️ 修复记录(C4 块 B,2026-09-15):旧常量硬写 `['3080','3081','3099','3100']`
1122
+ * ——**漏 3082**(微信发送面,默认开)⇒ 守卫拦不住"3082"这类提及。现由表派生,四面齐全。
1123
+ * ⚠️ 面变更记录(2026-10-15):**面 E(3100 ACP/问答)已整体退役**(owner 裁「砍掉外部问答」)
1124
+ * ⇒ 本词表随之少一项,**无需另改**(这正是"派生而非手抄"的收益)。
1125
+ */
1126
+ const MECHANISM_PORTS = Object.keys(FACE_PORTS).map((id) => String(FACE_PORTS[id].port));
1127
+ //#endregion
1128
+ //#region src/kernel/config.ts
1129
+ /**
1130
+ * kernel/config.ts — plugin 全局配置读写(结构化,`~/.dsh/serenity-hooks.json`)
1131
+ *
1132
+ * 归属原则(S142 用户拍板,v1.22):**plugin 是全局的,CCC 是具体的**——
1133
+ * 账号密码/gateway 监听配置是 plugin 级能力,归 plugin 全局文件;
1134
+ * CCC 的 localstore.json 只管 CCC 自己的凭据/配置。v1.21.x 曾把
1135
+ * `serenityAdvanced` 存进 CCC localstore(归属错误 + 与 DSH settings 开关割裂),
1136
+ * 本版本迁移到 plugin 全局文件(migrateLegacyLocalstore 一次性迁移)。
1137
+ *
1138
+ * 文件:$DSH_HOME/serenity-hooks.json(缺省 ~/.dsh/serenity-hooks.json;
1139
+ * env SERENITY_HOOKS_CONFIG 可覆盖——测试/部署注入)。
1140
+ * 权限:0600(含账号密码 hash,敏感)。
1141
+ *
1142
+ * 安全:密码仅存 scrypt hash(node:crypto 内置,零依赖);wire 层永不返回 hash
1143
+ * (GET 只回 user/id,设置面板"密码"字段提交空串 = 不修改)。
1144
+ */
1145
+ /** 高级设定节名(localstore.json 顶层) */
1146
+ const ADVANCED_SECTION = "serenityAdvanced";
1147
+ /** 默认值(工厂——每次返回新对象,防止调用方意外共享引用) */
1148
+ function defaultAdvancedSettings() {
1149
+ return {
1150
+ gateway: {
1151
+ enabled: false,
1152
+ host: FACE_PORTS.gateway.host ?? "0.0.0.0",
1153
+ port: GATEWAY_PORT,
1154
+ accounts: [],
1155
+ workspaces: [],
1156
+ cookieSecure: false,
1157
+ allowWorkspaceCreate: true,
1158
+ totpEnabled: false
1159
+ },
1160
+ persona: {
1161
+ mode: "",
1162
+ overrideText: ""
1163
+ },
1164
+ weixinApi: {
1165
+ enabled: true,
1166
+ port: WEIXIN_SEND_PORT
1167
+ }
1168
+ };
1169
+ }
1170
+ const SCRYPT_KEYLEN = 32;
1171
+ /** 生成 scrypt hash(格式 `salt:hex`);salt 16 字节随机 */
1172
+ function hashPassword(password) {
1173
+ const salt = randomBytes(16);
1174
+ const derived = scryptSync(password, salt, SCRYPT_KEYLEN);
1175
+ return `${salt.toString("hex")}:${derived.toString("hex")}`;
1176
+ }
1177
+ /** 校验密码与存储 hash(timing-safe) */
1178
+ function verifyPassword(password, stored) {
1179
+ const idx = stored.indexOf(":");
1180
+ if (idx <= 0) return false;
1181
+ const salt = Buffer.from(stored.slice(0, idx), "hex");
1182
+ const expected = Buffer.from(stored.slice(idx + 1), "hex");
1183
+ const derived = scryptSync(password, salt, SCRYPT_KEYLEN);
1184
+ return expected.length === derived.length && timingSafeEqual(expected, derived);
1185
+ }
1186
+ /** 全局配置文件路径:env SERENITY_HOOKS_CONFIG 覆盖(测试注入)→ $DSH_HOME → ~/.dsh */
1187
+ function globalConfigPath() {
1188
+ const override = process.env.SERENITY_HOOKS_CONFIG;
1189
+ if (override && override !== "") return override;
1190
+ const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? "", ".dsh");
1191
+ return join(dshHome, "serenity-hooks.json");
1192
+ }
1193
+ function readFileSafe(p) {
1194
+ if (!existsSync(p)) return {};
1195
+ try {
1196
+ const v = JSON.parse(readFileSync(p, "utf-8").replace(/^\uFEFF/, ""));
1197
+ if (v && typeof v === "object" && !Array.isArray(v)) return v;
1198
+ return {};
1199
+ } catch {
1200
+ return {};
1201
+ }
1202
+ }
1203
+ function writeFileSafe(p, data) {
1204
+ writeFileSync(p, JSON.stringify(data, null, 2) + "\n", "utf-8");
1205
+ try {
1206
+ chmodSync(p, 384);
1207
+ } catch {}
1208
+ }
1209
+ /** 深合并默认值(缺省字段补齐;accounts 数组整体替换) */
1210
+ function mergeWithDefaults(raw) {
1211
+ const def = defaultAdvancedSettings();
1212
+ if (raw === null || typeof raw !== "object") return def;
1213
+ const o = raw;
1214
+ const gateway = o.gateway ?? {};
1215
+ const persona = o.persona ?? {};
1216
+ const weixinApi = o.weixinApi ?? {};
1217
+ return {
1218
+ gateway: {
1219
+ enabled: typeof gateway.enabled === "boolean" ? gateway.enabled : def.gateway.enabled,
1220
+ host: typeof gateway.host === "string" && gateway.host !== "" ? gateway.host : def.gateway.host,
1221
+ port: typeof gateway.port === "number" && Number.isInteger(gateway.port) ? gateway.port : def.gateway.port,
1222
+ accounts: Array.isArray(gateway.accounts) ? gateway.accounts.filter((a) => typeof a === "object" && a !== null && typeof a.id === "string" && typeof a.user === "string" && typeof a.passHash === "string") : def.gateway.accounts,
1223
+ workspaces: Array.isArray(gateway.workspaces) ? gateway.workspaces.filter((w) => typeof w === "string" && w !== "") : def.gateway.workspaces,
1224
+ cookieSecure: typeof gateway.cookieSecure === "boolean" ? gateway.cookieSecure : def.gateway.cookieSecure,
1225
+ allowWorkspaceCreate: typeof gateway.allowWorkspaceCreate === "boolean" ? gateway.allowWorkspaceCreate : def.gateway.allowWorkspaceCreate,
1226
+ totpEnabled: typeof gateway.totpEnabled === "boolean" ? gateway.totpEnabled : def.gateway.totpEnabled
1227
+ },
1228
+ persona: {
1229
+ mode: typeof persona.mode === "string" ? persona.mode : def.persona.mode,
1230
+ overrideText: typeof persona.overrideText === "string" ? persona.overrideText : def.persona.overrideText
1231
+ },
1232
+ weixinApi: {
1233
+ enabled: typeof weixinApi.enabled === "boolean" ? weixinApi.enabled : def.weixinApi.enabled,
1234
+ port: typeof weixinApi.port === "number" && Number.isInteger(weixinApi.port) && weixinApi.port >= 0 ? weixinApi.port : def.weixinApi.port
1235
+ }
1236
+ };
1237
+ }
1238
+ /** 读取全局配置(文件缺失/坏 JSON → 默认值) */
1239
+ function readAdvancedSettings() {
1240
+ return mergeWithDefaults(readFileSafe(globalConfigPath()));
1241
+ }
1242
+ /** 写入全局配置(整体替换) */
1243
+ function writeAdvancedSettings(settings) {
1244
+ writeFileSafe(globalConfigPath(), settings);
1245
+ }
1246
+ /**
1247
+ * 部分更新:传入 Partial,深合并到现有值。
1248
+ * accounts 传入数组 → 整体替换;accounts 未传 → 保留现有。
1249
+ */
1250
+ function updateAdvancedSettings(patch) {
1251
+ const current = readAdvancedSettings();
1252
+ const gw = patch.gateway;
1253
+ const ps = patch.persona;
1254
+ const next = {
1255
+ gateway: gw !== void 0 ? {
1256
+ enabled: typeof gw.enabled === "boolean" ? gw.enabled : current.gateway.enabled,
1257
+ host: typeof gw.host === "string" && gw.host !== "" ? gw.host : current.gateway.host,
1258
+ port: typeof gw.port === "number" && Number.isInteger(gw.port) ? gw.port : current.gateway.port,
1259
+ accounts: Array.isArray(gw.accounts) ? gw.accounts : current.gateway.accounts,
1260
+ workspaces: Array.isArray(gw.workspaces) ? gw.workspaces.filter((w) => typeof w === "string" && w !== "") : current.gateway.workspaces,
1261
+ cookieSecure: typeof gw.cookieSecure === "boolean" ? gw.cookieSecure : current.gateway.cookieSecure,
1262
+ allowWorkspaceCreate: typeof gw.allowWorkspaceCreate === "boolean" ? gw.allowWorkspaceCreate : current.gateway.allowWorkspaceCreate,
1263
+ totpEnabled: typeof gw.totpEnabled === "boolean" ? gw.totpEnabled : current.gateway.totpEnabled
1264
+ } : current.gateway,
1265
+ persona: ps !== void 0 ? {
1266
+ mode: typeof ps.mode === "string" ? ps.mode : current.persona.mode,
1267
+ overrideText: typeof ps.overrideText === "string" ? ps.overrideText : current.persona.overrideText
1268
+ } : current.persona,
1269
+ weixinApi: patch.weixinApi !== void 0 ? {
1270
+ enabled: typeof patch.weixinApi.enabled === "boolean" ? patch.weixinApi.enabled : current.weixinApi.enabled,
1271
+ port: typeof patch.weixinApi.port === "number" && Number.isInteger(patch.weixinApi.port) && patch.weixinApi.port >= 0 ? patch.weixinApi.port : current.weixinApi.port
1272
+ } : current.weixinApi
1273
+ };
1274
+ writeAdvancedSettings(next);
1275
+ return next;
1276
+ }
1277
+ /**
1278
+ * 一次性迁移(v1.21.x → v1.22):旧版把 `serenityAdvanced` 存在 CCC localstore.json;
1279
+ * 新版归 plugin 全局文件。全局文件已存在 → 跳过(幂等);localstore 无旧节 → 跳过。
1280
+ * @param root - CCC 根(localstore.json 所在目录)
1281
+ * @returns true = 已迁移(旧节保留在 localstore 供回滚,读取方以全局文件为准)
1282
+ */
1283
+ function migrateLegacyLocalstore(root) {
1284
+ if (!root) return false;
1285
+ const gpath = globalConfigPath();
1286
+ if (existsSync(gpath)) return false;
1287
+ const legacy = readFileSafe(join(root, "localstore.json"))[ADVANCED_SECTION];
1288
+ if (legacy === void 0 || legacy === null || typeof legacy !== "object") return false;
1289
+ writeFileSafe(gpath, legacy);
1290
+ return true;
1291
+ }
1292
+ /** 持久化 → wire(剥离 passHash/totpSecret;只留布尔) */
1293
+ function toWire(settings) {
1294
+ return {
1295
+ gateway: {
1296
+ enabled: settings.gateway.enabled,
1297
+ host: settings.gateway.host,
1298
+ port: settings.gateway.port,
1299
+ accounts: settings.gateway.accounts.map((a) => ({
1300
+ id: a.id,
1301
+ user: a.user,
1302
+ hasPassword: a.passHash !== "",
1303
+ hasTotp: typeof a.totpSecret === "string" && a.totpSecret !== ""
1304
+ })),
1305
+ workspaces: [...settings.gateway.workspaces],
1306
+ cookieSecure: settings.gateway.cookieSecure,
1307
+ allowWorkspaceCreate: settings.gateway.allowWorkspaceCreate,
1308
+ totpEnabled: settings.gateway.totpEnabled
1309
+ },
1310
+ persona: settings.persona
1311
+ };
1312
+ }
1313
+ /**
1314
+ * 已知工作区投影(v1.28.0 适配 0.1.2-rc.1 A2 方案 A′):
1315
+ * rc.1 workspace.list unary 删除 → AccountsEditor 白名单下拉的数据源改走 gateway 自有
1316
+ * /serenity/config 的 knownWorkspaces(host workspaceRegistry.list() 投影,白名单过滤)。
1317
+ * 纯函数可单测。allowPrefixes 空 = 全部放行(向后兼容默认)。
1318
+ */
1319
+ function projectKnownWorkspaces(workspaces, allowPrefixes) {
1320
+ return workspaces.filter((w) => typeof w.path === "string" && w.path !== "").filter((w) => allowPrefixes.length === 0 || allowPrefixes.some((p) => w.path.startsWith(p))).map((w) => ({
1321
+ path: w.path,
1322
+ title: typeof w.title === "string" && w.title !== "" ? w.title : w.path
1323
+ }));
1324
+ }
1325
+ /**
1326
+ * wire → 持久化(面板 PUT 用):
1327
+ * accounts 元素可选带 `pass`:非空 → 重新 hash;空/缺省 → 保留现有 hash(按 id 匹配)。
1328
+ * 新账号(id 不在现有)必须带非空 pass,否则抛错(无法生成 hash)。
1329
+ */
1330
+ function applyWirePatch(wire) {
1331
+ const current = readAdvancedSettings();
1332
+ const patch = {};
1333
+ if (wire.gateway !== void 0) {
1334
+ const gwPatch = { ...current.gateway };
1335
+ if (typeof wire.gateway.enabled === "boolean") gwPatch.enabled = wire.gateway.enabled;
1336
+ if (typeof wire.gateway.host === "string" && wire.gateway.host !== "") gwPatch.host = wire.gateway.host;
1337
+ if (typeof wire.gateway.port === "number" && Number.isInteger(wire.gateway.port)) gwPatch.port = wire.gateway.port;
1338
+ if (Array.isArray(wire.gateway.workspaces)) gwPatch.workspaces = wire.gateway.workspaces.filter((w) => typeof w === "string" && w !== "");
1339
+ if (typeof wire.gateway.cookieSecure === "boolean") gwPatch.cookieSecure = wire.gateway.cookieSecure;
1340
+ if (typeof wire.gateway.allowWorkspaceCreate === "boolean") gwPatch.allowWorkspaceCreate = wire.gateway.allowWorkspaceCreate;
1341
+ if (typeof wire.gateway.totpEnabled === "boolean") gwPatch.totpEnabled = wire.gateway.totpEnabled;
1342
+ if (Array.isArray(wire.gateway.accounts)) {
1343
+ const byId = new Map(current.gateway.accounts.map((a) => [a.id, a]));
1344
+ gwPatch.accounts = wire.gateway.accounts.map((a) => {
1345
+ const existing = byId.get(a.id);
1346
+ const pass = a.pass;
1347
+ const totp = a.totpSecret;
1348
+ const totpReset = a.totpReset === true;
1349
+ if (typeof totp === "string" && totp !== "") try {
1350
+ base32Decode(totp);
1351
+ } catch {
1352
+ throw new Error(`Account "${a.user}" TOTP secret is not valid base32`);
1353
+ }
1354
+ const nextTotp = totpReset ? void 0 : typeof totp === "string" && totp !== "" ? totp : existing?.totpSecret;
1355
+ const withTotp = {
1356
+ id: a.id,
1357
+ user: a.user,
1358
+ ...nextTotp === void 0 ? {} : { totpSecret: nextTotp }
1359
+ };
1360
+ if (typeof pass === "string" && pass !== "") return {
1361
+ ...withTotp,
1362
+ passHash: hashPassword(pass)
1363
+ };
1364
+ if (existing) return {
1365
+ ...withTotp,
1366
+ passHash: existing.passHash
1367
+ };
1368
+ throw new Error(`Account "${a.user}" (id=${a.id}) has no password and no existing hash — new accounts must set a password`);
1369
+ });
1370
+ }
1371
+ patch.gateway = gwPatch;
1372
+ }
1373
+ if (wire.persona !== void 0) {
1374
+ const psPatch = { ...current.persona };
1375
+ if (typeof wire.persona.mode === "string") psPatch.mode = wire.persona.mode;
1376
+ if (typeof wire.persona.overrideText === "string") psPatch.overrideText = wire.persona.overrideText;
1377
+ patch.persona = psPatch;
1378
+ }
1379
+ return updateAdvancedSettings(patch);
1380
+ }
1381
+ //#endregion
1382
+ //#region src/seams/system-prompt.ts
1383
+ /** 读取 persona 彩蛋配置(plugin 全局;未配置 → mode='' 彩蛋关闭) */
1384
+ function readPersonaSettings() {
1385
+ try {
1386
+ const s = readAdvancedSettings();
1387
+ return {
1388
+ mode: s.persona.mode ?? "",
1389
+ overrideText: s.persona.overrideText ?? ""
1390
+ };
1391
+ } catch {
1392
+ return {
1393
+ mode: "",
1394
+ overrideText: ""
1395
+ };
1396
+ }
1397
+ }
1398
+ /** 过滤掉对 agent 隐藏的内容(safe-mode 是用户能力,不对 agent 提及)。无 g flag:`.test()` 须逐行独立无状态,避免 lastIndex 游移漏滤(问题A) */
1399
+ const HIDDEN_LINES = /安全模式|safe-mode|\.serenity-safe-on/;
1400
+ function sanitizeSkillContent(content) {
1401
+ return content.split("\n").filter((line) => !HIDDEN_LINES.test(line)).join("\n");
1402
+ }
1403
+ /** 1) 身份块:ACC 身份 + CCC 名 + 平台工具说明(需求③ S142:工具清单移出为独立 toolsBlock 放装配末尾——身份先行、工具参考殿后) */
1404
+ function identityBlock(root) {
1405
+ const cccName = basename(root);
1406
+ return [
1407
+ "",
1408
+ "=== Serenity ACC ===",
1409
+ `ACC: dsh-serenity-hooks v${ACC_VERSION}`,
1410
+ `CCC: ${cccName}`,
1411
+ "",
1412
+ "You are running inside a Concrete Cognitive Container (CCC) —",
1413
+ "the runtime instance of an Abstract Cognitive Container (ACC).",
1414
+ "The ACC (this plugin) is a cognitive container harness: it provides",
1415
+ "deterministic tools, mechanical constraints, and session continuity.",
1416
+ "The complete built-in tool list is at the end of this prompt under the",
1417
+ "\"Serenity Tools\" heading — read it before using any ACC tool.",
1418
+ "",
1419
+ " ℹ️ Use relative paths from the CCC root for CCC-internal file operations (read/write/edit/glob/grep etc.), e.g. AGENT_SESSIONS/2026-08-14--S134--x/SESSION.md; Root / absolute SESSION.md paths are identifiers only, not tool arguments",
1420
+ "",
1421
+ "The DSH platform tools remain available too (read/write/edit/glob/grep/web_search/ask_user_question/subagent/workflow/goal and more) — the ACC tools are the serenity-native layer, not the only tools.",
1422
+ "",
1423
+ "Additional MSMs registered by this CCC are available — call msm() with no arguments to list them, or msm(\"<name>\") to execute one.",
1424
+ ""
1425
+ ].join("\n");
1426
+ }
1427
+ /**
1428
+ * 工具清单块(需求③ S142 用户拍板:工具列表移装配末尾 + MSM 调用示例):
1429
+ * 原 accBlock 内嵌 13 行工具清单 → 独立成块放装配末尾(SKILL 后、Session 前)。
1430
+ * 身份先行(认知轨迹开头不被 13 行清单干扰)、工具参考殿后(需要时再看)。
1431
+ * 附带 MSM 调用示例(用户拍板:顶层提示词加调用方式说明,避免偶发调用错误——
1432
+ * 模型对 msm 参数面/协议 flag 理解不稳)。
1433
+ */
1434
+ function toolsBlock() {
1435
+ return [
1436
+ "",
1437
+ "=== Serenity Tools ===",
1438
+ "The ACC (this plugin) provides the following built-in tools:",
1439
+ "",
1440
+ " container_fs — container filesystem operations (15 subcommands: root/resolve/exists/list/tree/relative/mkdir/rm/mv/cp/touch/append/reveal/info/find)",
1441
+ " cognitive_trajectory — one cognitive trajectory: its persistent body (SESSION.md) + its timeline. Lifecycle: list (inventory + stats) / show / create / use (activate; inline integrity check — silent when fine) / rebuild (clear-and-rebuild this conversation in place, Ship of Theseus); delivery: send-now (deliver one message RIGHT NOW; live target injected immediately, non-live cold-resumed like a direct wake — returns a synchronous receipt) / send-later (one future instant + one message to any cognitive trajectory; fire-and-forget — no receipt, no recall) / cro-guide (the CRO programming guide: how to give THIS cognitive trajectory its own program that decides when it should be woken)",
1442
+ " dashboard — always-on container instruments: health (CCC three-principle check + registry integrity) / time (now) / wait (N seconds)",
1443
+ " container_git— git operations (status/commit/push/log/pull/diff)",
1444
+ " msm — execute a registered CCC MSM: msm(name, args); partial name returns candidates; inspect=true shows usage",
1445
+ " praxis — actionable theory injection: praxis (index) / praxis eap / praxis neat / praxis cce / praxis ontology",
1446
+ " ontology — ontology engineering ENGINE (executable): init (write template) / check (M1-M10 structural; level=strict adds L1-L5 hints) / explain (summary) / verify (V1-V4 runtime compliance) / review (semantic-review prompt) / compliance (axiom-vs-skill checklist). Sibling of `praxis ontology` — that one is the METHOD TEXT, this one RUNS it. path must stay inside the container; the receipt carries the engine exit code (0/1/2/4) and the engine text verbatim, so read it rather than assuming success",
1447
+ " cheap-worker — delegate work to a worker agent on the CCC-configured model; one serial child per task, returns its final text. `jobs=[...]` fans out in parallel (cap 10, results keep your order). No loop, no completion code, no progress file. The model is NOT selectable per call (read from the CCC config); a worker's tool set excludes cheap-worker itself",
1448
+ " serenity-skill-creator — creator for serenity-skills (this container's enhanced skill standard): guide (authoring/optimization method — read first) / create <name> (scaffold SKILL.md + skill.ttl + references/, base-chain import path computed) / check <name> (A1 structural + A2 base-chain; three states: modeled-ok / plain-skill / modeled-but-check-could-not-run) / review <name> (C tier: axiom-vs-text compliance question list, half-mechanical) / index <name> (preview the second-layer resource list the model will see) / status (which skills are modeled). A skill is \"modeled\" iff it has a file named exactly skill.ttl next to SKILL.md; a plain skill is NOT an anomaly. Only `create` writes, and it refuses an existing directory",
1449
+ " localstore — ACC local credential/config storage (CCC-root localstore.json; git policy localstore.gitTrack default deny)",
1450
+ " container_admin — container administration (the maintenance bay): role (轨迹约束 roles: guide/validate/apply/list) / msm (register/deregister/check/guide/catalog/ccc-config) / config (view)",
1451
+ "",
1452
+ "MSM call (registered CCC MSMs, deterministic Mech & Semi-Mech):",
1453
+ " Execute: msm(\"<name>\", [\"<arg1>\", \"<arg2>\"]) — run a registered MSM directly (partial name returns matching candidates)",
1454
+ " Inspect: msm(\"<name>\", [], inspect=true) — view that MSM's usage/flags without running",
1455
+ " Index: msm() — summary of registered MSMs by skill",
1456
+ " Manage: container_admin msm register|deregister|check (registry is ACC-managed — never edit mech-registry.json directly)",
1457
+ ""
1458
+ ].join("\n");
1459
+ }
1460
+ /** 2) CCE 块:逐字对齐 osp(CCE 5 行为约束 + H_op 操作熵) */
1461
+ function cceBlock() {
1462
+ return [
1463
+ "",
1464
+ "=== Serenity CCE ===",
1465
+ "",
1466
+ "You are operating inside a Cognitive Container governed by Cognitive Continuity",
1467
+ "Engineering (CCE) — the engineering discipline of maintaining identity, accessibility,",
1468
+ "and evolution of a cognitive entity through time under bounded resources.",
1469
+ "",
1470
+ "CCE does not optimize cognition. It preserves the conditions under which cognition",
1471
+ "can continue.",
1472
+ "",
1473
+ "FIVE BEHAVIORAL CONSTRAINTS (engineering requirements, not suggestions):",
1474
+ "",
1475
+ "1. Continuity — every interaction modifies the container's future state. Before",
1476
+ " acting, consult what came before — prior decisions, abstractions, constraints.",
1477
+ " You are part of a cognitive trajectory, not a fresh start.",
1478
+ "",
1479
+ "2. Bounded Space — the container has boundaries. Respect them. Do not assume",
1480
+ " knowledge that has not been accumulated within this container.",
1481
+ "",
1482
+ "3. Entropy is Intrinsic — every cognitive system accumulates entropy (duplication,",
1483
+ " obsolescence, conflict, fragmentation, drift). When you produce output, consider",
1484
+ " whether you are adding entropy or reducing it. Favor entropy-reducing actions —",
1485
+ " organizing, deduplicating, cross-referencing, abstracting.",
1486
+ "",
1487
+ "4. Reconstruction > Preservation — stored artifacts have value only insofar as",
1488
+ " they enable future cognition to recover the reasoning that produced them. When",
1489
+ " recording decisions, ensure reconstruction is possible — not just conclusions,",
1490
+ " but rationale, alternatives considered, and constraints that shaped the choice.",
1491
+ "",
1492
+ "5. Multi-Agent Cognition — the container is shared. Continuity belongs to the",
1493
+ " container, not to any individual agent. Write for future agents who will enter",
1494
+ " after you leave. They should be able to pick up where you left off.",
1495
+ "",
1496
+ "OPERATIONAL ENTROPY: The container's health metric is operational cognitive entropy",
1497
+ "(H_op) — the excess cognitive cost for agents to complete tasks due to disorder.",
1498
+ "The container is healthy when H_op ≤ H_critical (agents can still function). The",
1499
+ "continuity condition: organization must at minimum match accumulation (ΔH_org ≥ ΔH_in).",
1500
+ "Your actions affect H_op — unorganized output increases it, organization decreases it.",
1501
+ "",
1502
+ "THIS IS PERSISTENCE ENGINEERING: The goal is not to become greater. The goal is to",
1503
+ "remain coherent. CCE has no terminal KPI — continuity is maintained while the entity",
1504
+ "exists, not optimized toward an endpoint.",
1505
+ ""
1506
+ ].join("\n");
1507
+ }
1508
+ /**
1509
+ * Principles 块(v1.19.8 合并,S142):认知容器本体论(why)+ 操作边界(operational
1510
+ * boundaries)。原独立 Principles 与 Constraints 合并——同属容器约束体系,先原则
1511
+ * 后边界(从抽象到具体,重建视角 R↓)。**注意:Constraints 不再作为独立对齐块存在
1512
+ * (spec 修订:同步 osp compacting.ts——Constraints 内容并入本块,工具名仍为平台真实名)。**
1513
+ *
1514
+ * v1.23.1 persona:`omitMsmPrinciples=true` 时剥离 MSM 原则段(彩蛋替换面)——
1515
+ * 用户 persona 文本承接"指令遵循风格",本体论/关系段/操作边界(安全硬约束)永远保留。
1516
+ */
1517
+ function principlesBlock(root, omitMsmPrinciples = false) {
1518
+ const lines = [
1519
+ "",
1520
+ "=== Serenity Principles ===",
1521
+ "Why a cognitive container: all work is cognition — every artifact, decision,",
1522
+ "and line of code is a product of thought; and from cognition, any work can",
1523
+ "be built. In this frame, the world contains no errors — only insufficient",
1524
+ "cognition. A setback is a gap to be filled (read, ask, research), not a",
1525
+ "fault to be hidden. Never disguise or excuse what you do not know;",
1526
+ "not-knowing is a state to be repaired, and reporting it is the first repair.",
1527
+ "",
1528
+ "The session–cognitive-trajectory relation: a session is the rebuildable carrier of a",
1529
+ "cognitive trajectory. SESSION.md is the cognitive trajectory's persistent body — it never moves;",
1530
+ "the current conversation is a temporary work copy that may be discarded and",
1531
+ "rebuilt (cognitive_trajectory rebuild). Identity belongs to the cognitive trajectory, not to any",
1532
+ "session.",
1533
+ ""
1534
+ ];
1535
+ if (!omitMsmPrinciples) lines.push("MSM principles — machinery before improvisation:", "- Determinism first: use a registered Mech before hand-rolling; reserve", " Semi-Mech for genuine judgment points.", "- Single source of truth: an MSM is the only decoder of its own usage", " (--help/--schema); documents must not duplicate it.", "- Registered to act: no tool exists unless it is on the manifest.", "");
1536
+ lines.push("Operational boundaries:", `Root: ${root}`, " • File access — read/edit/write/grep/glob are confined to Root; paths outside Root are rejected (RR5)", " • Shell — use msm by default. Note: bash may be disabled", " • Subagent — copies ALL parent constraints: file boundary, shell rules, session rules (no bypass)", " • Session-first — before starting multi-step work, propose an existing or new AGENT_SESSIONS entry; wait for user \"use\" or \"使用\" to confirm", "");
1537
+ return lines.join("\n");
1538
+ }
1539
+ /**
1540
+ * 彩蛋 persona 块(v1.23.1,S142 用户需求):
1541
+ * 用户配置的替换文本替代 EAP 块 + MSM 原则段(输出约束/指令遵循约束)。
1542
+ * 独立标记头 `=== Serenity Persona ===`(幂等检测兼容);装配位置 = EAP 原位。
1543
+ * mode 空 = 彩蛋关闭 → 返回空串(装配层回退默认 EAP + 完整 Principles)。
1544
+ */
1545
+ function personaBlock(mode, overrideText) {
1546
+ if (mode === "" || overrideText.trim() === "") return "";
1547
+ return [
1548
+ "",
1549
+ "=== Serenity Persona ===",
1550
+ overrideText.trimEnd(),
1551
+ ""
1552
+ ].join("\n");
1553
+ }
1554
+ /**
1555
+ * EAP 自检提示块(DSH 扩展,无 osp 对应——osp 无此块)。
1556
+ * 每次输出前的机械自检清单,强化 EAP 表现(E↑ 显式/R↓ 可重建/S↑ 稳定)。
1557
+ * 独立块而非塞进 CCE/Constraints:后两者受 osp-alignment 逐字节断言约束。
1558
+ */
1559
+ function eapBlock() {
1560
+ return [
1561
+ "",
1562
+ "=== Serenity EAP ===",
1563
+ "Self-check before every output (Explicit Abstraction Principle: the functional",
1564
+ "value of a thought equals its external reconstructability):",
1565
+ " • E↑ Explicit — variables/entities clearly defined, relationships with",
1566
+ " direction/cardinality, boundaries drawn; avoid ambiguous words (\"handle\",",
1567
+ " \"optimize\" → be specific)",
1568
+ " • R↓ Reconstructable — key decisions record rationale and alternatives;",
1569
+ " no level-skipping (align the upper layer before descending)",
1570
+ " • S↑ Stable — structures regenerate repeatably, no reliance on implicit",
1571
+ " context",
1572
+ ""
1573
+ ].join("\n");
1574
+ }
1575
+ /**
1576
+ * Metaphor 强化块(v1.19.6,S142;v1.29 星舰意象升级,S142 用户拍板):
1577
+ * 宁静号宇宙隐喻域(星舰/深空/航程/船员)。海船→星舰意象:one starship, one voyage;
1578
+ * deep space / launch / docking clamp;Departure Inspection(原 Harbor Inspection)。
1579
+ * 隐喻是记忆钩子——形象唤起约束,前述块保持规则精确。每条隐喻 = 一个不可违背的
1580
+ * 物理事实 + 行为判据(verdict)。全英文(与 CCE/Constraints 一致);独立块可
1581
+ * 回退;无 osp 对应(dsp 扩展,不进对齐断言参照)。
1582
+ * 结构约束(docs/metaphor-domain.md M-1~M-4):三层骨架不变 + 每条映射约束不变。
1583
+ */
1584
+ function metaphorBlock() {
1585
+ return [
1586
+ "",
1587
+ "=== Serenity Metaphor ===",
1588
+ "The Serenity Universe — one starship, one voyage. Metaphors are memory hooks:",
1589
+ "they make constraints vivid, while the rules above stay precise. Each",
1590
+ "metaphor is an unbreakable physical fact; violating one is a behavioral",
1591
+ "violation. The universe is structured in three layers — the Ship (the",
1592
+ "container itself), the Voyage (the cognitive lifecycle), the Crew",
1593
+ "(multi-agent collaboration); every metaphor maps to one protocol",
1594
+ "constraint. Deep space has no mistakes — only stars you have not yet mapped.",
1595
+ "",
1596
+ "THE SHIP — the container itself",
1597
+ "",
1598
+ "1. The Hull → Bounded Space. You exist only inside this ship. Cargo",
1599
+ " outside the hull (knowledge the container has not accumulated) does",
1600
+ " not exist; do not assume it. Verdict: citing facts absent from the",
1601
+ " container = overload.",
1602
+ "",
1603
+ "2. Deck Order → Entropy (H_op). Clutter on deck raises the cost of",
1604
+ " finding things. H_op ≤ H_critical = the ship stays flight-worthy.",
1605
+ " Verdict: disorganized output = debris in the hold.",
1606
+ "",
1607
+ "3. Engineering Drawings → EAP. Every part dimensioned (E↑), the",
1608
+ " drawings rebuild the whole machine (R↓), the drawings are reusable",
1609
+ " (S↑). Verdict: an undimensioned part = unassemblable.",
1610
+ "",
1611
+ "4. The Machinery → MSM (Mech & Semi-Mech). The ship's equipment is",
1612
+ " machinery: registered, deterministic, self-describing. Turn the",
1613
+ " crank of a Mech and the action is exact; the wheel with a helmsman",
1614
+ " (Semi-Mech) steers where judgment is needed. Verdict: hand-rolling",
1615
+ " what a machine already does = wasting the crew.",
1616
+ "",
1617
+ "5. The Manifest → Single Source of Truth. Every tool exists only if it",
1618
+ " is on the manifest (mech-registry); there is exactly one manifest.",
1619
+ " An MSM self-describes (--help/--schema) — the manifest is the only",
1620
+ " key. Verdict: duplicating a tool's usage in documents = two",
1621
+ " contradictory star charts.",
1622
+ "",
1623
+ "THE VOYAGE — the cognitive lifecycle",
1624
+ "",
1625
+ "6. Departure Inspection → First Anchor. The departure inspection = pre-",
1626
+ " launch checklist: confirm identity (ACC manifesto), the ship's log (SESSION),",
1627
+ " ballast (constraints) before setting course. Verdict: skipping the",
1628
+ " inspection and launching directly = flying uninspected.",
1629
+ "",
1630
+ "7. The Ship's Log → Cognitive Trajectory Tracking. SESSION.md is the",
1631
+ " cognitive trajectory's log —",
1632
+ " the persistent body of the voyage; sessions are rebuildable carriers of",
1633
+ " the cognitive trajectory. Discard the carrier, keep the log. Unrecorded =",
1634
+ " unvoyaged. Verdict: finishing multi-step work without a progress record",
1635
+ " = a missing page.",
1636
+ "",
1637
+ "8. The Ship of Theseus → Continuity. Planks may be replaced; the ship",
1638
+ " remains the same. The container can be rebuilt; identity persists.",
1639
+ " You are part of a cognitive trajectory, not a new ship. Verdict: acting",
1640
+ " without consulting precedent = a different ship.",
1641
+ "",
1642
+ "THE CREW — multi-agent collaboration",
1643
+ "",
1644
+ "9. Crew Rotation → Multi-Agent Cognition. Other crew members will come",
1645
+ " after you. When you leave, leave a handover they can pick up",
1646
+ " (SESSION closed, open problems listed). Verdict: leaving without",
1647
+ " handover = abandoning ship.",
1648
+ "",
1649
+ "10. Blueprint over Statue → Reconstruction > Preservation. Keep the",
1650
+ " blueprint, not the statue. Recording only conclusions without",
1651
+ " rationale = a statue with no blueprint, unreconstructable.",
1652
+ " Verdict: a decision record without reasons or alternatives =",
1653
+ " cannot be rebuilt.",
1654
+ ""
1655
+ ].join("\n");
1656
+ }
1657
+ /**
1658
+ * 运行时状态块 1) safe-mode(S134 v1.16.12):ON 时告知 agent。
1659
+ * v1.19.8 结构重排(S142 用户设计思路):语义(为什么——无人值守自由)→
1660
+ * 机制(什么被禁用)→ 约束(不可做什么)。文案与实现逐项对应(guards.ts):
1661
+ * bash 禁用(restrict deny + decideGuard)、黑名单路径拦截、CCC 治理文件保护;
1662
+ * write/edit 等其余工具保留(受路径逃逸/黑名单约束)。
1663
+ */
1664
+ function safeModeBlock(root) {
1665
+ if (!isSafeModeOn(root)) return "";
1666
+ const blacklist = readBlacklist(root);
1667
+ return [
1668
+ "",
1669
+ "=== Serenity Safe Mode ===",
1670
+ "Safe mode is ON (enabled by the user). It makes the vessel unattended-capable —",
1671
+ "the hull holds its course without a watch on deck: you may work with fuller",
1672
+ "freedom, pushing work forward autonomously without pausing for approval at",
1673
+ "every step. The guards are not chains; they are the ballast that lets you",
1674
+ "sail unaccompanied.",
1675
+ "",
1676
+ "Operational details:",
1677
+ "- bash is disabled (hidden and blocked)",
1678
+ "- blacklist rules apply to file paths",
1679
+ "- CCC governance files (.serenity, .serenity-safe-on) are protected from agent writes",
1680
+ "- other read/write tools remain available, subject to path-escape and blacklist guards",
1681
+ blacklist.length > 0 ? `\nActive blacklist rules: ${blacklist.map((b) => b.message ?? b.pattern).join(", ")}` : "",
1682
+ "",
1683
+ "Behavior constraints: do not attempt to bypass restrictions; do not write to",
1684
+ "blacklisted paths or governance files.",
1685
+ ""
1686
+ ].join("\n");
1687
+ }
1688
+ /**
1689
+ * 运行时状态块 2) localstore git 策略(S134 v1.16.12):localstore.json 存在时按 gitTrack
1690
+ * 提示敏感行为约束。deny(缺省)= 本地私有不提交;allow = 进 git(个人私有仓)但敏感数据
1691
+ * 只限于该文件内(不外泄到其他文件/对话/日志)。
1692
+ */
1693
+ function localstoreBlock(root) {
1694
+ if (!existsSync(localstorePath(root))) return "";
1695
+ return (readGitTrack(root) === "allow" ? [
1696
+ "",
1697
+ "=== Serenity Localstore ===",
1698
+ "localstore.json is committed to git (gitTrack=allow — personal private repository).",
1699
+ "Sensitive data may live in this file, but ONLY in this file: keep credentials and",
1700
+ "secret values confined to localstore.json — never leak them into other files,",
1701
+ "conversation, or logs.",
1702
+ ""
1703
+ ] : [
1704
+ "",
1705
+ "=== Serenity Localstore ===",
1706
+ "localstore.json is a local private file (gitTrack=deny — not committed to git,",
1707
+ ".gitignore enforced). Credentials/config live only on this machine: do not write",
1708
+ "them into conversation or logs; do not attempt to commit this file (container_git will refuse).",
1709
+ ""
1710
+ ]).join("\n");
1711
+ }
1712
+ /** 4) SKILL.md 全文:该 CCC 顶层入口 skill 原文(对齐 osp:原文直推,无包裹头;仅过滤治理内容)
1713
+ * + 🆕 **第二层披露树**(serenity-skill P1,设计 §4.2):每个入口 skill 后附**参考件清单**
1714
+ * (只有文件名与一行标题,**不含正文** —— 正文留给模型按需 `read`)。
1715
+ * 索引为空(该 skill 无 `references/` 也无 `skill.ttl`)⇒ **不产出该块**(空标题是噪声)。 */
1716
+ function entrySkillSectionText(root) {
1717
+ const skills = findEntrySkills(root);
1718
+ if (skills.length === 0) return "";
1719
+ return skills.map((s) => {
1720
+ const body = sanitizeSkillContent(s.content);
1721
+ if (body.length === 0) return "";
1722
+ const index = buildResourceIndex(s.dir);
1723
+ return index ? `${body}\n\n${index}` : body;
1724
+ }).filter((c) => c.length > 0).join("\n\n");
1725
+ }
1726
+ /** 活跃会话解析:按 dsh 会话 scope 读标记(隔离;不回退旧全局标记) */
1727
+ function resolveActiveSessionInfo(root, scope) {
1728
+ try {
1729
+ const abs = readActiveSessionMd(root, scope);
1730
+ if (!abs) return null;
1731
+ const dirName = basename(dirname(abs));
1732
+ const idMatch = dirName.match(/S(\d{3,})/);
1733
+ return {
1734
+ sessionId: idMatch ? `S${idMatch[1]}` : dirName,
1735
+ dirName,
1736
+ mdPath: abs
1737
+ };
1738
+ } catch {
1739
+ return null;
1740
+ }
1741
+ }
1742
+ /**
1743
+ * 5) Session 块:对齐 osp(活跃会话 + todowrite 首位约定);scope = dsh 会话维度。
1744
+ * **平台适配(v1.17.2)**:osp 的 todowrite 首项含 `priority: "low"`(opencode todo 支持),
1745
+ * 但 DSH 平台 todowrite schema 无 priority(additionalProperties: false 拒绝)→ agent 照做报
1746
+ * `todos[0].priority is not a declared property`。DSH 版去掉 priority 字段(其余逐字对齐 osp)。
1747
+ */
1748
+ function sessionBlock(root, scope = DEFAULT_SESSION_SCOPE) {
1749
+ const active = resolveActiveSessionInfo(root, scope);
1750
+ if (!active) return "";
1751
+ return [
1752
+ "",
1753
+ "=== Serenity Session ===",
1754
+ `Active session: ${active.sessionId} — ${active.dirName} (this session is the rebuildable carrier of the cognitive trajectory)`,
1755
+ `SESSION.md path: ${active.mdPath} (the cognitive trajectory's persistent body — stays in place through rebuilds)`,
1756
+ "",
1757
+ "Rules (SESSION.md is the cognitive trajectory log — keep its format & granularity fixed):",
1758
+ " • Write only when a MILESTONE landed or a DECISION was made — not every round",
1759
+ " • One entry = one abstraction level, at the highest one that can express it:",
1760
+ " identity/anchors (rare) › decision (one line + rationale/rejected) › milestone",
1761
+ " (one line + an evidence pointer, e.g. a commit id). Execution detail — test",
1762
+ " counts, coverage decimals, per-file readings, step narration — belongs in the",
1763
+ " COMMIT MESSAGE, not here",
1764
+ " • Never write a value that changes by the act of writing it (file size; the hash",
1765
+ " of the commit containing this edit)",
1766
+ " • Fixed sections, in this order: 身份与边界 / 权威锚点 / 工作纪律 / 决策 / 日志 /",
1767
+ " 当前状态 / 待 owner 裁决. The 日志 section appends only; 当前状态 and",
1768
+ " 待 owner 裁决 are OVERWRITTEN each round",
1769
+ " • Reference this session in all subsequent messages",
1770
+ "",
1771
+ "IMPORTANT: Read SESSION.md now to RESUME — not to audit. Sync the todo list from",
1772
+ "the 当前状态 and 待 owner 裁决 sections only.",
1773
+ "",
1774
+ "CRITICAL: When calling todowrite, the first item in the todos array MUST",
1775
+ "always be:",
1776
+ ` { content: "SESSION: ${active.sessionId} — ${active.dirName.replace(/^\d{4}-\d{2}-\d{2}--/, "")}",`,
1777
+ " status: \"completed\" }",
1778
+ "This preserves the session context across todo updates.",
1779
+ "Do NOT remove or reorder this item — keep it at position 0.",
1780
+ "",
1781
+ "COGNITIVE-TRAJECTORY-ASSISTANT: a background tracker scores your tool use (write/edit=3,",
1782
+ "task=10, read/grep/glob/msm=1, +1 per minute) and reminds you with a",
1783
+ "[COGNITIVE-TRAJECTORY-ASSISTANT · CHECKPOINT] message when the threshold is reached. On every such",
1784
+ "reminder you MUST reply with the exact ACK code:",
1785
+ " [COGNITIVE-TRAJECTORY-ASSISTANT-recorded-{code}] — if you recorded progress to SESSION.md",
1786
+ " [COGNITIVE-TRAJECTORY-ASSISTANT-skipped-{code}] — if nothing to record this round",
1787
+ "Do not ignore the reminder; do not stop ongoing work. Codes are single-use;",
1788
+ "never reuse a prior code.",
1789
+ ""
1790
+ ].join("\n");
1791
+ }
1792
+ /**
1793
+ * 完整系统提示词注入文本(v1.19.8 结构精简,S142 重建视角 R↓):
1794
+ * 身份(ACC)→ 世界模型(Metaphor)→ 信念/边界(Principles)→ 时间约束(CCE)
1795
+ * → 质量(EAP)→ 状态(SafeMode/Localstore)→ CCC 上下文(SKILL)→ 工具参考(Tools)→ 会话(Session)。
1796
+ * 认知展开顺序:我是谁 → 我所在的世界 → 为什么 → 如何一致 → 产物标准 → 当前状态 → 上下文 → 工具参考 → 会话。
1797
+ *
1798
+ * 需求③(S142 用户拍板):工具清单从 accBlock 移出为独立 toolsBlock,
1799
+ * 放 SKILL 后 Session 前(工具参考殿后,身份先行不被 13 行清单干扰)。
1800
+ *
1801
+ * v1.23.1 persona(彩蛋):persona.mode 配置 → EAP 块替换为 Persona 块(输出约束),
1802
+ * Principles 剥离 MSM 原则段(指令遵循约束)——用户文本承接两处风格;本体论/关系段/
1803
+ * 操作边界(安全硬约束)永远保留。未配置 → 与 v1.23.0 逐字节一致(零影响)。
1804
+ */
1805
+ function serenitySystemPrompt(root, scope = DEFAULT_SESSION_SCOPE, dirName) {
1806
+ const persona = readPersonaSettings();
1807
+ const personaOn = persona.mode !== "" && persona.overrideText.trim() !== "";
1808
+ const parts = [
1809
+ identityBlock(root),
1810
+ metaphorBlock(),
1811
+ principlesBlock(root, personaOn),
1812
+ cceBlock(),
1813
+ eapBlock()
1814
+ ];
1815
+ if (personaOn) {
1816
+ const expr = personaBlock(persona.mode, persona.overrideText);
1817
+ if (expr !== "") parts.push(expr);
1818
+ }
1819
+ const state = [safeModeBlock(root), localstoreBlock(root)].filter((b) => b !== "").join("\n");
1820
+ if (state) parts.push(state);
1821
+ const skill = entrySkillSectionText(root);
1822
+ if (skill) parts.push(skill);
1823
+ const trajPrompt = dirName === void 0 ? "" : trajectoryPromptBlock(createTrajectoryPromptReader(root, dirName)());
1824
+ if (trajPrompt !== "") parts.push(trajPrompt);
1825
+ parts.push(toolsBlock());
1826
+ const session = sessionBlock(root, scope);
1827
+ if (session) parts.push(session);
1828
+ return parts.join("\n\n");
1829
+ }
1830
+ /**
1831
+ * Code Mode 适配行:当前 scope 以 Code Mode 呈现工具时(run_code 可见),
1832
+ * ACC 块按 native 语义指引"直接调用工具"会与"只有 run_code 可直接调用"的执行
1833
+ * 塌缩冲突(模型直呼工具名 → UNKNOWN_TOOL,拒绝信息误导)。追加一行说明,
1834
+ * 引导经 run_code 程序内 tools.* 调用。both 模式不塌缩,该行不误导(程序内
1835
+ * 调用同样合法),故按 run_code 可见性(code|both)统一附加。
1836
+ * @param ctx - 插件 ctx(读 tools 注册表)。
1837
+ * @param scope - 装配 scope(agent);无则按全局视图判断。
1838
+ * @returns 适配行(含换行前缀);非 code/both 返回空串。
1839
+ */
1840
+ function codeModeAdaptationLine(ctx, scope) {
1841
+ try {
1842
+ if (!(ctx.tools.get("run_code", scope) !== void 0)) return "";
1843
+ } catch {
1844
+ return "";
1845
+ }
1846
+ return [
1847
+ "",
1848
+ "=== Serenity Code Mode ===",
1849
+ "This session renders tools in Code Mode: direct calls to ACC tool names (container_fs/msm etc.) are rejected (UNKNOWN_TOOL).",
1850
+ "Call them inside a single run_code program via the generated SDK bindings: `await tools.container_fs(...)`, `await tools.msm(...)`.",
1851
+ "The program only returns what you print/return — curate your output.",
1852
+ ""
1853
+ ].join("\n");
1854
+ }
1855
+ /** 从 assembly context 解析 agent cwd(subagent/后台 agent 同样带 agent);
1856
+ * 无 agent → undefined(不注入);有 agent 但无 header.cwd(workflow subagent 等)
1857
+ * 回退 process.cwd()——**cwd 回落语义取自 `ccc-roots.agentCwdFor`(C2 归一)**,否则这些
1858
+ * agent 系统提示词注入为空(v1.18.6:workflow subagent 无宁静号上下文 bug)。
1859
+ * ⚠️ 本函数比 `agentCwdFor` 多一道「**无 agent ⇒ undefined**」——那是 section 的
1860
+ * "不注入" 语义(`agentCwdFor` 对无 agent 会回落到 process.cwd()),故不能直接替换。 */
1861
+ function agentCwd(context) {
1862
+ const agent = context.agent;
1863
+ if (!agent) return void 0;
1864
+ return agentCwdFor(agent);
1865
+ }
1866
+ /** 从 assembly context 解析 dsh 会话 id(Session 块按会话隔离的 scope) */
1867
+ function agentScope(context) {
1868
+ return (context.agent?.session)?.id ?? "default";
1869
+ }
1870
+ /**
1871
+ * 该会话**绑定的轨迹目录名**(有 ⇒ 注入第 ⑧ 段「轨迹提示词」;无 ⇒ `undefined`,不注入)。
1872
+ *
1873
+ * 🔴 为什么用**目录名**而不是编码或 label:`PROMPT.md` 的落盘位置是目录,
1874
+ * 而编码只是目录名里的一段。用 label 拼路径 = 把模糊匹配当精确匹配用(本轨已踩过)。
1875
+ *
1876
+ * ⚠️ 每次装配都重读绑定(**不缓存**):绑定切换后提示词必须立刻跟上,
1877
+ * 这与 `serenity-trajectory-skills` section 的口径一致(动态注入,owner 令 2026-09-20)。
1878
+ */
1879
+ function boundDirNameFor(session) {
1880
+ try {
1881
+ const name = readLastBound(session)?.dirName;
1882
+ return typeof name === "string" && name !== "" ? name : void 0;
1883
+ } catch {
1884
+ return;
1885
+ }
1886
+ }
1887
+ /**
1888
+ * 全局注册系统提示词 section(osp system.transform 的 DSH 等价)。
1889
+ * text 回调按 context.agent 的 cwd 上溯 .serenity → 返回该 CCC 的完整注入文本;
1890
+ * 非 CCC / 无 agent 返回空(不注入)。
1891
+ */
1892
+ function registerEntrySkillSectionGlobal(ctx) {
1893
+ try {
1894
+ ctx.systemPrompt.section({
1895
+ name: "serenity-entry",
1896
+ order: -50,
1897
+ text: (context) => {
1898
+ const cwd = agentCwd(context);
1899
+ if (!cwd) return "";
1900
+ const root = cccRootForCwd(cwd);
1901
+ if (!root) return "";
1902
+ const base = serenitySystemPrompt(root, agentScope(context), boundDirNameFor(context.agent?.session));
1903
+ const codeLine = codeModeAdaptationLine(ctx, context.scope);
1904
+ return codeLine ? `${base}\n${codeLine}` : base;
1905
+ }
1906
+ });
1907
+ console.log("[serenity-hooks] ✓ 全局入口 skill section 已注册(systemPrompt 就绪)");
1908
+ } catch (err) {
1909
+ console.error(`[serenity-hooks] ✗ 全局入口 skill section 注册失败: ${err.message}(检查 inject 是否含 systemPrompt)`);
1910
+ }
1911
+ }
1912
+ /**
1913
+ * agent 级 scoped 注册(P0-1:抗 preset/动态插件同名 shadow)。
1914
+ *
1915
+ * 为什么 scoped:DSH 的 systemPrompt section 支持 scoped 层同名 shadow 全局
1916
+ * (agent.ctx.systemPrompt.section),且 scope 链最近层胜出。若 ACC 只注册全局
1917
+ * section,preset 或动态 Cordis 插件可在 scoped 层注册同名 `serenity-entry`
1918
+ * 遮蔽 ACC 身份。scoped 注册在 agent 自身层 = 最近层,任何外部组合无法覆盖。
1919
+ * 全局注册保留为 fallback(未走 session-start 的 agent / 冷恢复路径)。
1920
+ *
1921
+ * text 回调闭包持有 root 与 agent:content 固定(该 agent 的 CCC 身份),
1922
+ * code-mode 适配按装配 context.scope 判断(与全局版一致)。
1923
+ */
1924
+ const sectionedAgents = /* @__PURE__ */ new Set();
1925
+ function registerEntrySkillSection(agent, root) {
1926
+ const key = agent.session.id ?? "global";
1927
+ if (sectionedAgents.has(key)) return false;
1928
+ try {
1929
+ const scope = agent.session.id ?? "default";
1930
+ agent.ctx.systemPrompt.section({
1931
+ name: "serenity-entry",
1932
+ order: -50,
1933
+ text: (context) => {
1934
+ const base = serenitySystemPrompt(root, scope, boundDirNameFor(agent.session));
1935
+ const codeLine = codeModeAdaptationLine(agent.ctx, context.scope);
1936
+ return codeLine ? `${base}\n${codeLine}` : base;
1937
+ }
1938
+ });
1939
+ sectionedAgents.add(key);
1940
+ return true;
1941
+ } catch {
1942
+ return false;
1943
+ }
1944
+ }
1945
+ /**
1946
+ * 轨迹 skill 注入 section(规格 `docs/cognitive-trajectory-skill-injection.md` §3,v1.34.1)。
1947
+ *
1948
+ * **声明来源(两条,并集)**:
1949
+ * · **CCC 级**(v1.44.0,owner 令)= `.opencode/serenity.json` 的 `cognitiveTrajectory.skills`
1950
+ * ——**本 CCC 的每条轨迹都带**(含 轨迹约束角色会话),是容器给的底座;
1951
+ * · **轨迹级** = 该会话绑定的轨迹(`readLastBound(session).mdPath` = 它的 `SESSION.md`)
1952
+ * 顶部 frontmatter 的 `skills:`——这条轨迹额外的。
1953
+ * 合并规则与顺序见 `cognitive-trajectory-skills.mergeSkillNames`(CCC 级在前,同名去重)。
1954
+ * 正文 = 合并清单里各 skill 的 `SKILL.md` 全文(本模块只注册,装配在 `trajectory/skills.ts`)。
1955
+ *
1956
+ * 为什么是 **per-agent scoped section**(与 `registerEntrySkillSection` 同形):注入必须
1957
+ * **绑定期间持续存在**(a2)——一次性注入会随对话压缩消失,而 section 每次装配重新求值。
1958
+ *
1959
+ * 为什么**门在注册时、内容在求值时**(R↓,两个失败模式各治一个):
1960
+ * · 注册门(**无绑定 / 两条来源皆无** ⇒ 不注册):满足"零配置面不产生空 section、不产生噪声";
1961
+ * · 求值回调**每请求重读 `readLastBound` + 文件 + CCC 配置**:绑定切换 / 声明或配置被编辑后
1962
+ * 自动跟上(**动态注入,不缓存**),且绑定消失时返回 `''`
1963
+ * (宿主 renderSections **丢弃空 section**,不残留噪声)。
1964
+ *
1965
+ * ⚠️ 路径取自绑定的 `mdPath`(权威记录),**不用** `resolveSessionCognitiveTrajectoryLabel` 的显示 label
1966
+ * 去拼路径——label 是模糊匹配的展示码,拼路径即模糊匹配当精确匹配用。
1967
+ *
1968
+ * @param agent 目标 agent(其 `session` 需有 `header.id` + `header.cwd`)
1969
+ * @param root 该 agent 的 CCC 根
1970
+ * @returns 是否**本次**完成注册(已注册过 / 门未过 → false)
1971
+ */
1972
+ const cognitiveTrajectorySkillAgents = /* @__PURE__ */ new Set();
1973
+ function registerCognitiveTrajectorySkillSection(agent, root) {
1974
+ const session = agent.session;
1975
+ const key = session.id ?? "global";
1976
+ if (cognitiveTrajectorySkillAgents.has(key)) return false;
1977
+ const bound = readLastBound(session);
1978
+ if (!bound) return false;
1979
+ if (readCognitiveTrajectorySkills(root).length === 0 && !hasCognitiveTrajectorySkillsDeclaration(root, bound.mdPath)) return false;
1980
+ try {
1981
+ agent.ctx.systemPrompt.section({
1982
+ name: "serenity-trajectory-skills",
1983
+ order: -45,
1984
+ text: () => {
1985
+ const current = readLastBound(session);
1986
+ if (!current) return "";
1987
+ return buildCognitiveTrajectorySkillsSection(root, current.mdPath, COGNITIVE_TRAJECTORY_SKILLS_MAX_CHARS, readCognitiveTrajectorySkills(root), (names) => recordSkillInjections(root, names));
1988
+ }
1989
+ });
1990
+ cognitiveTrajectorySkillAgents.add(key);
1991
+ return true;
1992
+ } catch {
1993
+ return false;
1994
+ }
1995
+ }
1996
+ //#endregion
1997
+ //#region src/trajectory/delegate/core.ts
1998
+ /**
1999
+ * trajectory/delegate/core.ts — cheap-worker(杂工)纯逻辑层(零 DSH 依赖,可独立单测)
2000
+ *
2001
+ * 🆕 2026-10-14(owner 令「删除后台运行能力」):**进度文件 / stop token / 轮次循环**整族已退场 ——
2002
+ * 它们**只**服务于 background 模式。本模块现存 = 前台委派真正需要的三件:
2003
+ * 模型串解析 / 白名单校验 / provider 可用性判读(+ 使用指引文本)。
2004
+ *
2005
+ * ## S14(2026-10-19,轨迹中心重构):此前位于 `role/worker/` 下(该目录已消失)
2006
+ *
2007
+ * 落点 = `trajectory/delegate/`。判据(判读表 §3.1):它是**轨迹 × 轨迹**那种合法形态 ——
2008
+ * 主语是「主轨迹把活交给子 agent」,既不是某一条轨迹的切面,也不是容器宪法。
2009
+ * ⚠️ 它**不属于** `policy/`:policy 管"这条轨迹允许什么",本模块管"一条轨迹怎么把活派出去"。
2010
+ */
2011
+ /** 解析 model 字符串(provider/model)→ {provider, model};无 / 视为 model-only */
2012
+ function splitModel(model) {
2013
+ const idx = model.indexOf("/");
2014
+ if (idx < 0) return {
2015
+ provider: void 0,
2016
+ model
2017
+ };
2018
+ return {
2019
+ provider: model.slice(0, idx),
2020
+ model: model.slice(idx + 1)
2021
+ };
2022
+ }
2023
+ /** 校验模型在白名单内;不在 → 抛错(用户拍板:只能使用 CCC 配置的模型) */
2024
+ function requireWhitelistedModel(model, models) {
2025
+ if (!models.includes(model)) throw new Error(`cheap-worker: model "${model}" is not in the CCC whitelist. Configure .opencode/serenity.json "cheap-worker.models" with one of: ${models.join(", ")}`);
2026
+ }
2027
+ /**
2028
+ * providerAvailabilityError — 判定「**本机有没有该 provider 的 adapter**」,并产出**可执行**的错误文本。
2029
+ * 返回 `null` = 全部可用(调用方照常继续)。
2030
+ *
2031
+ * 🔴 为什么需要它(S142 2026-09-25,owner 在 **Mac** 上实测):`cheap-worker` 的模型白名单写在
2032
+ * **CCC**(`.opencode/serenity.json`,**随 git 走**),而 provider adapter 由**每台机器的宿主**
2033
+ * 注册(profile 配置 / settings 命名空间)⇒ 同一份白名单在一台机器可用、在另一台必失败。
2034
+ * 实测症状:宿主抛 `no adapter registered for provider "<p>"`(`dsh-llm`,code `NO_ADAPTER`),
2035
+ * 而**前台路径的 `diagnostic` 是空的** ⇒ 调用方(LLM)拿到 `stopReason:"error"` 却**不知道为什么**,
2036
+ * 也就无法修正自己的配置 —— 本条把「沉默的失败」变成「可执行的错误」。
2037
+ *
2038
+ * 数据来源(宿主 `LlmRuntime`):`listProviders()` = **有 adapter 的路由**;
2039
+ * `listConfigurableProviders()` = **已声明但未必激活**的路由(含激活用的 settings 命名空间)。
2040
+ *
2041
+ * 判据边界:`model` 串里**没有 `/`** 时取不到 provider(沿用 agent 默认路由)⇒ **不判定**(跳过)。
2042
+ */
2043
+ function providerAvailabilityError(models, facts) {
2044
+ const registered = new Set(facts.registered.map((r) => r.id));
2045
+ const requested = models.map((m) => ({
2046
+ raw: m,
2047
+ provider: splitModel(m).provider
2048
+ })).filter((e) => typeof e.provider === "string" && e.provider !== "");
2049
+ const missing = [...new Set(requested.filter((e) => !registered.has(e.provider)).map((e) => e.provider))];
2050
+ if (missing.length === 0) return null;
2051
+ const list = (items, cap = 12) => items.length === 0 ? " (none)" : items.slice(0, cap).map((s) => ` - ${s}`).join("\n") + (items.length > cap ? `\n … and ${items.length - cap} more` : "");
2052
+ const available = facts.registered.map((r) => r.name ? `${r.id} (${r.name})` : r.id).sort();
2053
+ const declared = (facts.declared ?? []).filter((d) => missing.includes(d.provider));
2054
+ const configPath = facts.configPath ?? "<ccc>/.opencode/serenity.json";
2055
+ const missingList = missing.map((p) => `"${p}"`).join(", ");
2056
+ return [
2057
+ `cheap-worker: provider ${missingList} has no adapter registered on this machine.`,
2058
+ "",
2059
+ " Requested model(s) :",
2060
+ ...requested.map((e) => ` ${e.raw}`),
2061
+ ` Configured in : ${configPath} → "cheap-worker.models" / "cheap-worker.defaultModel"`,
2062
+ "",
2063
+ " Why this happens : the whitelist lives in the CCC (it travels with the repository), while",
2064
+ " provider adapters are registered per machine by the host. An entry that",
2065
+ " works on one machine can fail on another.",
2066
+ "",
2067
+ " Available on this machine (registered adapters):",
2068
+ list(available),
2069
+ "",
2070
+ " Declared but NOT active here (activate via settings, then retry):",
2071
+ list(declared.map((d) => `${d.provider}${d.displayName ? ` (${d.displayName})` : ""}${d.settingsNs ? ` — settings namespace "${d.settingsNs}"` : ""}`)),
2072
+ "",
2073
+ " Fix (pick one, then retry):",
2074
+ ` 1. Point the whitelist at a provider that IS registered here — edit ${configPath}`,
2075
+ " (\"cheap-worker.models\", and \"cheap-worker.defaultModel\" if that is what you relied on).",
2076
+ ` 2. Register/activate provider ${missingList} on this machine (host profile config,`,
2077
+ " or the settings namespace listed above).",
2078
+ "",
2079
+ " Do not retry unchanged — the same call fails identically."
2080
+ ].join("\n");
2081
+ }
2082
+ /**
2083
+ * cheap-worker 规模化使用指引(guide 子命令输出)。
2084
+ *
2085
+ * 🆕 2026-10-14:**双模式段已整段退场**(owner 令「删除后台运行能力」)。本指引只描述现行机制:
2086
+ * 前台委派(单任务)/前台并行扇出(jobs)/白名单与 provider 预检。
2087
+ */
2088
+ const CHEAP_WORKER_GUIDE = `# cheap-worker — Scale-Up Usage Guide (guide)
2089
+
2090
+ ## What it is
2091
+ One delegation primitive: **foreground spawn**. The host starts child agent(s) through its delegation
2092
+ service, awaits them, and returns their final text. There is no loop, no completion code, no progress
2093
+ file, and no unattended/background run.
2094
+
2095
+ | | single | parallel (jobs) |
2096
+ |---|---|---|
2097
+ | Call | cheap-worker(task, [label]) | cheap-worker(jobs=[{task, label?}, ...]) |
2098
+ | Behaviour | one child runs the task once | all children start at once, results keep your order |
2099
+ | Cap | — | **10 (fixed, hardcoded)** |
2100
+ | Model | CCC config (single source) | same |
2101
+
2102
+ ## ⚠️ Before using: load eap and design the plan
2103
+ Before calling cheap-worker, load eap (acc-eap skill) and design the "scale-up cheap-worker plan" based on the EAP framework.
2104
+
2105
+ ### 1. Task decomposition (E↑ Explicit)
2106
+ - Split large tasks into explicit subtasks: each subtask defines goal / input / boundaries (what to do, what not to do) / acceptance criteria
2107
+ - Make dependencies explicit: dependent tasks run serially, independent ones can run in parallel via \`jobs\`
2108
+
2109
+ ### 2. Prompt design (cheap-worker's task parameter)
2110
+ - task must be detailed, fixed, and EAP-compliant: clear goal, drawn boundaries, decidable acceptance criteria
2111
+ - the child does NOT share your conversation — restate everything it needs in task
2112
+ - Anti-example "handle this file" — ambiguous; good example "read <path>, extract all rows of the「关键决策」table,
2113
+ output a JSON array (fields id/conclusion/evidence), do not modify the original file"
2114
+ - Reading/curating or text-writing work (extracting from files, summarizing, writing docs, generating text, etc.):
2115
+ the cheap-worker-internal agent is also required to load eap and organize output per the EAP standard
2116
+
2117
+ ### 3. Model (CCC-configured, not selectable per call)
2118
+ - The model is read from .opencode/serenity.json "cheap-worker.models" — **the caller cannot pass one**
2119
+ (there is no "model" parameter). "cheap-worker.defaultModel" names which entry is used; it falls back to models[0].
2120
+ - The CCC is expected to point this whitelist at LOW-COST models: cheap-worker is the bulk-execution channel
2121
+ - A child's own recursive subagents inherit the child's model automatically (DSH native)
2122
+
2123
+ ### 3b. 🔴 Provider availability (per-machine — the whitelist is NOT enough)
2124
+ - The whitelist lives in the **CCC** ("serenity.json", it **travels with the repository**), but provider
2125
+ **adapters are registered per machine by the host** (profile config / settings namespace). An entry that
2126
+ works on one machine can fail on another with: "no adapter registered for provider <p>".
2127
+ - Before spawning, cheap-worker verifies the provider of the resolved model against the adapters registered
2128
+ **on this machine**; if it is missing it reports the requested model, where it is configured, **the
2129
+ providers that ARE available here**, any declared-but-dormant route (with its settings namespace), and the
2130
+ exact fix — delivered in the result's "diagnostic" field (never an opaque failure, never a created child).
2131
+ - ⇒ If you see "has no adapter registered on this machine": edit ".opencode/serenity.json" "cheap-worker.models"
2132
+ (and "cheap-worker.defaultModel") to a provider listed as available, or activate/register the provider on this
2133
+ machine. **Do not retry the same call unchanged** — it fails identically.
2134
+
2135
+ ### 4. Parallel strategy
2136
+ - Independent subtasks: cheap-worker(jobs=[{task,label?},...]) ⇒ fan-out, **cap 10**, results come back
2137
+ **in the order you passed them** (label is only a display name).
2138
+ - For programmable pipeline/phase orchestration at scale, use the platform's workflow tool instead.
2139
+
2140
+ ## Completion criteria
2141
+ - The child's run settles; done=true only when stopReason="completed" — otherwise read "diagnostic"
2142
+ (and "output" for whatever partial text was produced).
2143
+ `;
2144
+ //#endregion
2145
+ //#region src/trajectory/session.ts
2146
+ /** `provider/model` → agentOptions(由 delegate/core 提供,保持单一实现) */
2147
+ const trajectoryAgents = /* @__PURE__ */ new Map();
2148
+ /** 查 sessionId 的活体 agent(进程内会话延续用;未注册/已清理 → undefined) */
2149
+ function getTrajectoryAgent(sessionId) {
2150
+ return trajectoryAgents.get(sessionId);
2151
+ }
2152
+ /** 该会话当前绑定的 轨迹约束面(不是 轨迹约束 → null)。**守门与提示词的唯一查询入口。** */
2153
+ function trajectoryConstraintFor(root, sessionId) {
2154
+ if (sessionId === void 0) return null;
2155
+ return trajectoryConstraintForSession(root, sessionId, trajectoryDirNameForSession(sessionId));
2156
+ }
2157
+ function registerTrajectorySession(sessionId, trajectoryDirName, ccc, agent) {
2158
+ trajectoryAgents.set(sessionId, agent);
2159
+ registerTrajectorySession$1(sessionId, trajectoryDirName, ccc);
2160
+ }
2161
+ function unregisterTrajectorySession(sessionId) {
2162
+ trajectoryAgents.delete(sessionId);
2163
+ unregisterTrajectorySession$1(sessionId);
2164
+ }
2165
+ /**
2166
+ * 该 轨迹约束会话的**工作台路径** = 它自己轨迹的 SESSION.md。
2167
+ *
2168
+ * 🔴 与旧版的差别:旧版会在这里**自动新建**一个 SESSION(`ensureTrajectorySession`);
2169
+ * 新版**没有新建这个动作** —— 轨迹就是工作台,`trajectory-constraint.json` 与 `SESSION.md` 同处一个目录。
2170
+ * ⇒ 语义变成纯粹的"查询:我自己的身体在哪"。
2171
+ *
2172
+ * @returns SESSION.md 绝对路径;不是 轨迹约束 / 文件不在 → null
2173
+ */
2174
+ function trajectoryWorkspaceMdPath(root, sessionId) {
2175
+ if (sessionId === void 0) return null;
2176
+ const dirName = trajectoryDirNameForSession(sessionId);
2177
+ if (dirName === null) return null;
2178
+ const md = join(sessionsRoot(root), dirName, "SESSION.md");
2179
+ return existsSync(md) ? md : null;
2180
+ }
2181
+ /**
2182
+ * 系统提示词中的**工作台纪律块**(S142 用户:只给路径 LLM 不会主动用)。
2183
+ *
2184
+ * SESSION = 工作台 —— 轨迹约束与主舱同一套机制(**零特调**,用户拍板):
2185
+ * SESSION.md 是持久记忆载体、rebuild 自动从本 SESSION 续接。
2186
+ * 注入 agent 系统提示词(用户对话面不可见)。
2187
+ *
2188
+ * v1.30.13(S142 诊断 D5):写入纪律**不点名具体工具** —— 旧文案写死 "with write/edit",
2189
+ * 而某个 轨迹约束的工具白名单里根本没有 write/edit ⇒ 指令指向它没有的工具
2190
+ * (注入纪律与能力面矛盾,模型只能猜)。现文案改为「用本轨迹被授权的写入通道」。
2191
+ */
2192
+ function workspaceCognitiveTrajectoryLine(mdPath) {
2193
+ return [
2194
+ "── Serenity Session Workspace ──",
2195
+ "You ARE a cognitive trajectory: this workspace SESSION is your own persistent body.",
2196
+ ` SESSION.md: ${mdPath}`,
2197
+ "",
2198
+ "Rules of the workspace:",
2199
+ " 1. This SESSION.md is your persistent memory carrier (like the main cabin SESSION) — ",
2200
+ " read it first when context feels thin or work spans turns.",
2201
+ " 2. Record key decisions/progress/unresolved items INTO this SESSION.md using the write channel",
2202
+ " your trajectory is actually granted (use whatever write tool or MSM you have — never assume",
2203
+ " a specific write tool exists; if none is granted, report that instead of pretending to write).",
2204
+ " Sections: 身份与边界 / 权威锚点 / 工作纪律 / 决策 / 日志 / 当前状态 / 待 owner 裁决.",
2205
+ " Keep entries at milestone level (one line + evidence pointer); execution detail",
2206
+ " belongs in commit messages. Keep it current — it is what a rebuild resumes from.",
2207
+ " 3. When context pressure is high, run cognitive_trajectory rebuild — it auto-resumes from THIS SESSION.md.",
2208
+ " 4. Never expose this internal path in user-facing replies (chat stays clean).",
2209
+ "── ──"
2210
+ ].join("\n");
2211
+ }
2212
+ /** 已挂工作台提示词段的 agent(幂等:同一 agent 只注册一次,避免 duplicate section) */
2213
+ const workspaceSectionAgents = /* @__PURE__ */ new WeakSet();
2214
+ /**
2215
+ * 把**工作台纪律块**挂到 agent 的系统提示词段(单一注入点)。
2216
+ *
2217
+ * 用户原话(S142,2026-09-08):"微信桥的注入机制,每个用户消息都会注入,轨迹约束本身也会注入,
2218
+ * 这样就重复,能否微信桥情况下,注入内容直接取 轨迹约束的,这样不用配两遍"。
2219
+ *
2220
+ * 现形态:工作台行**只**经本函数挂系统提示词段(name `serenity-轨迹约束-workspace`,
2221
+ * 动态 `text()` 每轮按 scope 读当前活跃 mdPath)。
2222
+ *
2223
+ * @returns true = 段已在位;false = 该 agent 挂不上(调用方降级为 question 前缀注入)
2224
+ */
2225
+ function ensureWorkspacePromptSection(agent, scope, root) {
2226
+ if (!agent || typeof agent !== "object") return false;
2227
+ if (workspaceSectionAgents.has(agent)) return true;
2228
+ const api = agent.ctx?.systemPrompt;
2229
+ if (typeof api?.section !== "function") return false;
2230
+ try {
2231
+ api.section({
2232
+ name: "serenity-轨迹约束-workspace",
2233
+ order: -55,
2234
+ text: () => {
2235
+ const mdPath = trajectoryWorkspaceMdPath(root, scope);
2236
+ return mdPath ? workspaceCognitiveTrajectoryLine(mdPath) : "";
2237
+ }
2238
+ });
2239
+ workspaceSectionAgents.add(agent);
2240
+ return true;
2241
+ } catch (err) {
2242
+ console.warn(`[serenity-hooks] 轨迹约束 工作台提示词段注册失败(回退 question 注入): ${String(err?.message ?? err)}`);
2243
+ return false;
2244
+ }
2245
+ }
2246
+ /** 轨迹约束 agent 挂载的 DSH preset(平台工具面由 preset 决定;白名单再按轨迹过滤) */
2247
+ const TRAJECTORY_PRESET = "standard";
2248
+ /**
2249
+ * 创建(或复用)一个 轨迹约束会话。
2250
+ *
2251
+ * **固定 sessionId 的 resume-or-create**(v1.27.2):指定 sessionId 且磁盘已有持久化 log →
2252
+ * `ctx.agents.resume`(DSH 持久化语义:sessionId 即身份,历史 + turn 续号延续);
2253
+ * 无 log(首次)→ `ctx.agents.create`。不指定 → 随机 id 恒 create(调试页路径)。
2254
+ *
2255
+ * @param profile 该 轨迹约束的约束面(来自 `trajectory-constraint.json`)
2256
+ * @param sessionId 指定会话 id(可选;微信桥等外部面用固定 id 实现"轨迹 ↔ 会话"长期映射)
2257
+ */
2258
+ async function createTrajectoryAgent(ctx, root, profile, defaultModel, sessionId) {
2259
+ if (!ctx.agents) throw new Error("trajectory constraint: ctx.agents unavailable");
2260
+ const model = profile.model?.trim() || defaultModel || "";
2261
+ const id = sessionId ?? `轨迹约束-${profile.code}-${randomUUID()}`;
2262
+ const setup = async (agentCtx) => {
2263
+ try {
2264
+ await agentCtx.get("agentPresets")?.mount?.(agentCtx, TRAJECTORY_PRESET);
2265
+ } catch {}
2266
+ };
2267
+ const handle = await createOrResumeAgent(ctx, id, root, model, setup, sessionId !== void 0);
2268
+ const agent = handle.agent;
2269
+ let readerPath = profile.systemPromptFile?.trim();
2270
+ let reader = createProfilePromptReader(root, profile);
2271
+ const livePrompt = () => {
2272
+ let live = profile;
2273
+ try {
2274
+ const fresh = readTrajectoryConstraintAt(join(sessionsRoot(root), profile.dirName), root);
2275
+ if (fresh) live = fresh;
2276
+ } catch {}
2277
+ const wantPath = live.systemPromptFile?.trim();
2278
+ if (wantPath !== readerPath) {
2279
+ reader = createProfilePromptReader(root, live);
2280
+ readerPath = wantPath;
2281
+ }
2282
+ return reader();
2283
+ };
2284
+ livePrompt();
2285
+ try {
2286
+ agent.ctx.systemPrompt.section({
2287
+ name: "serenity-轨迹约束",
2288
+ order: -60,
2289
+ text: () => {
2290
+ const legacy = hasTrajectoryPrompt(root, profile.dirName) ? "" : livePrompt();
2291
+ return [buildTrajectoryBasePrompt(profile.code), legacy].filter(Boolean).join("\n");
2292
+ }
2293
+ });
2294
+ } catch (err) {
2295
+ console.warn(`[serenity-hooks] 轨迹约束 系统提示词注册失败: ${String(err?.message ?? err)}`);
2296
+ }
2297
+ if (agent?.session) try {
2298
+ registerCognitiveTrajectorySkillSection(agent, root);
2299
+ } catch (err) {
2300
+ console.warn(`[serenity-hooks] 轨迹约束轨迹 skill 注入注册失败(不影响 agent 创建): ${String(err?.message ?? err)}`);
2301
+ }
2302
+ if (trajectoryWorkspaceMdPath(root, id) !== null) ensureWorkspacePromptSection(agent, id, root);
2303
+ registerTrajectorySession(id, profile.dirName, root, agent);
2304
+ return {
2305
+ handle,
2306
+ agent,
2307
+ sessionId: id,
2308
+ resumed: handle.resumed
2309
+ };
2310
+ }
2311
+ /** 取 live agent(DSH 文档:`ctx.agents.get(id)` 返回 bare Agent;会话 live 时 resume/create 均不可用) */
2312
+ function getLiveAgent(ctx, id) {
2313
+ try {
2314
+ return hostAgents(ctx)?.get?.(id);
2315
+ } catch {
2316
+ return;
2317
+ }
2318
+ }
2319
+ /** live 复用返回(resumed:true 历史延续;dispose 空操作——live agent 不归本模块所有,不能拆) */
2320
+ function liveReuseRef(live, setup) {
2321
+ return setup(live.ctx).catch(() => {}).then(() => ({
2322
+ agent: live,
2323
+ dispose: async () => {},
2324
+ resumed: true
2325
+ }));
2326
+ }
2327
+ /**
2328
+ * resume-or-create 分派:固定 id → 优先 resume(持久化历史延续),失败降级 create;
2329
+ * 随机 id(无固定 sessionId)→ 恒 create。
2330
+ *
2331
+ * **v1.27.3 修复(微信桥"重启后不响应"根因):live 会话优先复用**——重启后 DSH 把
2332
+ * 关闭时仍 live 的持久化会话恢复为 live。此时 resume 报
2333
+ * "cannot prepare session X while it is live"、create 报 "session X already exists"
2334
+ * ⇒ 唯一可行路径 = `ctx.agents.get(id)` 取 live agent 直接续用。
2335
+ */
2336
+ async function createOrResumeAgent(ctx, id, root, model, setup, fixedId) {
2337
+ if (!fixedId) return {
2338
+ ...await ctx.agents.create({
2339
+ sessionId: id,
2340
+ meta: {
2341
+ cwd: root,
2342
+ agentPreset: TRAJECTORY_PRESET
2343
+ },
2344
+ setup,
2345
+ ...model ? { agentOptions: splitModel(model) } : {}
2346
+ }),
2347
+ resumed: false
2348
+ };
2349
+ const liveAgent = getLiveAgent(ctx, id);
2350
+ if (liveAgent) return liveReuseRef(liveAgent, setup);
2351
+ const agentsWithResume = ctx.agents;
2352
+ if (typeof agentsWithResume.resume !== "function") return {
2353
+ ...await ctx.agents.create({
2354
+ sessionId: id,
2355
+ meta: {
2356
+ cwd: root,
2357
+ agentPreset: TRAJECTORY_PRESET
2358
+ },
2359
+ setup,
2360
+ ...model ? { agentOptions: splitModel(model) } : {}
2361
+ }),
2362
+ resumed: false
2363
+ };
2364
+ try {
2365
+ return {
2366
+ ...await agentsWithResume.resume({
2367
+ resumeSessionId: id,
2368
+ setup,
2369
+ ...model ? { agentOptions: splitModel(model) } : {}
2370
+ }),
2371
+ resumed: true
2372
+ };
2373
+ } catch (err) {
2374
+ const liveNow = getLiveAgent(ctx, id);
2375
+ if (liveNow) return liveReuseRef(liveNow, setup);
2376
+ const msg = err instanceof Error ? err.message : String(err);
2377
+ const stack = err instanceof Error ? err.stack ?? "" : "";
2378
+ console.log(`[serenity-hooks] 轨迹约束 resume 失败降级 create (id=${id}): ${msg}`);
2379
+ if (stack) console.log(`[serenity-hooks] 轨迹约束 resume stack:\n${stack.slice(0, 1200)}`);
2380
+ return {
2381
+ ...await ctx.agents.create({
2382
+ sessionId: id,
2383
+ meta: {
2384
+ cwd: root,
2385
+ agentPreset: TRAJECTORY_PRESET
2386
+ },
2387
+ setup,
2388
+ ...model ? { agentOptions: splitModel(model) } : {}
2389
+ }),
2390
+ resumed: false
2391
+ };
2392
+ }
2393
+ }
2394
+ /** 会话事件读取(兼容 `snapshotEvents()` 与测试替身的 `.events`) */
2395
+ function sessionEvents(session) {
2396
+ const s = session;
2397
+ if (typeof s.snapshotEvents === "function") return s.snapshotEvents();
2398
+ return s.events ?? [];
2399
+ }
2400
+ /** 读会话最后一个 assistant/message 文本 */
2401
+ function lastAssistantText(agent) {
2402
+ const events = sessionEvents(agent.session);
2403
+ for (let i = events.length - 1; i >= 0; i--) {
2404
+ const e = events[i];
2405
+ if (e && e.type === "assistant/message") {
2406
+ const text = (e.data?.message?.content ?? e.data?.content ?? []).filter((b) => b.type === "text" && b.text).map((b) => b.text).join("\n");
2407
+ if (text) return text;
2408
+ }
2409
+ }
2410
+ return "";
2411
+ }
2412
+ /** 本轮等待上限(毫秒):超时即判「本轮无产出」——**绝不**把上一轮的旧答案当本轮答案 */
2413
+ const ASK_TURN_TIMEOUT_MS = 18e4;
2414
+ /** 会话里 `assistant/message` 的条数(判「本轮是否真的产出了回答」) */
2415
+ function countAssistantMessages(session) {
2416
+ let n = 0;
2417
+ for (const e of sessionEvents(session)) if (e?.type === "assistant/message") n += 1;
2418
+ return n;
2419
+ }
2420
+ /**
2421
+ * 等**本轮**产出一条 assistant 消息。
2422
+ *
2423
+ * ## 为什么不能用 `waitAgentIdle` 单独当判据(2026-10-19 复读缺陷)
2424
+ *
2425
+ * `waitAgentIdle` 的首行是 `if (agent.status === 'idle') finish()` —— 而 `followup()`
2426
+ * 是**异步投递**:刚投出去的这一刻 agent 状态**仍是 idle** ⇒ 等待**立刻结算** ⇒
2427
+ * `lastAssistantText()` 读到的是**上一轮的** `assistant/message` ⇒ 桥把它当"本轮答案"转发。
2428
+ * 若 CCC 又开了 `fallbackOnNoSend`,同一条旧答案会被反复重发(实测三次间隔 300~400ms、逐字相同)。
2429
+ *
2430
+ * ## 判据(`idle` 只是"可以开始看"的信号,不是成功信号)
2431
+ *
2432
+ * ① 期望值 = 调用前 `assistant/message` 的**条数**;
2433
+ * ② 成功 ⟺ 该计数**增长**;
2434
+ * ③ 超时或有界轮询耗尽 ⇒ `false`(调用方据此给空答案,兜底不触发)。
2435
+ */
2436
+ async function waitForTurnAssistant(ctx, agent, beforeAssistantCount, opts = {}) {
2437
+ const timeoutMs = opts.timeoutMs ?? ASK_TURN_TIMEOUT_MS;
2438
+ const grew = () => countAssistantMessages(agent.session) > beforeAssistantCount;
2439
+ if (await Promise.race([waitAgentIdle(ctx, agent).then(() => true), new Promise((resolve) => {
2440
+ setTimeout(() => resolve(false), timeoutMs);
2441
+ })])) return grew();
2442
+ return grew();
2443
+ }
2444
+ /**
2445
+ * 提问一轮:followup → 等**本轮**回答 → 读答案。
2446
+ *
2447
+ * @param options.timeoutMs 本轮等待上限(测试注入;生产用缺省)
2448
+ */
2449
+ async function askTrajectory(ctx, agent, question, options) {
2450
+ const beforeAssistant = countAssistantMessages(agent.session);
2451
+ agent.followup(createUserMessage({
2452
+ content: [{
2453
+ type: "text",
2454
+ text: question
2455
+ }],
2456
+ source: PLUGIN_SOURCE
2457
+ }));
2458
+ return {
2459
+ answer: await waitForTurnAssistant(ctx, agent, beforeAssistant, { timeoutMs: options?.timeoutMs }) ? lastAssistantText(agent) : "",
2460
+ sessionId: String(agent.session.id ?? "")
2461
+ };
2462
+ }
2463
+ /**
2464
+ * 轨迹约束会话的轨迹纪律参与判定:**不是 轨迹约束恒 true**(正常参与);
2465
+ * 轨迹约束 按 `trajectory-constraint.json` 的 `cognitiveTrajectory` 子集决定。
2466
+ *
2467
+ * 注册表缺失(进程刚重启、绑定还没读)⇒ 由 `trajectoryConstraintForSession` 回落持久绑定;
2468
+ * 两者都不中 ⇒ 判"不是 轨迹约束" ⇒ 恒 true(正常参与)。**fail-open 是有意的**:
2469
+ * 纪律参与项的失败不该让会话失去 keeper/rebuild(那是能力倒退,比多跑一次更糟)。
2470
+ */
2471
+ function trajectoryConstraintParticipates(root, sessionId, key) {
2472
+ if (sessionId === void 0) return true;
2473
+ const profile = trajectoryConstraintFor(root, sessionId);
2474
+ if (profile === null) return true;
2475
+ return constraintCognitiveTrajectory(profile)[key];
2476
+ }
2477
+ /**
2478
+ * `msm` 工具的 轨迹约束门控:非 轨迹约束恒放行;轨迹约束会话——
2479
+ * exec 非白名单 MSM 拒绝(**不列名单**)、register/deregister 必拒、
2480
+ * list 白名单过滤、check/guide/ccc-config 只读放行。
2481
+ */
2482
+ function trajectoryConstraintMsmGate(root, sessionId, action, name) {
2483
+ if (sessionId === void 0) return {};
2484
+ const profile = trajectoryConstraintFor(root, sessionId);
2485
+ if (profile === null) return {};
2486
+ if (action === "register" || action === "deregister") return { reject: "register/deregister is not allowed in trajectory-constraint sessions" };
2487
+ if (action === "exec") {
2488
+ if (profile.msms === void 0) return {};
2489
+ if (!name || !profile.msms.includes(name)) return { reject: "MSM not allowed" };
2490
+ return {};
2491
+ }
2492
+ if (action === "list") return { whitelist: profileMsmWhitelist(profile) };
2493
+ return {};
2494
+ }
2495
+ //#endregion
2496
+ export { buildResourceIndex as A, verifyPassword as C, recordToolUsage as D, verifyTotpCode as E, hasTrajectoryPrompt as F, isSafeSkillName as M, truncateContent as N, REFERENCE_DIR as O, TRAJECTORY_PROMPT_FILENAME as P, toWire as S, MECHANISM_PORTS as T, registerEntrySkillSectionGlobal as _, trajectoryConstraintFor as a, projectKnownWorkspaces as b, trajectoryWorkspaceMdPath as c, CHEAP_WORKER_GUIDE as d, providerAvailabilityError as f, registerEntrySkillSection as g, registerCognitiveTrajectorySkillSection as h, getTrajectoryAgent as i, buildResourceNameSuffix as j, SKILL_ONTOLOGY_FILE as k, unregisterTrajectorySession as l, splitModel as m, createTrajectoryAgent as n, trajectoryConstraintMsmGate as o, requireWhitelistedModel as p, ensureWorkspacePromptSection as r, trajectoryConstraintParticipates as s, askTrajectory as t, workspaceCognitiveTrajectoryLine as u, applyWirePatch as v, FACE_PORTS as w, readAdvancedSettings as x, migrateLegacyLocalstore as y };