@shgroup/dsh-serenity-hooks 1.76.0 → 1.77.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,488 @@
1
+ import { r as cccRootForCwd } from "./ccc-roots-rwgzt_0F.js";
2
+ import { A as sessionsRoot, T as parseTrajectoryCode, b as getLastActiveSessionInfo, k as sessionEvents, y as getActiveSessionInfo } from "./profile-DplLGGqo.js";
3
+ import { dirname, join } from "node:path";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
5
+ //#region src/trajectory/bind-file.ts
6
+ /**
7
+ * trajectory/bind-file.ts — **当前载体绑定 = 认知轨迹目录里的自我交代**(判决 D172,owner 2026-10-20)
8
+ *
9
+ * ## owner 的原话(这是本模块的全部设计依据)
10
+ *
11
+ * > 「**认知轨迹自己交代永远是最好的交代**」
12
+ * > 「绑定表是没有意义的,绑定关系必须**运行时按照规则探测**,绑定表本质就搞成**第二真相源**了」
13
+ * > 「依赖文件系统,定义一个 `.dsh_session_bind` 后缀(加入 gitignore);每个认知轨迹目录内放一个
14
+ * > `current.dsh_session_bind`,**以这个为绑定真相源**」
15
+ *
16
+ * ## 为什么这是对的(而不是又一张表)
17
+ *
18
+ * 旧的 `.bindings.json` + 宿主存储域是**旁路索引**:它描述的对象(轨迹目录)与它自己**分离**,
19
+ * ⇒ 一旦两者不一致就必须写代码去对账,而**对账失败没有任何机械信号**(本轨反复踩的形状)。
20
+ *
21
+ * 新形态把这条信息**放回它描述的对象的内部**:
22
+ *
23
+ * ```text
24
+ * <轨迹目录>/
25
+ * SESSION.md ← 身体
26
+ * PROMPT.md ← 章程
27
+ * trajectory.json ← 声明(约束面 / uid / cro.host)
28
+ * current.dsh_session_bind ← 🆕 **此刻谁在承载它**(单个文件 ⇒ 结构上不可能有两个)
29
+ * ```
30
+ *
31
+ * ## 🔴 两条不变量由**形态本身**保证,不靠记账
32
+ *
33
+ * | 不变量 | 保证方式 |
34
+ * |---|---|
35
+ * | 一条轨迹**至多一个**当前载体 | 它是**一个文件**,写入即整体覆盖(原子 rename) |
36
+ * | 一个载体**至多一条**轨迹 | 写入前扫全部轨迹的 bind 文件,**该会话 id 已出现在别处 ⇒ 拒绝** |
37
+ *
38
+ * ⚠️ 旧形态靠 `supersededAt` 标记"退掉",而**标记是可以忘的**;覆盖不会忘。
39
+ *
40
+ * ## 零依赖纪律(同 `policy/files.ts`)
41
+ * 只用 `node:fs` / `node:path` ⇒ 谁 import 它都不会成环。每个读函数**永不抛**。
42
+ *
43
+ * ## 🔴 第三条不变量(owner 2026-10-20 追加):「文件里写的 sessionId 可能已经不存在了
44
+ * ——这个情况下就**视同没绑定**,就正确了」
45
+ *
46
+ * 这一条让整个设计**自洽**:**绑定的有效性由「那个会话真实存在」决定,不由「文件存在」决定。**
47
+ *
48
+ * ```text
49
+ * 文件在 + 会话在 ⇒ 有效绑定
50
+ * 文件在 + 会话没了 ⇒ **视同没绑定**(过期文件自动失效,不需要任何人来清理)
51
+ * 文件不在 ⇒ 没绑定
52
+ * ```
53
+ *
54
+ * ⇒ 因此**不需要**任何"清理陈旧绑定"的机制,也不需要 `reconcile` 去对账 —— 对账这件事本身消失。
55
+ * 代价是**每次取用都要验一次会话是否还在**(`ctx.agents.get` 或 `sessionController.resolveAgent`
56
+ * 或宿主会话清单),**这是运行时探测**,正是 owner 要的那种判据。
57
+ *
58
+ * ⚠️ 本模块**只负责读写文件**;"会话是否还在"必须由**调用方**(持有宿主上下文)来验 ——
59
+ * 把那条判断放在这里会迫使本模块拿到 `ctx`,从而破坏零依赖与可单测性。
60
+ * 调用方正确用法见 {@link isBindUsable}。
61
+ *
62
+ * ## 🔴 第四条:**完整生命周期**(owner 2026-10-20 追加:「调用 use 会覆盖掉这个文件,
63
+ * 完整生命周期要实现,请注意」)
64
+ *
65
+ * 文件是**一个槽位**,不是追加日志。⇒ 每次改变归属都是一次**状态迁移**,
66
+ * 四个动作必须**成对**出现,否则槽位会留下无法解释的残留:
67
+ *
68
+ * | 迁移 | 何时 | 必须做什么 |
69
+ * |---|---|---|
70
+ * | **acquire** | 会话首次认领轨迹 | 写槽位(该会话此前不持有任何轨迹) |
71
+ * | **switch** | 会话从轨迹 A **改到** B | 🔴 **先清 A 的槽位,再写 B** —— 只写 B 会让 A 永远指着一个不再属于它的会话 |
72
+ * | **release** | 会话主动解绑 / 会话将死 | **清掉槽位**(不是写空串,是删文件) |
73
+ * | **takeover** | 载体换代(rebuild / 崩溃后重建) | 覆盖写:旧会话退位,新会话入位(**必须显式声明**) |
74
+ *
75
+ * 🔴 **最容易漏的是 switch 的"先清 A"**:旧实现(`.bindings.json`)天然支持"一个会话多条记录",
76
+ * 所以从来没暴露过这个问题;新形态是**单槽位**,不清 A 就会得到
77
+ * 「A 与 B 都说这个会话属于自己」—— 正是 owner 要禁的"一个会话绑两条轨迹",
78
+ * 只不过这次是**残留**而不是**主动绑定**,更难发现。
79
+ *
80
+ * ⇒ 因此**不要**直接调 {@link writeBindDeclaration} 来改归属;一律走
81
+ * {@link transitionBinding}(它把四条迁移的配对关系写死在一处)。
82
+ */
83
+ /** 绑定文件的固定名(**owner 指定**:`current.dsh_session_bind`) */
84
+ const BIND_FILENAME = "current.dsh_session_bind";
85
+ /**
86
+ * 绑定文件的**族**(认族不认名,D167 通用纪律)。
87
+ *
88
+ * 目前只有一代。留成数组是**为了下一次改名不重演历史**:本仓已在
89
+ * `skiff.json → trajectory-constraint.json → trajectory.json` 上栽过两次
90
+ * (上游改名、下游写死旧名 ⇒ 症状与真因在读数上完全不像)。
91
+ */
92
+ const BIND_FILENAME_FAMILY = [BIND_FILENAME];
93
+ /** 校验一条声明(容忍手改 / 部分字段缺失) */
94
+ function asDeclaration(value) {
95
+ const d = value;
96
+ if (!d || typeof d !== "object") return null;
97
+ if (typeof d.sessionId !== "string" || d.sessionId === "") return null;
98
+ return {
99
+ sessionId: d.sessionId,
100
+ at: typeof d.at === "number" ? d.at : 0,
101
+ action: typeof d.action === "string" && d.action !== "" ? d.action : "activate",
102
+ ...typeof d.note === "string" && d.note !== "" ? { note: d.note } : {},
103
+ ...d.createdAsUser === true ? { createdAsUser: true } : {}
104
+ };
105
+ }
106
+ /** 某轨迹目录里绑定文件的绝对路径 */
107
+ function bindFilePath(trajectoryDir) {
108
+ return join(trajectoryDir, BIND_FILENAME);
109
+ }
110
+ /**
111
+ * 读一条轨迹的**当前载体声明**。
112
+ *
113
+ * @param trajectoryDir 轨迹目录绝对路径
114
+ * @returns 声明;文件不存在 / 坏 JSON / 形状不符 ⇒ `null`(**永不抛**)
115
+ */
116
+ function readBindDeclaration(trajectoryDir) {
117
+ for (const name of BIND_FILENAME_FAMILY) {
118
+ const p = join(trajectoryDir, name);
119
+ try {
120
+ if (!existsSync(p)) continue;
121
+ return asDeclaration(JSON.parse(readFileSync(p, "utf-8")));
122
+ } catch {}
123
+ }
124
+ return null;
125
+ }
126
+ /**
127
+ * 写一条轨迹的**当前载体声明**(原子:同目录 tmp + rename)。
128
+ *
129
+ * 🔴 **不做唯一性检查** —— 那是 `claimBinding` 的职责。本函数只负责"这份文件长成什么样"。
130
+ * 分开的理由:唯一性检查需要**扫全部轨迹**,而写只需要一个目录 ⇒ 让不扫盘的地方能便宜地写。
131
+ */
132
+ function writeBindDeclaration(trajectoryDir, decl) {
133
+ const path = bindFilePath(trajectoryDir);
134
+ const tmp = `${path}.tmp`;
135
+ try {
136
+ mkdirSync(dirname(path), { recursive: true });
137
+ writeFileSync(tmp, `${JSON.stringify(decl, null, 2)}\n`, "utf-8");
138
+ renameSync(tmp, path);
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+ /**
145
+ * 清掉一条轨迹的当前载体声明(解绑)。
146
+ * @returns 文件原本存在且已删除 ⇒ true
147
+ */
148
+ function clearBindDeclaration(trajectoryDir) {
149
+ try {
150
+ const path = bindFilePath(trajectoryDir);
151
+ if (!existsSync(path)) return false;
152
+ renameSync(path, `${path}.del`);
153
+ return true;
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+ /**
159
+ * 列出某 CCC 下**全部轨迹的当前载体声明**(`dirName → 声明`)。
160
+ *
161
+ * 这是**反向查询**(会话 → 轨迹 / 轨迹 → 会话)的唯一入口,取代旧的旁路表扫描。
162
+ * 跳过 `_` / `.` 前缀目录(归档区与运行时区不是轨迹,判据同 `activeTrajectoryDirNames`)。
163
+ *
164
+ * @param sessionsRoot `<CCC>/AGENT_SESSIONS`
165
+ * @returns 有交代的轨迹;**永不抛**(目录读不到 ⇒ 空表)
166
+ */
167
+ function listBindDeclarations(sessionsRoot) {
168
+ const out = [];
169
+ let names;
170
+ try {
171
+ names = readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith("_") && !e.name.startsWith(".")).map((e) => e.name);
172
+ } catch {
173
+ return out;
174
+ }
175
+ for (const dirName of names) {
176
+ const decl = readBindDeclaration(join(sessionsRoot, dirName));
177
+ if (decl) out.push({
178
+ dirName,
179
+ decl
180
+ });
181
+ }
182
+ return out;
183
+ }
184
+ /**
185
+ * **这条绑定还作数吗**(第三条不变量的机械形态)。
186
+ *
187
+ * 判据(owner 原话:「文件里写的 sessionId 可能已经不存在了,这个情况下就视同没绑定」):
188
+ *
189
+ * | 文件 | 会话存在? | 结论 |
190
+ * |---|---|---|
191
+ * | 无 | — | `absent` |
192
+ * | 有 | 是 | **`usable`** |
193
+ * | 有 | 否 | **`stale`** ⇒ **视同没绑定** |
194
+ *
195
+ * 🔴 **为什么"会话存在"必须由调用方给**:判断它要问宿主(`ctx.agents.get` /
196
+ * `sessionController.resolveAgent` / 会话清单),而本模块是零依赖层。
197
+ * ⇒ 这里只收一个 **`alive: (sessionId) => boolean`** 探针,把判断留在外面,本函数仍是纯逻辑、可单测。
198
+ *
199
+ * ⚠️ **`stale` 不删文件**:删除是写操作,而"视同没绑定"已经足够。
200
+ * 留着过期文件反而有用 —— 它记录了"这条轨迹上一次由谁承载"(沿革不丢,同旧 `supersededAt` 的意图)。
201
+ *
202
+ * @param decl {@link readBindDeclaration} 的结果
203
+ * @param alive 探针:该会话 id 此刻是否真实存在(**由调用方用宿主事实实现**)
204
+ * @returns `absent` / `usable` / `stale`,以及(usable 时的)声明
205
+ */
206
+ function isBindUsable(decl, alive) {
207
+ if (decl === null) return { state: "absent" };
208
+ try {
209
+ return alive(decl.sessionId) ? {
210
+ state: "usable",
211
+ decl
212
+ } : {
213
+ state: "stale",
214
+ decl
215
+ };
216
+ } catch {
217
+ return {
218
+ state: "stale",
219
+ decl
220
+ };
221
+ }
222
+ }
223
+ /**
224
+ * 找出**已经占用**某个 dsh 会话 id 的那条轨迹(唯一性判据)。
225
+ *
226
+ * @param sessionsRoot `<CCC>/AGENT_SESSIONS`
227
+ * @param sessionId 待检查的会话 id
228
+ * @returns 占用它的轨迹目录名;无人占用 ⇒ `null`
229
+ */
230
+ function findTrajectoryHolding(sessionsRoot, sessionId) {
231
+ for (const { dirName, decl } of listBindDeclarations(sessionsRoot)) if (decl.sessionId === sessionId) return dirName;
232
+ return null;
233
+ }
234
+ /**
235
+ * **认领**:把某条轨迹的当前载体设成 `sessionId`,并**当场执行两条不变量**。
236
+ *
237
+ * 这是全仓**唯一**允许改变绑定关系的写入口:`appendBound` 与迁移脚本都走这里。
238
+ * 集中成一处的理由 = 不变量只能有一个实现,否则迟早出现"某条路径忘了检查"(本仓已多次踩)。
239
+ *
240
+ * ## 判据(任一不成立即**拒绝**,不写)
241
+ *
242
+ * 1. **载体唯一**:`sessionId` 已安家在**另一条**轨迹 ⇒ 拒绝(一个会话不能绑两条轨迹)。
243
+ * ⚠️ 若它已安家在**本条**轨迹 ⇒ 幂等通过(重复 `use` 同一条轨迹是合法重入)。
244
+ * 2. **轨迹唯一**:本条轨迹已由**另一个** `sessionId` 承载 ⇒ **拒绝**,除非 `takeover`。
245
+ * `takeover` = 载体换代(`rebuild` / 崩溃后新建),由调用方**显式**声明;
246
+ * 绝不默认允许 —— 静默换代正是"一条轨迹长出两个载体"的成因。
247
+ *
248
+ * @param sessionsRoot `<CCC>/AGENT_SESSIONS`
249
+ * @param dirName 目标轨迹目录名
250
+ * @param decl 要写入的声明
251
+ * @param opts.takeover 允许顶掉本条轨迹现有的另一个载体
252
+ * @returns `{ ok: true }` 或 `{ ok: false, code, detail }`(**不抛**,由调用方决定如何呈现)
253
+ */
254
+ function claimBinding(sessionsRoot, dirName, decl, opts = {}) {
255
+ const holder = findTrajectoryHolding(sessionsRoot, decl.sessionId);
256
+ if (holder !== null && holder !== dirName) return {
257
+ ok: false,
258
+ code: "SESSION_TAKEN",
259
+ detail: `会话 ${decl.sessionId} 已经承载轨迹「${holder}」,不能同时再承载「${dirName}」。一个会话只能属于一条认知轨迹 —— 要给「${dirName}」开一个载体,请新建一条会话。`
260
+ };
261
+ const cur = readBindDeclaration(join(sessionsRoot, dirName));
262
+ if (cur !== null && cur.sessionId !== decl.sessionId && opts.takeover !== true) return {
263
+ ok: false,
264
+ code: "TRAJECTORY_OCCUPIED",
265
+ detail: `轨迹「${dirName}」当前已由会话 ${cur.sessionId} 承载(${cur.action},${new Date(cur.at).toISOString()})。如需换代(rebuild / 重建载体),调用方必须显式声明 takeover —— 静默换代会让一条轨迹出现两个载体。`
266
+ };
267
+ let released;
268
+ if (holder !== null && holder !== dirName) {
269
+ if (clearBindDeclaration(join(sessionsRoot, holder))) released = holder;
270
+ }
271
+ if (!writeBindDeclaration(join(sessionsRoot, dirName), decl)) return {
272
+ ok: false,
273
+ code: "WRITE_FAILED",
274
+ detail: `写 ${BIND_FILENAME} 失败:${join(sessionsRoot, dirName)}`
275
+ };
276
+ return released === void 0 ? { ok: true } : {
277
+ ok: true,
278
+ released
279
+ };
280
+ }
281
+ //#endregion
282
+ //#region src/trajectory/bound.ts
283
+ /**
284
+ * trajectory/bound.ts — 「会话 ↔ 认知轨迹」绑定(**2026-10-20 重做:归属 = 轨迹自己的交代**)
285
+ *
286
+ * ## 判决 D172(owner 2026-10-20 原话,这是整个文件的设计依据)
287
+ *
288
+ * > 「**认知轨迹自己交代永远是最好的交代**」
289
+ * > 「绑定表是没有意义的,绑定关系必须**运行时按照规则探测**,绑定表本质就搞成**第二真相源**了」
290
+ * > 「依赖文件系统…每个认知轨迹目录内放一个 `current.dsh_session_bind`,**以这个为绑定真相源**」
291
+ * > 「文件里写的 sessionId **可能已经不存在了**,这个情况下就**视同没绑定**,就正确了」
292
+ *
293
+ * ## 旧形态为什么错(不是"性能不够",是"真相源放错地方")
294
+ *
295
+ * 旧实现用 **`.bindings.json` + 宿主存储域** 两张旁路索引记录"谁承载谁"。
296
+ * 它们是**旁路**:描述的对象(轨迹目录)与自己分离 ⇒ 一旦不一致就要写代码对账,
297
+ * 而**对账失败没有任何机械信号**(本轨反复踩的形状:症状与真因在读数上完全不像)。
298
+ *
299
+ * 新形态:归属写在**轨迹目录自己里面**(`current.dsh_session_bind`)。
300
+ * 判据不再是"表里有没有一条记录",而是"**这一刻谁真的在承载它**"。
301
+ *
302
+ * ## 🔴 三条不变量(前两条由**形态**保证,第三条由**探针**保证)
303
+ *
304
+ * | # | 不变量 | 保证方式 |
305
+ * |---|---|---|
306
+ * | 1 | 一条轨迹**至多一个**当前载体 | 它是**一个文件**,写入即整体原子覆盖 |
307
+ * | 2 | 一个载体**至多一条**轨迹 | 写入前扫全部轨迹的交代,该 id 已在别处 ⇒ **拒绝** |
308
+ * | 3 | **会话不存在 ⇒ 视同没绑定** | `isBindUsable(decl, alive)`(探针由调用方给) |
309
+ *
310
+ * ⚠️ 旧的 `supersededAt`("只留一条"靠标记退掉)**整条退役**:标记可以忘,覆盖不会忘。
311
+ * 存量里带 `supersededAt` 的记录在迁移时**直接跳过**(它们本就不是"当前")。
312
+ *
313
+ * ## 零依赖与不抛
314
+ * 读函数**永不抛**;写函数返回布尔 / 结构化结果,由调用方决定如何呈现。
315
+ */
316
+ /**
317
+ * 从 dsh 会话对象取 `header.id` / `header.cwd`。
318
+ * @returns 取不到 ⇒ `null`
319
+ */
320
+ function sessionHeader(session) {
321
+ const header = session?.header;
322
+ if (!header || typeof header !== "object") return null;
323
+ return header;
324
+ }
325
+ /** 由会话对象定位 CCC 根(上溯 `.serenity`);定位不到 ⇒ `null` */
326
+ function rootOf(session) {
327
+ const cwd = sessionHeader(session)?.cwd;
328
+ if (typeof cwd !== "string") return null;
329
+ return cccRootForCwd(cwd);
330
+ }
331
+ /** 目录名 → 轨迹标签(固定位置编码;非本工具建的目录回退整名——编码无关 U4) */
332
+ function cognitiveTrajectoryLabel(dirName) {
333
+ return parseTrajectoryCode(dirName) ?? dirName;
334
+ }
335
+ /**
336
+ * 把一条「轨迹自我交代」投影成对外的 `SessionBoundRecord`。
337
+ *
338
+ * 🔴 **`sessionId` 的语义在这里对齐**:旧记录里 `sessionId` 是**展示码**(`S###`),
339
+ * 新绑定文件里是**dsh 会话 id**(它是硬锚)。历史调用方有两类读法,故两个字段都给:
340
+ * · `sessionId` → **dsh 会话 id**(新语义,硬锚)
341
+ * · `sessionId2` → 展示码(由轨迹目录名派生;旧语义)
342
+ * 这样"我要这个会话的 id"与"我要显示成 S###"各自有确定字段,不再靠猜。
343
+ */
344
+ function toRecord(dirName, decl, root) {
345
+ return {
346
+ dirName,
347
+ mdPath: join(root, "AGENT_SESSIONS", dirName, "SESSION.md"),
348
+ sessionId: decl.sessionId,
349
+ sessionId2: cognitiveTrajectoryLabel(dirName),
350
+ at: decl.at,
351
+ action: decl.action,
352
+ ...decl.note !== void 0 ? { note: decl.note } : {},
353
+ ...decl.createdAsUser === true ? { createdAsUser: true } : {}
354
+ };
355
+ }
356
+ /** @deprecated D172:存储域已退役;保留为空实现,装载期调用点无需改动 */
357
+ function setBindingStore(_store) {}
358
+ /**
359
+ * 读**这个会话**当前承载哪条轨迹(正向:会话 → 轨迹)。
360
+ *
361
+ * 判据(D172):扫全部轨迹的自我交代,找 `sessionId === 本会话 id` 且**仍然 usable** 的那条。
362
+ *
363
+ * 🔴 为什么这里是"扫"而不是"查表":**归属是算出来的,不是记下来的**。
364
+ * 本 CCC 的轨迹数是**数十**量级,每次读盘只读每个目录里的一个小文件;比维护一份会漂的索引便宜。
365
+ *
366
+ * ⚠️ **不验"会话是否还活着"**:本函数回答的是"这个**活着的**会话属于谁"——
367
+ * 调用方手里就拿着那个会话对象,它显然存在。`alive` 探针只在**反向**(轨迹 → 会话)才需要。
368
+ *
369
+ * @returns 记录(含 `dirName` / `mdPath` / dsh `sessionId`);无 ⇒ `null`
370
+ */
371
+ function readLastBound(session) {
372
+ const id = sessionHeader(session)?.id;
373
+ const root = rootOf(session);
374
+ if (typeof id !== "string" || id === "" || root === null) return readLegacyBoundFromEvents(session);
375
+ for (const { dirName, decl } of listBindDeclarations(sessionsRoot(root))) if (decl.sessionId === id) return toRecord(dirName, decl, root);
376
+ return readLegacyBoundFromEvents(session);
377
+ }
378
+ /** 旧形态事件类型(v1.29.1~v1.30.5 写入会话日志;只读兼容,**永不写**) */
379
+ const SESSION_BOUND_EVENT = "serenity/bound";
380
+ /** 从会话日志扫最后一条 `serenity/bound`(兼容存量;坏记录跳过) */
381
+ function readLegacyBoundFromEvents(session) {
382
+ const events = sessionEvents(session);
383
+ for (let i = events.length - 1; i >= 0; i--) {
384
+ const ev = events[i];
385
+ if (!ev || ev.type !== SESSION_BOUND_EVENT) continue;
386
+ const d = ev.data;
387
+ if (!d || typeof d.dirName !== "string" || typeof d.mdPath !== "string") continue;
388
+ return {
389
+ dirName: d.dirName,
390
+ mdPath: d.mdPath,
391
+ sessionId: typeof d.sessionId === "string" ? d.sessionId : "",
392
+ at: typeof d.at === "number" ? d.at : 0,
393
+ action: typeof d.action === "string" ? d.action : "activate"
394
+ };
395
+ }
396
+ return null;
397
+ }
398
+ /**
399
+ * **调用方自身**所属轨迹的标签(审计字段 `createdBy` 的唯一正解)。
400
+ *
401
+ * 三级来源(逐级回退,理由见旧实现注释与 S142 §30.12.1 实测):
402
+ * 1. `readLastBound(session)` —— 持久归属(重启存活)
403
+ * 2. `getActiveSessionInfo(scope)` —— 本会话的内存激活(按 scope 隔离)
404
+ * 3. `getLastActiveSessionInfo()` —— 全局最近激活指针(最后兜底)
405
+ */
406
+ function resolveSessionCognitiveTrajectoryLabel(session, scope) {
407
+ const bound = session ? readLastBound(session) : null;
408
+ if (bound?.dirName) return cognitiveTrajectoryLabel(bound.dirName);
409
+ const scoped = scope !== "" ? getActiveSessionInfo(scope) : null;
410
+ if (scoped) return scoped.sessionId;
411
+ return getLastActiveSessionInfo()?.sessionId ?? "";
412
+ }
413
+ /**
414
+ * 反向查:**此刻**承载某条轨迹的 dsh 会话 id(按 `at` 倒序,最新在前)。
415
+ *
416
+ * D172 起判据 = **轨迹自己的交代 + "那个会话还在"**:
417
+ * · 文件不存在 ⇒ 没绑定
418
+ * · 文件在但 `alive` 说不存在 ⇒ **视同没绑定**(owner 原话),**不算候选**
419
+ *
420
+ * ⚠️ **`alive` 缺省时怎么判**:调用方没给探针 ⇒ 只能信文件(`usable`)。
421
+ * 那是"读不到宿主事实"的降级,**不是**"会话一定在"。需要严格判的一方请显式传探针
422
+ * (唤醒选人是这种:见 `wake/scheduler.ts`)。
423
+ *
424
+ * @param root CCC 根
425
+ * @param dirName 轨迹目录名
426
+ * @param alive 探针:该 dsh 会话 id 此刻是否真实存在
427
+ * @returns 会话 id 列表(本形态**至多一条**——一个文件只装一个 id);无 ⇒ `[]`
428
+ */
429
+ function listBoundSessionIds(root, dirName, alive) {
430
+ const decl = readBindDeclaration(join(sessionsRoot(root), dirName));
431
+ if (decl === null) return [];
432
+ return (alive ? isBindUsable(decl, alive).state === "usable" : isBindUsable(decl, () => true).state === "usable") ? [decl.sessionId] : [];
433
+ }
434
+ /**
435
+ * 把一条轨迹的当前载体写成 `sessionId`(**唯一写入口**,内部走 `claimBinding` 的两条不变量)。
436
+ *
437
+ * 旧签名 `appendBound(session, action, rec)` 保持不变:`session` 提供 `header.id` / `header.cwd`,
438
+ * `rec.dirName` 指明写哪条轨迹的交代。
439
+ *
440
+ * @returns 写入成功 ⇒ true(**每条轨迹只有一个文件,没有"追加"语义,只有"覆盖"**)
441
+ */
442
+ function appendBound(session, action, rec) {
443
+ const id = sessionHeader(session)?.id;
444
+ const root = rootOf(session);
445
+ if (typeof id !== "string" || id === "" || root === null) return false;
446
+ const result = claimBinding(sessionsRoot(root), rec.dirName, {
447
+ sessionId: id,
448
+ at: Date.now(),
449
+ action,
450
+ ...rec.note !== void 0 ? { note: rec.note } : {},
451
+ ...rec.createdAsUser === true ? { createdAsUser: true } : {}
452
+ }, { takeover: rec.takeover === true });
453
+ if (!result.ok) {
454
+ console.warn(`[serenity-hooks] ✗ 绑定未写入(${result.code}):${result.detail}`);
455
+ return false;
456
+ }
457
+ return true;
458
+ }
459
+ /**
460
+ * 这条轨迹**是否曾经有过载体**(判"要不要允许自建")。
461
+ *
462
+ * 🔴 这是 owner 第 2 条的判据来源:**"绑定表空"≠"从未有过会话"**。
463
+ * 旧实现拿"未退的绑定条目为空"当"没绑定",于是一条轨迹的载体被退掉之后就
464
+ * 又长出一个新的(实测:S196 在同一分钟出现第二个载体)。
465
+ *
466
+ * 新判据 = **轨迹目录里有没有交代文件**(哪怕它指向的会话已不存在 ⇒ 有过,就不再自动建)。
467
+ *
468
+ * @returns `'never'`(从未有过 ⇒ 允许自建)/`'occupied'`(有过 ⇒ 禁止自建)/`'live'`(此刻有效)
469
+ */
470
+ function carrierState(root, dirName, alive) {
471
+ const decl = readBindDeclaration(join(sessionsRoot(root), dirName));
472
+ if (decl === null) return { state: "never" };
473
+ return isBindUsable(decl, alive).state === "usable" ? {
474
+ state: "live",
475
+ sessionId: decl.sessionId
476
+ } : {
477
+ state: "occupied",
478
+ sessionId: decl.sessionId
479
+ };
480
+ }
481
+ //#endregion
482
+ //#region src/kernel/message-source.ts
483
+ /** ACC 注入消息的 kind —— 唤醒 / 打回 / keeper 提醒 / 锚定轮 / rebuild 交接等一律同值。 */
484
+ const ACC_MESSAGE_KIND = "plugin:dsh-serenity-hooks";
485
+ /** ACC 注入消息的统一来源值(各缝共用;需要 `form` 时按 `ContextFormed` 另加字段)。 */
486
+ const PLUGIN_SOURCE = { kind: ACC_MESSAGE_KIND };
487
+ //#endregion
488
+ 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 };
@@ -13,7 +13,14 @@
13
13
  * 归属(D23)不变:ACC 提供机制(教程 + 校验 + 列表),**约束内容归 CCC**(写进 `trajectory-constraint.json`)。
14
14
  */
15
15
  import type { JsonValue } from '../kernel/json.js';
16
- /** 轨迹约束 定义教程:概念 / `trajectory.json` schema / 双白名单 / 轨迹纪律 / 微信路由 */
16
+ /**
17
+ * 轨迹约束 定义教程:**只讲「限制面」**(约束面 / 双白名单 / 纪律参与项 / 路由)。
18
+ *
19
+ * 🔴 2026-10-20 拆分(owner 裁 Q2):本章原先同时承载「描述自己」与「限制自己」。
20
+ * 两者读者与前提不同 —— **描述自己是所有轨迹的常态,限制自己才是特例** ——
21
+ * 故「章程 / 常驻 / 三个载体」已移入 `config docs trajectory-self`。
22
+ * ⛔ **本文件不再重复那份内容**(一个内容一个正文入口,v1.75.0 指针纪律)。
23
+ */
17
24
  export declare const TRAJECTORY_CONSTRAINT_GUIDE: string;
18
25
  /** 校验当前 CCC 的 轨迹约束面:msms 均已注册 / model ∈ cheap-worker.models / 提示词非空 */
19
26
  export declare function validateTrajectoryConstraint(root: string): JsonValue;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * tools/trajectory-self-docs.ts — 「一条认知轨迹如何描述自己」的**手册正文**
3
+ *
4
+ * ## 为什么这一章单独存在(owner 2026-10-20 原话)
5
+ *
6
+ * > 「例如某个认知轨迹想设定自己是**常驻**的,想给自己做**轨迹约束**,
7
+ * > 这种**说明性的内容**有吗,在哪里,**这是很重要的内容**,
8
+ * > 是认知轨迹感知理解这些**边界的重要手册**」
9
+ *
10
+ * ⇒ 此前「描述自己」与「限制自己」被混成一章(`trajectory-constraint`),
11
+ * 而那两件事的**读者与前提完全不同**:
12
+ *
13
+ * | 面 | 谁需要 | 性质 |
14
+ * |---|---|---|
15
+ * | **描述自己**(我是谁、我擅长什么、我是否欢迎来麻烦我) | **所有轨迹** | 常态 |
16
+ * | **限制自己**(工具白名单、模型、MSM 白名单) | 只有需要限权的轨迹 | 特例 |
17
+ *
18
+ * ⇒ 把「描述」独立成章。**普通轨迹不必为了写一段自述而变成受约束的轨迹** ——
19
+ * 这是本章存在的**唯一理由**,也是 D173「常驻 = 自我声明」的落点。
20
+ *
21
+ * ## 归属(D23)
22
+ * 机制在 ACC(有哪些载体文件、字段叫什么、怎么被读、何时生效);
23
+ * **内容在 CCC**(这条轨迹具体写什么 —— 它的章程与自述是它自己的知识)。
24
+ *
25
+ * ## 与 `trajectory-constraint` 章的分工(一个内容一个正文入口)
26
+ * 本章 = **声明面(我是什么)**;那一章 = **约束面(我被限成什么)**。
27
+ * 两章互指,但**不复制**对方正文(本仓 v1.75.0 的指针纪律)。
28
+ */
29
+ /**
30
+ * 一章的全文。
31
+ *
32
+ * ⚠️ 本函数是**纯常量拼接**(无 IO、无 ctx):手册正文不随容器状态变化,
33
+ * 唯一随状态变的东西("我现在是不是轨迹约束")由 `config list` 回答,不写进正文。
34
+ */
35
+ export declare const TRAJECTORY_SELF_GUIDE: string;