@shgroup/dsh-serenity-hooks 1.104.0 → 1.107.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/invariant.js CHANGED
@@ -147,10 +147,9 @@ const REGISTERED_TOOLS = [
147
147
  "localstore",
148
148
  "container_admin",
149
149
  "im-bridge",
150
- "acc-diag",
151
150
  "diagram"
152
151
  ];
153
152
  /** 条件出现的工具数(`REGISTERED_TOOLS` 的**末段**;`assertManifestProse` 用) */
154
- const CONDITIONAL_TOOL_COUNT = 3;
153
+ const CONDITIONAL_TOOL_COUNT = 2;
155
154
  //#endregion
156
155
  export { CONDITIONAL_TOOL_COUNT, REGISTERED_TOOLS, assertManifestProse, verifyToolConsistency };
@@ -126,13 +126,45 @@ interface SerenityConfig {
126
126
  */
127
127
  im?: ImSettings;
128
128
  /**
129
- * 专属工具声明(v1.33,S142 §32.11):**ACC 注册但默认对所有 CCC 隐藏**的工具,
130
- * 只有在自己这里声明的 CCC 可见(例:`["acc-diag"]` = ACC 负责人的运行态诊断)。
129
+ * 轨迹消息订阅(v1.107.0,owner 2026-11-01 令):
130
+ * **观察者盯一组成员,任一成员「一轮结束」就通知观察者**。
131
131
  *
132
- * 归属判据(D23):**机制在 ACC(通用,任何工具名皆可),声明在 CCC**——
133
- * ACC 代码里不出现任何具体 CCC 的名字;未声明(含未配置/配置损坏)⇒ 隐藏。
132
+ * 🔴 **这是通知,不是控制**(owner 原话「loose notification, not control」):
133
+ * 观察者收到的是「某人刚干完一轮活儿」,**不带他干了什么**(owner:「不需要带它做了什么」),
134
+ * 也不赋予它任何指挥权。要不要看、看了做什么,全由观察者自己决定。
135
+ *
136
+ * ## 三件必须固定的语义
137
+ *
138
+ * 1. **「一轮结束」的判据 = `agent.status` 由 running → idle**(`cro/turns.ts` D244 已定死:
139
+ * `agent/turn-stopping` 是**暂停点**不是轮结束,⛔ 不得用它)。因此本机制**复用**那条事件,
140
+ * 不新造判据 —— 两个判据并存会立刻分叉。
141
+ * 2. **频率不设上限**:结束了就通知(owner:「不设定上限,结束了就通知」)。
142
+ * ⚠️ 但**不额外防抖**是有依据的:`agent/status` 只在**真实状态跃迁**时发
143
+ * (宿主 `AgentLoop.setPhase`,见 `cro/turns.ts` 文件头),所以"抖动"不会发生。
144
+ * 3. **目标的三种失败面各自显形**(见 `trajectory/subscriptions.ts` 文件头的失败面表):
145
+ * 成员解析不出轨迹 / 观察者唤不醒 / 投递失败 —— ⛔ 一个都不许静默。
146
+ *
147
+ * ⚠️ **与 CRO 的区别**(别混):CRO 是「**自己**决定要不要把自己叫起来」(自编程);
148
+ * 本节是「**别人**干完活了,告诉我一声」(互观察)。两者都走唤醒链路,但**主语相反**。
134
149
  */
135
- exclusiveTools?: string[];
150
+ trajectorySubscriptions?: TrajectorySubscription[];
151
+ }
152
+ /**
153
+ * 一条轨迹消息订阅:`watcher` 观察 `members`。
154
+ *
155
+ * 🔴 **方向性是本结构唯一容易写反的地方**:`watcher` 是**收通知的那个**,
156
+ * `members` 是**被盯的那组**。写反了照样能跑、只是通知发给了错误的人 —— 没有机械信号,
157
+ * 所以字段名取"谁看/看谁"而不是"a/b"(D150 同族:字面要能自证方向)。
158
+ */
159
+ export interface TrajectorySubscription {
160
+ /** 收通知的轨迹(`S###` 或 AGENT_SESSIONS 目录名) */
161
+ watcher: string;
162
+ /** 被观察的轨迹清单(同上格式) */
163
+ members: string[];
164
+ /** 这条订阅是否生效(缺省 true —— 写了就是要用) */
165
+ enabled?: boolean;
166
+ /** 通知正文里对这个订阅的一句话说明(可选;用于让观察者分辨是哪条订阅触发的) */
167
+ note?: string;
136
168
  }
137
169
  /**
138
170
  * 🔴 **统一 IM 配置(2026-10-19 owner 定向:「飞书和微信一样,只不过是 account 方实现不同。
@@ -315,24 +347,39 @@ export declare function readUtf8(path: string): string;
315
347
  */
316
348
  export declare function loadSerenityConfig(root: string, paths?: string[]): SerenityConfig;
317
349
  /**
318
- * 读取 CCC 声明的「专属工具」清单(`exclusiveTools`,v1.33)。
350
+ * (已删除)`readExclusiveTools` —— v1.106.0 随 `exclusiveTools` 机制整体删除。
351
+ * 原语义:读 CCC 声明的专属工具清单,fail-closed(读不到 ⇒ 空数组 ⇒ 专属工具保持隐藏)。
352
+ * 删除理由见 `SerenityConfig.exclusiveTools` 处的注释(表空 ⇒ 不可证伪)。
353
+ */
354
+ /**
355
+ * 读取 CCC 声明的轨迹消息订阅表(`trajectorySubscriptions`,v1.107.0)。
356
+ *
357
+ * ## 失败方向 = **fail-open,但坏项逐条报告**(与另外两处刻意不同)
358
+ *
359
+ * 不订阅 ≠ 危险动作:它只是"没有通知",不会藏掉任何能力(对比 `safeMode.hiddenTools`
360
+ * 是 fail-open 因为**隐藏**才是危险方向)。⇒ 整体读不到 ⇒ 空表。
361
+ *
362
+ * 🔴 **但"坏项"不能一起吞掉**:配置里写了这一条、却因为成员名写错而**永远不触发**,
363
+ * 是与 `safeMode.hiddenTools` 写错工具名**同族的静默失效**(写了但不生效,且没人知道)。
364
+ * ⇒ 本函数返回 `{ subs, problems }`:合法项照常生效,坏项进 `problems` 供调用方响亮告警。
365
+ * ⚠️ 这与"整表读不到就空表"并不矛盾:前者是**文件级**失败(无法解析),
366
+ * 后者是**条目级**失败(能解析但内容不成立)—— **两个粒度、两种处置**(D233 同族)。
319
367
  *
320
- * 失败语义:无配置/字段缺失/配置损坏 → **空数组**(= 不声明任何专属工具 ⇒ 专属工具保持隐藏)。
321
- * 这里选 **fail-closed**(与 im-bridge 的 fail-open 相反)的理由:专属工具的默认态就是"隐藏",
322
- * 判据读不到 = 没有证据表明该 CCC 要它;而配置损坏本身已在 `loadSerenityConfig` 响亮告警
323
- * (不会静默)。im-bridge 相反——隐藏一个**已在使用**的通道才会静默损害能力。
324
368
  * @param root CCC 根
325
369
  * @param paths 候选配置相对路径
326
- * @returns 去空白后的工具名列表(非字符串项被丢弃)
370
+ * @returns `subs` = 干净的订阅表;`problems` = 逐条可读的坏项说明(空数组 = 全好)
327
371
  */
328
- export declare function readExclusiveTools(root: string, paths?: string[]): string[];
372
+ export declare function readTrajectorySubscriptions(root: string, paths?: string[]): {
373
+ subs: TrajectorySubscription[];
374
+ problems: string[];
375
+ };
329
376
  /**
330
377
  * 读取 CCC 声明的「安全模式期间隐藏的工具」清单(`safeMode.hiddenTools`,v1.51.4)。
331
378
  *
332
379
  * 语义(R↓):**标记文件存在 ⇒ 才生效**。deny = `['bash', ...本清单]`(并集去重);
333
380
  * 标记消失 ⇒ 连同 `bash` 一起解除(调用点在 `syncSafeModeRestriction`,与基础表同一处)。
334
381
  *
335
- * 失败方向 = **fail-open**(与 `exclusiveTools` 的 fail-closed 相反,理由不同):
382
+ * 失败方向 = **fail-open**(与已删除的 `exclusiveTools` 的 fail-closed 相反,理由不同):
336
383
  * 未配置 / 字段缺失 / 配置损坏 ⇒ **空数组** = 只隐藏基础表。本键是**附加**屏蔽,
337
384
  * 读不到声明就等于"没有证据要求多藏";且**隐藏是危险方向**(藏掉 agent 真正要用的工具
338
385
  * 会静默损害能力),而配置损坏本身已有 `loadSerenityConfig` 的响亮告警(不静默)。
@@ -58,6 +58,6 @@ export declare function assertManifestProse(manifestPath: string, readmePaths: r
58
58
  * `review`(C 档,调引擎 `compliance`)/`index`(调 P1 的生成器)/`status` ⇒ **14**。
59
59
  * 同规格:**条件可见与否不影响这里列它**(本常量 = "插件**具备**的工具面")。
60
60
  */
61
- export declare const REGISTERED_TOOLS: readonly ["container_fs", "cognitive_trajectory", "dashboard", "container_git", "msm", "praxis", "ontology", "serenity-skill-creator", "localstore", "container_admin", "im-bridge", "acc-diag", "diagram"];
61
+ export declare const REGISTERED_TOOLS: readonly ["container_fs", "cognitive_trajectory", "dashboard", "container_git", "msm", "praxis", "ontology", "serenity-skill-creator", "localstore", "container_admin", "im-bridge", "diagram"];
62
62
  /** 条件出现的工具数(`REGISTERED_TOOLS` 的**末段**;`assertManifestProse` 用) */
63
- export declare const CONDITIONAL_TOOL_COUNT = 3;
63
+ export declare const CONDITIONAL_TOOL_COUNT = 2;
@@ -0,0 +1,254 @@
1
+ import { r as cccRootForCwd } from "./ccc-roots-DPMwBywc.js";
2
+ import { T as sessionsRoot, a as readBindDeclaration, b as parseTrajectoryCode, h as getLastActiveSessionInfo, i as listBindDeclarations, m as getActiveSessionInfo, r as isBindUsable, t as claimBinding, w as sessionEvents } from "./bind-file-B0TuOP89.js";
3
+ import { join } from "node:path";
4
+ //#region src/trajectory/bound.ts
5
+ /**
6
+ * trajectory/bound.ts — 「会话 ↔ 认知轨迹」绑定(**2026-10-20 重做:归属 = 轨迹自己的交代**)
7
+ *
8
+ * ## 判决 D172(owner 2026-10-20 原话,这是整个文件的设计依据)
9
+ *
10
+ * > 「**认知轨迹自己交代永远是最好的交代**」
11
+ * > 「绑定表是没有意义的,绑定关系必须**运行时按照规则探测**,绑定表本质就搞成**第二真相源**了」
12
+ * > 「依赖文件系统…每个认知轨迹目录内放一个 `current.dsh_session_bind`,**以这个为绑定真相源**」
13
+ * > 「文件里写的 sessionId **可能已经不存在了**,这个情况下就**视同没绑定**,就正确了」
14
+ *
15
+ * ## 旧形态为什么错(不是"性能不够",是"真相源放错地方")
16
+ *
17
+ * 旧实现用 **`.bindings.json` + 宿主存储域** 两张旁路索引记录"谁承载谁"。
18
+ * 它们是**旁路**:描述的对象(轨迹目录)与自己分离 ⇒ 一旦不一致就要写代码对账,
19
+ * 而**对账失败没有任何机械信号**(本轨反复踩的形状:症状与真因在读数上完全不像)。
20
+ *
21
+ * 新形态:归属写在**轨迹目录自己里面**(`current.dsh_session_bind`)。
22
+ * 判据不再是"表里有没有一条记录",而是"**这一刻谁真的在承载它**"。
23
+ *
24
+ * ## 🔴 三条不变量(前两条由**形态**保证,第三条由**探针**保证)
25
+ *
26
+ * | # | 不变量 | 保证方式 |
27
+ * |---|---|---|
28
+ * | 1 | 一条轨迹**至多一个**当前载体 | 它是**一个文件**,写入即整体原子覆盖 |
29
+ * | 2 | 一个载体**至多一条**轨迹 | 写入前扫全部轨迹的交代,该 id 已在别处 ⇒ **拒绝** |
30
+ * | 3 | **会话不存在 ⇒ 视同没绑定** | `isBindUsable(decl, alive)`(探针由调用方给) |
31
+ *
32
+ * ⚠️ 旧的 `supersededAt`("只留一条"靠标记退掉)**整条退役**:标记可以忘,覆盖不会忘。
33
+ * 存量里带 `supersededAt` 的记录在迁移时**直接跳过**(它们本就不是"当前")。
34
+ *
35
+ * ## 零依赖与不抛
36
+ * 读函数**永不抛**;写函数返回布尔 / 结构化结果,由调用方决定如何呈现。
37
+ */
38
+ /**
39
+ * 从 dsh 会话对象取 `header.id` / `header.cwd`。
40
+ * @returns 取不到 ⇒ `null`
41
+ */
42
+ function sessionHeader(session) {
43
+ const header = session?.header;
44
+ if (!header || typeof header !== "object") return null;
45
+ return header;
46
+ }
47
+ /** 由会话对象定位 CCC 根(上溯 `.serenity`);定位不到 ⇒ `null` */
48
+ function rootOf(session) {
49
+ const cwd = sessionHeader(session)?.cwd;
50
+ if (typeof cwd !== "string") return null;
51
+ return cccRootForCwd(cwd);
52
+ }
53
+ /** 目录名 → 轨迹标签(固定位置编码;非本工具建的目录回退整名——编码无关 U4) */
54
+ function cognitiveTrajectoryLabel(dirName) {
55
+ return parseTrajectoryCode(dirName) ?? dirName;
56
+ }
57
+ /**
58
+ * 把一条「轨迹自我交代」投影成对外的 `SessionBoundRecord`。
59
+ *
60
+ * 🔴 **`sessionId` 的语义在这里对齐**:旧记录里 `sessionId` 是**展示码**(`S###`),
61
+ * 新绑定文件里是**dsh 会话 id**(它是硬锚)。历史调用方有两类读法,故两个字段都给:
62
+ * · `sessionId` → **dsh 会话 id**(新语义,硬锚)
63
+ * · `sessionId2` → 展示码(由轨迹目录名派生;旧语义)
64
+ * 这样"我要这个会话的 id"与"我要显示成 S###"各自有确定字段,不再靠猜。
65
+ */
66
+ function toRecord(dirName, decl, root) {
67
+ return {
68
+ dirName,
69
+ mdPath: join(root, "AGENT_SESSIONS", dirName, "SESSION.md"),
70
+ sessionId: decl.sessionId,
71
+ sessionId2: cognitiveTrajectoryLabel(dirName),
72
+ at: decl.at,
73
+ action: decl.action,
74
+ ...decl.note !== void 0 ? { note: decl.note } : {},
75
+ ...decl.createdAsUser === true ? { createdAsUser: true } : {}
76
+ };
77
+ }
78
+ /** @deprecated D172:存储域已退役;保留为空实现,装载期调用点无需改动 */
79
+ function setBindingStore(_store) {}
80
+ /**
81
+ * 读**这个会话**当前承载哪条轨迹(正向:会话 → 轨迹)。
82
+ *
83
+ * 判据(D172):扫全部轨迹的自我交代,找 `sessionId === 本会话 id` 且**仍然 usable** 的那条。
84
+ *
85
+ * 🔴 为什么这里是"扫"而不是"查表":**归属是算出来的,不是记下来的**。
86
+ * 本 CCC 的轨迹数是**数十**量级,每次读盘只读每个目录里的一个小文件;比维护一份会漂的索引便宜。
87
+ *
88
+ * ⚠️ **不验"会话是否还活着"**:本函数回答的是"这个**活着的**会话属于谁"——
89
+ * 调用方手里就拿着那个会话对象,它显然存在。`alive` 探针只在**反向**(轨迹 → 会话)才需要。
90
+ *
91
+ * @returns 记录(含 `dirName` / `mdPath` / dsh `sessionId`);无 ⇒ `null`
92
+ */
93
+ function readLastBound(session) {
94
+ const id = sessionHeader(session)?.id;
95
+ const root = rootOf(session);
96
+ if (typeof id !== "string" || id === "" || root === null) return readLegacyBoundFromEvents(session);
97
+ for (const { dirName, decl } of listBindDeclarations(sessionsRoot(root))) if (decl.sessionId === id) return toRecord(dirName, decl, root);
98
+ return readLegacyBoundFromEvents(session);
99
+ }
100
+ /** 旧形态事件类型(v1.29.1~v1.30.5 写入会话日志;只读兼容,**永不写**) */
101
+ const SESSION_BOUND_EVENT = "serenity/bound";
102
+ /** 从会话日志扫最后一条 `serenity/bound`(兼容存量;坏记录跳过) */
103
+ function readLegacyBoundFromEvents(session) {
104
+ const events = sessionEvents(session);
105
+ for (let i = events.length - 1; i >= 0; i--) {
106
+ const ev = events[i];
107
+ if (!ev || ev.type !== SESSION_BOUND_EVENT) continue;
108
+ const d = ev.data;
109
+ if (!d || typeof d.dirName !== "string" || typeof d.mdPath !== "string") continue;
110
+ return {
111
+ dirName: d.dirName,
112
+ mdPath: d.mdPath,
113
+ sessionId: typeof d.sessionId === "string" ? d.sessionId : "",
114
+ at: typeof d.at === "number" ? d.at : 0,
115
+ action: typeof d.action === "string" ? d.action : "activate"
116
+ };
117
+ }
118
+ return null;
119
+ }
120
+ /**
121
+ * **调用方自身**所属轨迹的标签(审计字段 `createdBy` 的唯一正解)。
122
+ *
123
+ * 三级来源(逐级回退,理由见旧实现注释与 S142 §30.12.1 实测):
124
+ * 1. `readLastBound(session)` —— 持久归属(重启存活)
125
+ * 2. `getActiveSessionInfo(scope)` —— 本会话的内存激活(按 scope 隔离)
126
+ * 3. `getLastActiveSessionInfo()` —— 全局最近激活指针(最后兜底)
127
+ */
128
+ function resolveSessionCognitiveTrajectoryLabel(session, scope) {
129
+ const bound = session ? readLastBound(session) : null;
130
+ if (bound?.dirName) return cognitiveTrajectoryLabel(bound.dirName);
131
+ const scoped = scope !== "" ? getActiveSessionInfo(scope) : null;
132
+ if (scoped) return scoped.sessionId;
133
+ return getLastActiveSessionInfo()?.sessionId ?? "";
134
+ }
135
+ /**
136
+ * 反向查:**此刻**承载某条轨迹的 dsh 会话 id(按 `at` 倒序,最新在前)。
137
+ *
138
+ * D172 起判据 = **轨迹自己的交代 + "那个会话还在"**:
139
+ * · 文件不存在 ⇒ 没绑定
140
+ * · 文件在但 `alive` 说不存在 ⇒ **视同没绑定**(owner 原话),**不算候选**
141
+ *
142
+ * ⚠️ **`alive` 缺省时怎么判**:调用方没给探针 ⇒ 只能信文件(`usable`)。
143
+ * 那是"读不到宿主事实"的降级,**不是**"会话一定在"。需要严格判的一方请显式传探针
144
+ * (唤醒选人是这种:见 `wake/scheduler.ts`)。
145
+ *
146
+ * 🔴 **2026-10-20(owner 裁决):生产调用点必须传探针,两个调用点都已接上。**
147
+ * 缺省分支**保留**只为两件事:① 既有单测的向后兼容;② 诊断面"只读文件"的显式用法。
148
+ * ⛔ 不要新增不传探针的生产调用点 —— 那等于把"归档会话算活"的漏洞再开一次
149
+ * (本仓同族已踩:`carrierState` + CRO 快照两处都漏传过)。
150
+ * 生产一律用 `trajectory/alive.ts` 的 `hostAliveProbe(ctx)`(判据 = owner 定的
151
+ * 「① 进程内 loaded ∨ ② 日志在且未被归档」)。
152
+ *
153
+ * @param root CCC 根
154
+ * @param dirName 轨迹目录名
155
+ * @param alive 探针:该 dsh 会话 id 此刻是否真实存在
156
+ * @returns 会话 id 列表(本形态**至多一条**——一个文件只装一个 id);无 ⇒ `[]`
157
+ */
158
+ function listBoundSessionIds(root, dirName, alive) {
159
+ const decl = readBindDeclaration(join(sessionsRoot(root), dirName));
160
+ if (decl === null) return [];
161
+ return (alive ? isBindUsable(decl, alive).state === "usable" : isBindUsable(decl, () => true).state === "usable") ? [decl.sessionId] : [];
162
+ }
163
+ /**
164
+ * 把一条轨迹的当前载体写成 `sessionId`(**唯一写入口**,内部走 `claimBinding` 的两条不变量)。
165
+ *
166
+ * 旧签名 `appendBound(session, action, rec)` 保持不变:`session` 提供 `header.id` / `header.cwd`,
167
+ * `rec.dirName` 指明写哪条轨迹的交代。
168
+ *
169
+ * @returns 写入成功 ⇒ `{ ok: true, replaced?, released? }`(**每条轨迹只有一个文件,没有"追加"语义,只有"覆盖"**);
170
+ * 失败 ⇒ `{ ok: false, code, detail }`,**`code` 指名是哪一条守卫拒的**
171
+ *
172
+ * 🔴 **为什么从 `boolean` 改成分级结果(2026-10-20,一条 skiff 轨迹实测逼出)**:
173
+ * 本函数有**两条完全不同的失败路径** —— ① header 守卫(id / root 取不到)② `claimBinding` 拒绝
174
+ * (`SESSION_TAKEN` / `TRAJECTORY_OCCUPIED` / `WRITE_FAILED`)。日志侧早已分开(D186),
175
+ * **但回执侧只有一句 header 文案** ⇒ 调用方被指向错误的方向(那位实测者第一轮就照文案去找 header,
176
+ * 真因是槽位被占)。⇒ 结果必须**带码**,让回执能说出是哪一半。
177
+ */
178
+ function appendBound(session, action, rec) {
179
+ const id = sessionHeader(session)?.id;
180
+ const root = rootOf(session);
181
+ if (typeof id !== "string" || id === "" || root === null) {
182
+ const detail = `会话对象取不到可用的 header(id=${typeof id === "string" && id !== "" ? id : "(空/非字符串)"},root=${root ?? "null(header.cwd 缺失或上溯不到 .serenity)"}) ⇒ 轨迹「${rec.dirName}」的 current.dsh_session_bind **未被改写**。 排查:该会话的 header.cwd 是否指向某个含 .serenity 的目录; 无 cwd 的会话(如某些程序性创建的载体)**不会**写绑定。`;
183
+ console.warn(`[serenity-hooks] ✗ 绑定未写入(SESSION_HEADER_UNRESOLVED):${detail}`);
184
+ return {
185
+ ok: false,
186
+ code: "SESSION_HEADER_UNRESOLVED",
187
+ detail
188
+ };
189
+ }
190
+ const header = sessionHeader(session);
191
+ if (header?.origin === "subagent") {
192
+ const detail = `会话 ${id} 是 **subagent 子会话**(header.origin="subagent"${header.parentSession ? `,parentSession=${header.parentSession}` : ""}),不能作为轨迹「${rec.dirName}」的载体 ⇒ current.dsh_session_bind **未被改写**。\n 原因:子会话的消息投递权归宿主 subagent routing,ACC 唤醒/投递会被宿主以 \`session/agent-busy\`(is owned by subagent routing)拒绝 ⇒ 该轨迹**每一条**到点唤醒都送不出去。
193
+ 正确做法:在**根会话**(不是子会话)里执行 cognitive_trajectory use。`;
194
+ console.warn(`[serenity-hooks] ✗ 绑定未写入(SESSION_IS_SUBAGENT):${detail}`);
195
+ return {
196
+ ok: false,
197
+ code: "SESSION_IS_SUBAGENT",
198
+ detail
199
+ };
200
+ }
201
+ const result = claimBinding(sessionsRoot(root), rec.dirName, {
202
+ sessionId: id,
203
+ at: Date.now(),
204
+ action,
205
+ ...rec.note !== void 0 ? { note: rec.note } : {},
206
+ ...rec.createdAsUser === true ? { createdAsUser: true } : {}
207
+ }, {
208
+ takeover: rec.takeover === true,
209
+ ...rec.alive !== void 0 ? { alive: rec.alive } : {}
210
+ });
211
+ if (!result.ok) {
212
+ console.warn(`[serenity-hooks] ✗ 绑定未写入(${result.code}):${result.detail}`);
213
+ return {
214
+ ok: false,
215
+ code: result.code,
216
+ detail: result.detail
217
+ };
218
+ }
219
+ return {
220
+ ok: true,
221
+ ...result.replaced === void 0 ? {} : { replaced: result.replaced },
222
+ ...result.released === void 0 ? {} : { released: result.released }
223
+ };
224
+ }
225
+ /**
226
+ * 这条轨迹**是否曾经有过载体**(判"要不要允许自建")。
227
+ *
228
+ * 🔴 这是 owner 第 2 条的判据来源:**"绑定表空"≠"从未有过会话"**。
229
+ * 旧实现拿"未退的绑定条目为空"当"没绑定",于是一条轨迹的载体被退掉之后就
230
+ * 又长出一个新的(实测:S196 在同一分钟出现第二个载体)。
231
+ *
232
+ * 新判据 = **轨迹目录里有没有交代文件**(哪怕它指向的会话已不存在 ⇒ 有过,就不再自动建)。
233
+ *
234
+ * @returns `'never'`(从未有过 ⇒ 允许自建)/`'occupied'`(有过 ⇒ 禁止自建)/`'live'`(此刻有效)
235
+ */
236
+ function carrierState(root, dirName, alive) {
237
+ const decl = readBindDeclaration(join(sessionsRoot(root), dirName));
238
+ if (decl === null) return { state: "never" };
239
+ return isBindUsable(decl, alive).state === "usable" ? {
240
+ state: "live",
241
+ sessionId: decl.sessionId
242
+ } : {
243
+ state: "occupied",
244
+ sessionId: decl.sessionId
245
+ };
246
+ }
247
+ //#endregion
248
+ //#region src/kernel/message-source.ts
249
+ /** ACC 注入消息的 kind —— 唤醒 / 打回 / keeper 提醒 / 锚定轮 / rebuild 交接等一律同值。 */
250
+ const ACC_MESSAGE_KIND = "plugin:dsh-serenity-hooks";
251
+ /** ACC 注入消息的统一来源值(各缝共用;需要 `form` 时按 `ContextFormed` 另加字段)。 */
252
+ const PLUGIN_SOURCE = { kind: ACC_MESSAGE_KIND };
253
+ //#endregion
254
+ export { listBoundSessionIds as a, setBindingStore as c, carrierState as i, PLUGIN_SOURCE as n, readLastBound as o, appendBound as r, resolveSessionCognitiveTrajectoryLabel as s, ACC_MESSAGE_KIND as t };
@@ -0,0 +1,210 @@
1
+ import { d as readCccName, i as findGitRoot, l as pathInside, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS } from "./ccc-BmpE3cQz.js";
2
+ import { u as ACC_VERSION } from "./rules-BLkYTPUy.js";
3
+ import { i as readDshVersion, t as hostContractReport, v as checkRegistryQuality } from "./contract-BiNzzd77.js";
4
+ import { g as listWakes, i as wakeSchedulerState } from "./scheduler-B0pB9mxI.js";
5
+ import { resolve } from "node:path";
6
+ import { existsSync, readFileSync, statSync } from "node:fs";
7
+ //#region src/surface/observation.ts
8
+ /**
9
+ * surface/observation.ts — 容器状态模型的**取数层唯一出口**(C5「观察面归一」,S142 2026-09-15)
10
+ *
11
+ * 为什么存在(R↓):ACC 有多个观察面(`dashboard health` / `acc-diag` / WebUI 面板 /
12
+ * 系统提示词注入),它们读的是同一批宿主事实。此前"这些事实怎么取"写在**每个消费方各自的
13
+ * 函数体里**——同一份状态有几个读者就有几处取数代码。本模块把取数收成一个出口,消费方只留
14
+ * **渲染**(薄渲染)。
15
+ *
16
+ * 本模块**只做取数 + 形状**(设计红线):
17
+ * - **文案/格式归渲染点**。本模块给判据与事实(`rooted: true` / `configPath: '.opencode/serenity.json'`),
18
+ * **不给**人读句子("✓ .serenity marker found")。同一份事实在 `dashboard health` 与 `acc-diag`
19
+ * 里的措辞不同,正是因为它属渲染。
20
+ * - **不合并两条 registry 判据**(见 {@link checkRegistryHealth} / {@link checkRegistryQuality} 的对照注释)。
21
+ * - **时钟读进程内模块级快照**({@link containerClocks})——**不重算**:调度器的武装态/计数只存在于
22
+ * 其模块级运行时对象里,任何"重新推导"得到的都是与真实调度器不一致的假值。
23
+ *
24
+ * 依赖分层(为什么本模块"重"而 `kit-ops` / `status` 保持"轻"):
25
+ * {@link containerClocks} 必须读 `wake-scheduler` 的进程内快照,而该模块带 **@deepseek-ai 值依赖**
26
+ * (`dsh-llm` 的 `createUserMessage`)。故本模块**只能**被本就重型或异步的消费方使用:
27
+ * `diag-ops`(静态 import)、`surface/http.ts` / `kit-ops`(`await import(...)`,与 `surface/http.ts` 既有的
28
+ * "保持静态链纯净"约定一致)。**轻链模块不得静态 import 本模块**。
29
+ *
30
+ * 已有的单一真相源继续指向原处(**不复制**):
31
+ * L0 根解析 `kernel/ccc.ts findSerenityRoot`|L1/L2 根与枚举 `kernel/ccc-roots.ts`|宿主契约 `host/contract.ts`
32
+ * |CCC 配置 `kernel/ccc.ts loadSerenityConfig`|注册表 `tools/msm/service.ts`|时钟 `wake/scheduler.ts`
33
+ * |唤醒注册表 `wake/registry.ts`。
34
+ */
35
+ /** 身份/版本段取数(版本自省是磁盘/常量事实,与 CCC 内容无关) */
36
+ function containerIdentity(root) {
37
+ return {
38
+ root,
39
+ ccc: root ? readCccName(root) : null,
40
+ accVersion: ACC_VERSION,
41
+ dshVersion: readDshVersion(),
42
+ nodeVersion: process.version
43
+ };
44
+ }
45
+ /**
46
+ * 三原则取数。
47
+ * ⚠️ 与 `kernel/ccc.ts` 的 P1/P2 原语是**同一实现**(`existsSync(.serenity)` / `findGitRoot`),
48
+ * 本函数只把它们组装成"一次检查"的形状;判定语义未变(P1 要求文件非空、P2 要求 `.git` 存在)。
49
+ */
50
+ function containerPrinciples(root) {
51
+ const serenityPath = root ? resolve(root, ".serenity") : null;
52
+ const rooted = serenityPath !== null && existsSync(serenityPath) && statSync(serenityPath).size > 0;
53
+ const gitRoot = root ? findGitRoot(root) : null;
54
+ const gitManaged = gitRoot !== null;
55
+ let configPath = null;
56
+ if (root) {
57
+ for (const candidate of DEFAULT_SERENITY_CONFIG_PATHS) if (existsSync(resolve(root, candidate))) {
58
+ configPath = candidate;
59
+ break;
60
+ }
61
+ }
62
+ return {
63
+ rooted,
64
+ gitManaged,
65
+ gitRoot,
66
+ configPath,
67
+ allPass: rooted && gitManaged && configPath !== null
68
+ };
69
+ }
70
+ /**
71
+ * **判据 A —— 注册表「结构完整性」**(原 `tools/kit/service.ts checkRegistryHealth`,C5 迁入本模块)。
72
+ *
73
+ * 回答的问题:**这张表坏没坏**——文件能不能被安全解析(JSON 合法 / 顶层形状 / entry 字段类型 /
74
+ * name 全局唯一 / path 在根内且脚本存在)。
75
+ *
76
+ * 🔴 **为什么不与 {@link checkRegistryQuality} 合并**(设计红线,取证见
77
+ * `acc-c4c5-current-state.md` §⑥ 与 §⑤ 观察面 4/5):两条判据的**主语不同**——
78
+ * - A 的主语是**文件**:"解析它会不会炸"。它**绝不**因为某个 entry 的脚本缺测试文件而报 issue;
79
+ * - B 的主语是**条目契约**:"这条 entry 符合 MSM 的质量规约吗"(DC-M1~M4)。
80
+ *
81
+ * 因此**同一份注册表上两条判据可以给出相反结论**(例:结构完全合法,但没有一条 entry 达标;
82
+ * 或条目全达标,而表里有同名重复 entry)。合并成一个 `ok` 会把"表坏了(要 git restore)"与
83
+ * "表没坏但条目欠火候(去写测试)"两种**修复动作完全不同**的故障压成同一个信号。
84
+ *
85
+ * health 必须**不因坏表抛错**(注册表坏 → `loadMsmEntries` 抛 → `container_admin`/`output-guard`
86
+ * 全崩且自锁无法自救),故本判据独立解析,不走 `loadMsmEntries`。
87
+ */
88
+ function checkRegistryHealth(root) {
89
+ const cccName = readCccName(root);
90
+ if (!cccName) return {
91
+ path: null,
92
+ ok: true,
93
+ present: false,
94
+ issues: []
95
+ };
96
+ const rel = `.opencode/skills/${cccName}/references/mech-registry.json`;
97
+ const abs = resolve(root, rel);
98
+ if (!existsSync(abs)) return {
99
+ path: rel,
100
+ ok: true,
101
+ present: false,
102
+ issues: []
103
+ };
104
+ const issues = [];
105
+ let raw = "";
106
+ try {
107
+ raw = readFileSync(abs, "utf-8");
108
+ } catch (err) {
109
+ return {
110
+ path: rel,
111
+ ok: false,
112
+ present: true,
113
+ issues: [`registry unreadable: ${String(err?.message ?? err)}`]
114
+ };
115
+ }
116
+ let data;
117
+ try {
118
+ data = JSON.parse(raw.replace(/^\uFEFF/, ""));
119
+ } catch (err) {
120
+ return {
121
+ path: rel,
122
+ ok: false,
123
+ present: true,
124
+ issues: [`registry JSON is broken: ${String(err?.message ?? err)}`, `Fix: restore from git — register/deregister auto-commits the registry (git checkout -- ${rel}; or git restore ${rel})`]
125
+ };
126
+ }
127
+ const entries = Array.isArray(data) ? data : data?.entries;
128
+ if (!Array.isArray(entries)) return {
129
+ path: rel,
130
+ ok: false,
131
+ present: true,
132
+ issues: ["registry top-level is neither an array nor a v1 wrapper with entries[]", `Fix: restore from git (git checkout -- ${rel}; or git restore ${rel})`]
133
+ };
134
+ const names = /* @__PURE__ */ new Set();
135
+ for (let i = 0; i < entries.length; i++) {
136
+ const e = entries[i];
137
+ if (typeof e !== "object" || e === null) {
138
+ issues.push(`entry[${i}] is not an object`);
139
+ continue;
140
+ }
141
+ if (typeof e.name !== "string" || e.name === "") issues.push(`entry[${i}]: name missing or not a string`);
142
+ if (typeof e.path !== "string" || e.path === "") issues.push(`entry[${i}] (${String(e.name ?? "?")}): path missing or not a string`);
143
+ if (e.skill !== void 0 && typeof e.skill !== "string") issues.push(`entry[${i}] (${String(e.name ?? "?")}): skill not a string`);
144
+ if (e.category !== void 0 && typeof e.category !== "string") issues.push(`entry[${i}] (${String(e.name ?? "?")}): category not a string`);
145
+ if (typeof e.name === "string" && e.name !== "") {
146
+ if (names.has(e.name)) issues.push(`duplicate MSM name: "${e.name}" (entry[${i}]) — loadMsmEntries dedups by name, ambiguity`);
147
+ names.add(e.name);
148
+ }
149
+ if (typeof e.path === "string" && e.path !== "") {
150
+ const scriptAbs = resolve(root, e.path);
151
+ if (!pathInside(resolve(root), scriptAbs)) issues.push(`entry[${i}] (${String(e.name ?? "?")}): path "${e.path}" escapes CCC root`);
152
+ else if (!existsSync(scriptAbs)) issues.push(`entry[${i}] (${String(e.name ?? "?")}): script not found at "${e.path}"`);
153
+ }
154
+ }
155
+ return {
156
+ path: rel,
157
+ ok: issues.length === 0,
158
+ present: true,
159
+ issues
160
+ };
161
+ }
162
+ /**
163
+ * 时钟的进程内状态("为什么这轮没被唤起"的第一手判据)。
164
+ *
165
+ * 🔴 **只读快照,不重算**(设计红线):`armed`/`ticks`/`lastTickAt`/`lastSkipReason` 只存在于
166
+ * 调度器自己的模块级运行时对象里(`wake/scheduler.ts schedulerRuntime`)。任何"从配置或文件
167
+ * 重新推导"得到的值都会与真实调度器的状态不一致——那比没有可观测量更糟(会给出假的"一切正常")。
168
+ */
169
+ function containerClocks() {
170
+ return { wake: wakeSchedulerState() };
171
+ }
172
+ /** 唤醒注册表取数(文件 IO:每个渲染点自己决定是否要看它) */
173
+ function containerWakes(root) {
174
+ const { entries, error } = listWakes(root);
175
+ return {
176
+ error,
177
+ entries,
178
+ pending: entries.filter((e) => e.state === "pending").length
179
+ };
180
+ }
181
+ /**
182
+ * 核心段组装(`dashboard health` 的取数:身份 + 三原则 + 注册表两判据 + 配置 + 宿主契约)。
183
+ *
184
+ * 时间轴两段(时钟/唤醒注册表)**不在本组装内**:它们是各自独立消费方的需要
185
+ * (`acc-diag` / 开发面诊断),硬塞进来会让只想看三原则的调用方付无谓的 IO。
186
+ * 需要时按名字调 {@link containerClocks} / {@link containerWakes}。
187
+ * (原第三段「autopilot 目标」及其取数函数 `containerAutopilot` 已随 ACC 侧 autopilot 退场删除。)
188
+ */
189
+ function containerStatus(opts = {}) {
190
+ const root = opts.root ?? null;
191
+ const configPaths = opts.configPaths ?? DEFAULT_SERENITY_CONFIG_PATHS;
192
+ const identity = containerIdentity(root);
193
+ return {
194
+ identity,
195
+ principles: containerPrinciples(root),
196
+ registry: {
197
+ structure: root ? checkRegistryHealth(root) : {
198
+ path: null,
199
+ ok: true,
200
+ present: false,
201
+ issues: []
202
+ },
203
+ quality: opts.withRegistryQuality === true && root ? checkRegistryQuality(root) : null
204
+ },
205
+ config: root ? loadSerenityConfig(root, configPaths) : null,
206
+ hostContract: opts.hostCtx !== void 0 ? hostContractReport(opts.hostCtx, identity.dshVersion) : hostContractReport()
207
+ };
208
+ }
209
+ //#endregion
210
+ export { containerClocks, containerStatus, containerWakes };