@shgroup/dsh-serenity-hooks 1.87.7 → 1.88.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/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-serenity-hooks",
3
- "version": "1.87.7",
3
+ "version": "1.88.0",
4
4
  "main": "lib/index.js",
5
5
  "description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——14 个工具(container_fs/cognitive_trajectory/dashboard/container_git/msm/praxis/ontology/serenity-skill-creator/cheap-worker/localstore/container_admin;另三个 im-bridge/acc-diag/diagram 按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 认知轨迹日志与原地重建 + 网页登录入口/IM 桥/认知轨迹唤醒注册表。轨迹声明 = 轨迹目录下的 trajectory.json(约束面 + 旧标识 uid + CRO 机器绑定 cro.host;旧名 trajectory-constraint.json / skiff.json 仍在读取族里);轨迹提示词 = 轨迹目录下的 PROMPT.md(一条轨迹「是谁、按什么章程工作」,所有轨迹通用,追加在容器层与质量层之后,不替换它们)。适配 DSH 0.1.7-rc.1 / 0.2.0-rc.1 / 0.2.1-rc.1(三档,deepseek-ai/deepseek-harness)。",
6
6
  "engines": {
@@ -227,7 +227,7 @@ async function listCccs(ctx, opts = {}) {
227
227
  if (typeof defaultRoot === "string" && defaultRoot !== "" && !c.roots.includes(defaultRoot)) c.roots.unshift(defaultRoot);
228
228
  let rolesOf = null;
229
229
  if (opts.withRoles === true) {
230
- const { listTrajectoryConstraints } = await import("./profile-DGt_e-Nx.js").then((n) => n.a);
230
+ const { listTrajectoryConstraints } = await import("./profile-zxakCNXy.js").then((n) => n.a);
231
231
  rolesOf = (root) => {
232
232
  try {
233
233
  return listTrajectoryConstraints(root).map((p) => p.code);
@@ -154,6 +154,46 @@ export declare function imChannelIds(): string[];
154
154
  * @param root 本会话 CCC 根(由调用方解析)
155
155
  */
156
156
  export declare function hasEnabledImChannel(root: string): boolean;
157
+ /**
158
+ * 可见性判据(**轨迹级**,2026-10-23 owner 令):
159
+ * 这个 dsh 会话所属的认知轨迹,是否**被 IM 路由表点名**(= 它才是那个"接线人")。
160
+ *
161
+ * ## 它解决的问题(owner 原话的**去标识改写**,原文含私有角色名,按 D145 不入仓库)
162
+ *
163
+ * > 「很多轨迹发消息给我,但是我没空回,它们就会等待我,其实并不是,我希望他们继续工作;
164
+ * > 所以只有 IM 桥绑定了任意账户的认知轨迹才拥有 `im-bridge` 这个 tool,
165
+ * > 这样可以确保只有一个接线角色和我联系,其它轨迹和它联系即可,它会自行回复它们的」
166
+ *
167
+ * ⇒ 旧判据是 **CCC 级**({@link hasEnabledImChannel}:本 CCC 有任一启用账号 ⇒ 全体可见)。
168
+ * 它的后果不是"少一个工具",而是**一条错误的工作流**:每条轨迹都以为自己该直接对 owner 说话,
169
+ * 于是发完就**停下来等人回**——而 owner 根本没空逐条回。**工具在,就把等待也带上了。**
170
+ *
171
+ * ## 判据(两条,缺一不可)
172
+ *
173
+ * 1. 本 CCC 有可用账号({@link hasEnabledImChannel})—— 没有账号时路由表也没有意义;
174
+ * 2. 本会话**绑定的轨迹编码**出现在 `routes[].trajectory` 里。
175
+ *
176
+ * ⚠️ **为什么不看 `routes[].user`**:那是"对端是谁"(owner 的通道 id),
177
+ * 判"这条轨迹能不能说话"只跟**轨迹**有关,与哪个对端无关。
178
+ *
179
+ * ⚠️ **`*` 通配也算点名**:`{ user:"*", trajectory:"<某轨迹>" }` 这条路由的含义就是
180
+ * "**没人认领的消息都归那条轨迹**" ⇒ 它确实是一条被授权的接线轨迹,必须算可见。
181
+ * 这与 `matchWeixinRoute` 的通配兜底语义**同源**,不另立规则。
182
+ *
183
+ * ## 拿不到轨迹编码时怎么办(fail-closed,与 `exclusive` 同向)
184
+ *
185
+ * 会话 → 轨迹编码要读绑定。**取不到就判不可见**:
186
+ * - 与 `exclusive`(未声明 ⇒ 隐藏)同向;
187
+ * - 反向(取不到就放行)会让"绑定坏了"表现成"工具还在",
188
+ * 而那正是本判据要防的形态(D179:配置说它有、行为说它没有,且零报错)。
189
+ * ⚠️ 代价明示:绑定读取若短暂失败,该轮会少一个工具;下一轮绑定恢复即回。
190
+ * 这比"误给一条轨迹发消息的权限"便宜 —— 后者会让它**停下来等 owner**。
191
+ *
192
+ * @param root CCC 根
193
+ * @param trajectoryCode 本会话所属认知轨迹的编码(**由调用方解析**,见 `trajectory/policy/profile.ts`)
194
+ * @returns true = 该轨迹接线,`im-bridge` 可见
195
+ */
196
+ export declare function isImWiredTrajectory(root: string, trajectoryCode: string | null | undefined): boolean;
157
197
  /** 结果载荷(lossless JSON——工具返回值必须可 JSON 化) */
158
198
  export type ImJson = null | boolean | number | string | ImJson[] | {
159
199
  [key: string]: ImJson;
package/lib/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { i as __toESM, n as __exportAll } from "./rolldown-runtime-B4iAMlE-.js";
2
- import { c as hostInjected, d as hostSessions, f as hostSettings, h as hostWebServer, i as cccRootForExec, l as hostLlm, m as hostWeb, n as agentCwdFor, o as listCccs, p as hostSubagents, r as cccRootForCwd, s as hostAgents, t as NO_CCC_FROM_AGENT_CWD, u as hostService } from "./ccc-roots-CvgsUnFW.js";
2
+ import { c as hostInjected, d as hostSessions, f as hostSettings, h as hostWebServer, i as cccRootForExec, l as hostLlm, m as hostWeb, n as agentCwdFor, o as listCccs, p as hostSubagents, r as cccRootForCwd, s as hostAgents, t as NO_CCC_FROM_AGENT_CWD, u as hostService } from "./ccc-roots-WfktvbN8.js";
3
3
  import { _ as resolveInside, a as findSerenityRoot$1, c as matchBlacklist, d as readCccName$1, f as readCheapWorkerConfig, h as readSafeModeHiddenTools, i as findGitRoot, l as pathInside, m as readExclusiveTools, n as SAFE_MODE_MARKER, o as isSafeModeOn, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-CyYJL4F2.js";
4
- import { A as resolveSessionByTitle, B as readTrajectoryFile, C as listActiveTrajectories, D as parseTrajectoryCode, E as parseSessionContextFromEvents, F as summarize, H as localFileStamp, I as trajectoryDescOf, L as useSession, M as sessionsRoot, N as setActiveSessionInfo, P as showSession, R as TRAJECTORY_FILENAME, T as namingTitleFor, U as localHuman, V as isoLocal, W as localIdStamp, _ as clearActiveSessionInfo, b as findSession, c as replacesDefaultIdentity, g as archiveSession, h as UID_FILENAME, i as listTrajectoryConstraints, j as sessionEvents, k as readTrajectoryUid, l as trajectoryConstraintForSession, n as findDirNameByCode, o as readTrajectoryConstraintAt, p as COGNITIVE_TRAJECTORY_ACTIONS, r as isTrajectoryConstrained, s as readTrajectoryConstraintByCode, t as TRAJECTORY_CONSTRAINT_FILENAME, v as createSession, w as listSessions, x as getActiveSessionInfo, y as extractSessionMdPathFromText, z as TRAJECTORY_LEGACY_FILENAMES } from "./profile-DGt_e-Nx.js";
4
+ import { A as namingTitleFor, B as summarize, C as createSession, E as getActiveSessionInfo, F as resolveSessionByTitle, G as readTrajectoryFile, H as useSession, I as sessionEvents, J as localHuman, K as isoLocal, L as sessionsRoot, M as parseTrajectoryCode, O as listActiveTrajectories, P as readTrajectoryUid, R as setActiveSessionInfo, S as clearActiveSessionInfo, T as findSession, U as TRAJECTORY_FILENAME, V as trajectoryDescOf, W as TRAJECTORY_LEGACY_FILENAMES, Y as localIdStamp, b as UID_FILENAME, c as replacesDefaultIdentity, i as listTrajectoryConstraints, j as parseSessionContextFromEvents, k as listSessions, l as trajectoryCodeForSession, n as findDirNameByCode, o as readTrajectoryConstraintAt, q as localFileStamp, r as isTrajectoryConstrained, s as readTrajectoryConstraintByCode, t as TRAJECTORY_CONSTRAINT_FILENAME, u as trajectoryConstraintForSession, v as COGNITIVE_TRAJECTORY_ACTIONS, w as extractSessionMdPathFromText, x as archiveSession, z as showSession } from "./profile-zxakCNXy.js";
5
5
  import { n as checkLocalstoreGitCompliance, o as readStore, s as runLocalStore, t as LOCALSTORE_SCOPES } from "./service-DDmHSglV.js";
6
- import { _ as resolveConstraintSystemPrompt, a as listBoundSessionIds, d as constraintCognitiveTrajectory, f as constraintSystemPromptSource, g as profileWriteWhitelist, h as profileToolWhitelist, i as carrierState, l as TRAJECTORY_CONSTRAINT_HINT, n as PLUGIN_SOURCE, o as readLastBound, r as appendBound, s as resolveSessionCognitiveTrajectoryLabel, t as ACC_MESSAGE_KIND, v as ACC_VERSION, y as findPackageRoot } from "./message-source-Cq4PsuES.js";
7
- import { A as buildResourceIndex, C as verifyPassword, D as recordToolUsage, E as verifyTotpCode, F as hasTrajectoryPrompt, M as isSafeSkillName, N as truncateContent, O as REFERENCE_DIR, P as TRAJECTORY_PROMPT_FILENAME, S as toWire, T as MECHANISM_PORTS, _ as registerEntrySkillSectionGlobal, a as trajectoryConstraintFor, b as projectKnownWorkspaces, c as trajectoryWorkspaceMdPath, d as CHEAP_WORKER_GUIDE, f as providerAvailabilityError, g as registerEntrySkillSection, h as registerCognitiveTrajectorySkillSection, i as getTrajectoryAgent, j as buildResourceNameSuffix, k as SKILL_ONTOLOGY_FILE, l as unregisterTrajectorySession, m as splitModel, n as createTrajectoryAgent, o as trajectoryConstraintMsmGate, p as requireWhitelistedModel, r as ensureWorkspacePromptSection, s as trajectoryConstraintParticipates, t as askTrajectory, u as workspaceCognitiveTrajectoryLine, v as applyWirePatch, w as FACE_PORTS, x as readAdvancedSettings, y as migrateLegacyLocalstore } from "./session-Ca5UYzF3.js";
6
+ import { _ as resolveConstraintSystemPrompt, a as listBoundSessionIds, d as constraintCognitiveTrajectory, f as constraintSystemPromptSource, g as profileWriteWhitelist, h as profileToolWhitelist, i as carrierState, l as TRAJECTORY_CONSTRAINT_HINT, n as PLUGIN_SOURCE, o as readLastBound, r as appendBound, s as resolveSessionCognitiveTrajectoryLabel, t as ACC_MESSAGE_KIND, v as ACC_VERSION, y as findPackageRoot } from "./message-source-BAMwXu6i.js";
7
+ import { A as buildResourceIndex, C as verifyPassword, D as recordToolUsage, E as verifyTotpCode, F as hasTrajectoryPrompt, M as isSafeSkillName, N as truncateContent, O as REFERENCE_DIR, P as TRAJECTORY_PROMPT_FILENAME, S as toWire, T as MECHANISM_PORTS, _ as registerEntrySkillSectionGlobal, a as trajectoryConstraintFor, b as projectKnownWorkspaces, c as trajectoryWorkspaceMdPath, d as CHEAP_WORKER_GUIDE, f as providerAvailabilityError, g as registerEntrySkillSection, h as registerCognitiveTrajectorySkillSection, i as getTrajectoryAgent, j as buildResourceNameSuffix, k as SKILL_ONTOLOGY_FILE, l as unregisterTrajectorySession, m as splitModel, n as createTrajectoryAgent, o as trajectoryConstraintMsmGate, p as requireWhitelistedModel, r as ensureWorkspacePromptSection, s as trajectoryConstraintParticipates, t as askTrajectory, u as workspaceCognitiveTrajectoryLine, v as applyWirePatch, w as FACE_PORTS, x as readAdvancedSettings, y as migrateLegacyLocalstore } from "./session-Bb4GSSFJ.js";
8
8
  import { i as sessionsRootDir, n as findSessionLog, r as hasSessionLogById } from "./cleanup-CPl1SSaK.js";
9
- import { n as readSimpleSettings, r as registerSettingsSection } from "./settings-BI3NRHgC.js";
9
+ import { n as readSimpleSettings, r as registerSettingsSection } from "./settings-DAorSseV.js";
10
10
  import { a as readFeishuCredential, c as readWeixinSettings, d as weixinInboundDir, f as weixinSessionIdForTrajectory, i as matchWeixinRoute, n as extractWeixinText, o as readImSettings, r as hasVoiceItem, s as readWeixinCredential, t as extractWeixinMedia, u as sanitizeFileName$1 } from "./route-3OzOAjec.js";
11
11
  import { REGISTERED_TOOLS } from "./invariant.js";
12
12
  import { c as sendTextMessage, i as getUpdates, l as sendTyping, n as downloadMedia, o as markdownToPlainText, r as getConfig, s as sendFileMessage, t as TypingStatus, u as sniffImageExt } from "./ilink-CsZ-f64q.js";
@@ -12391,6 +12391,61 @@ function hasEnabledImChannel(root) {
12391
12391
  return (settings.accounts ?? []).some((a) => a.enabled !== false);
12392
12392
  }
12393
12393
  /**
12394
+ * 可见性判据(**轨迹级**,2026-10-23 owner 令):
12395
+ * 这个 dsh 会话所属的认知轨迹,是否**被 IM 路由表点名**(= 它才是那个"接线人")。
12396
+ *
12397
+ * ## 它解决的问题(owner 原话的**去标识改写**,原文含私有角色名,按 D145 不入仓库)
12398
+ *
12399
+ * > 「很多轨迹发消息给我,但是我没空回,它们就会等待我,其实并不是,我希望他们继续工作;
12400
+ * > 所以只有 IM 桥绑定了任意账户的认知轨迹才拥有 `im-bridge` 这个 tool,
12401
+ * > 这样可以确保只有一个接线角色和我联系,其它轨迹和它联系即可,它会自行回复它们的」
12402
+ *
12403
+ * ⇒ 旧判据是 **CCC 级**({@link hasEnabledImChannel}:本 CCC 有任一启用账号 ⇒ 全体可见)。
12404
+ * 它的后果不是"少一个工具",而是**一条错误的工作流**:每条轨迹都以为自己该直接对 owner 说话,
12405
+ * 于是发完就**停下来等人回**——而 owner 根本没空逐条回。**工具在,就把等待也带上了。**
12406
+ *
12407
+ * ## 判据(两条,缺一不可)
12408
+ *
12409
+ * 1. 本 CCC 有可用账号({@link hasEnabledImChannel})—— 没有账号时路由表也没有意义;
12410
+ * 2. 本会话**绑定的轨迹编码**出现在 `routes[].trajectory` 里。
12411
+ *
12412
+ * ⚠️ **为什么不看 `routes[].user`**:那是"对端是谁"(owner 的通道 id),
12413
+ * 判"这条轨迹能不能说话"只跟**轨迹**有关,与哪个对端无关。
12414
+ *
12415
+ * ⚠️ **`*` 通配也算点名**:`{ user:"*", trajectory:"<某轨迹>" }` 这条路由的含义就是
12416
+ * "**没人认领的消息都归那条轨迹**" ⇒ 它确实是一条被授权的接线轨迹,必须算可见。
12417
+ * 这与 `matchWeixinRoute` 的通配兜底语义**同源**,不另立规则。
12418
+ *
12419
+ * ## 拿不到轨迹编码时怎么办(fail-closed,与 `exclusive` 同向)
12420
+ *
12421
+ * 会话 → 轨迹编码要读绑定。**取不到就判不可见**:
12422
+ * - 与 `exclusive`(未声明 ⇒ 隐藏)同向;
12423
+ * - 反向(取不到就放行)会让"绑定坏了"表现成"工具还在",
12424
+ * 而那正是本判据要防的形态(D179:配置说它有、行为说它没有,且零报错)。
12425
+ * ⚠️ 代价明示:绑定读取若短暂失败,该轮会少一个工具;下一轮绑定恢复即回。
12426
+ * 这比"误给一条轨迹发消息的权限"便宜 —— 后者会让它**停下来等 owner**。
12427
+ *
12428
+ * @param root CCC 根
12429
+ * @param trajectoryCode 本会话所属认知轨迹的编码(**由调用方解析**,见 `trajectory/policy/profile.ts`)
12430
+ * @returns true = 该轨迹接线,`im-bridge` 可见
12431
+ */
12432
+ function isImWiredTrajectory(root, trajectoryCode) {
12433
+ if (typeof trajectoryCode !== "string" || trajectoryCode.trim() === "") return false;
12434
+ if (!hasEnabledImChannel(root)) return false;
12435
+ let settings;
12436
+ try {
12437
+ settings = readImSettings(root);
12438
+ } catch {
12439
+ return false;
12440
+ }
12441
+ const wanted = trajectoryCode.trim();
12442
+ for (const route of settings.routes ?? []) {
12443
+ const target = typeof route.trajectory === "string" ? route.trajectory.trim() : "";
12444
+ if (target !== "" && target === wanted) return true;
12445
+ }
12446
+ return false;
12447
+ }
12448
+ /**
12394
12449
  * 错误码 → 可行动提示(模型视角:说清怎么改)。
12395
12450
  *
12396
12451
  * 🔴 `CHANNEL_NOT_CONFIGURED` 的提示**不能写死某一个渠道的字段名**(2026-10-20 Q4 真机验收踩到):
@@ -12920,7 +12975,7 @@ function computeVisibilitySources(sessionId, root) {
12920
12975
  }
12921
12976
  return { deny: [.../* @__PURE__ */ new Set([...SAFE_MODE_DENY_TOOLS, ...declared])] };
12922
12977
  }));
12923
- out.push(sourceOf("imBridge", () => hasEnabledImChannel(root) ? { deny: [] } : { deny: [IM_BRIDGE_TOOL_NAME] }));
12978
+ out.push(sourceOf("imBridge", () => isImWiredTrajectory(root, trajectoryCodeForSession(root, sessionId)) ? { deny: [] } : { deny: [IM_BRIDGE_TOOL_NAME] }));
12924
12979
  out.push(sourceOf("exclusive", () => {
12925
12980
  const declared = readExclusiveTools(root);
12926
12981
  return { deny: EXCLUSIVE_TOOL_NAMES.filter((t) => !declared.includes(t)) };
@@ -15113,7 +15168,7 @@ function registerStatusApi(ctx, opts = {}) {
15113
15168
  });
15114
15169
  }
15115
15170
  const { saveWeixinRoutes } = await import("./route-3OzOAjec.js").then((n) => n.l);
15116
- const { findDirNameByCode } = await import("./profile-DGt_e-Nx.js").then((n) => n.a);
15171
+ const { findDirNameByCode } = await import("./profile-zxakCNXy.js").then((n) => n.a);
15117
15172
  for (const r of routes) if (findDirNameByCode(root, r.trajectory) === null) {
15118
15173
  sendJson$2(res, 400, { error: `unknown trajectory: ${r.trajectory} (no such trajectory code in ${root})` });
15119
15174
  return;
@@ -20156,7 +20211,7 @@ const MEDIA_MAX_BYTES = 20971520;
20156
20211
  */
20157
20212
  async function resolveCccRootByAlias(ctx, alias) {
20158
20213
  try {
20159
- const { listCccs } = await import("./ccc-roots-CvgsUnFW.js").then((n) => n.a);
20214
+ const { listCccs } = await import("./ccc-roots-WfktvbN8.js").then((n) => n.a);
20160
20215
  const entries = await listCccs(ctx);
20161
20216
  const byName = entries.find((e) => e.name === alias);
20162
20217
  if (byName) return byName.root;
@@ -20224,7 +20279,7 @@ async function resolveInboundTarget(ctx, input) {
20224
20279
  });
20225
20280
  return null;
20226
20281
  }
20227
- const { findDirNameByCode } = await import("./profile-DGt_e-Nx.js").then((n) => n.a);
20282
+ const { findDirNameByCode } = await import("./profile-zxakCNXy.js").then((n) => n.a);
20228
20283
  if (findDirNameByCode(root, target.role) === null) {
20229
20284
  const detail = `目标 CCC "${target.ccc}" 内没有编码为 "${target.role}" 的认知轨迹`;
20230
20285
  console.error(`[serenity-hooks] ✗ human-channel: ${detail}(account=${input.account} user=${input.user})—— 该消息**未投递**,请改订阅表里的目标编码`);
@@ -21178,7 +21233,7 @@ function syncHumanChannel(ctx) {
21178
21233
  (async () => {
21179
21234
  try {
21180
21235
  stopMachineLoops();
21181
- const { listCccs } = await import("./ccc-roots-CvgsUnFW.js").then((n) => n.a);
21236
+ const { listCccs } = await import("./ccc-roots-WfktvbN8.js").then((n) => n.a);
21182
21237
  for (const entry of await listCccs(ctx)) syncCccBridge(ctx, entry.root);
21183
21238
  } catch {}
21184
21239
  })();
@@ -21471,7 +21526,7 @@ function matchCcc(input, candidates) {
21471
21526
  }
21472
21527
  /** 组装候选列表(`ccc-roots.listCccs` 投影 + `.serenity` 名;动态 import 保持本模块静态依赖轻量) */
21473
21528
  async function collectCandidates(ctx) {
21474
- const { listCccs } = await import("./ccc-roots-CvgsUnFW.js").then((n) => n.a);
21529
+ const { listCccs } = await import("./ccc-roots-WfktvbN8.js").then((n) => n.a);
21475
21530
  const entries = await listCccs(ctx, { defaultRoot: process.cwd() });
21476
21531
  const seen = /* @__PURE__ */ new Set();
21477
21532
  const out = [];
@@ -1,8 +1,8 @@
1
- import { r as cccRootForCwd } from "./ccc-roots-CvgsUnFW.js";
1
+ import { r as cccRootForCwd } from "./ccc-roots-WfktvbN8.js";
2
2
  import { _ as resolveInside, g as readUtf8 } from "./ccc-CyYJL4F2.js";
3
- import { D as parseTrajectoryCode, M as sessionsRoot, S as getLastActiveSessionInfo, j as sessionEvents, x as getActiveSessionInfo } from "./profile-DGt_e-Nx.js";
3
+ import { D as getLastActiveSessionInfo, E as getActiveSessionInfo, I as sessionEvents, L as sessionsRoot, M as parseTrajectoryCode, _ as readBindDeclaration, g as listBindDeclarations, h as isBindUsable, m as claimBinding } from "./profile-zxakCNXy.js";
4
4
  import { dirname, join } from "node:path";
5
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
5
+ import { existsSync, readFileSync, statSync } from "node:fs";
6
6
  import { fileURLToPath } from "node:url";
7
7
  //#region src/kernel/constants.ts
8
8
  /** 常量(纯模块,零 DSH 依赖) */
@@ -260,328 +260,6 @@ function buildTrajectoryBasePrompt(code) {
260
260
  ].join("\n");
261
261
  }
262
262
  //#endregion
263
- //#region src/trajectory/bind-file.ts
264
- /**
265
- * trajectory/bind-file.ts — **当前载体绑定 = 认知轨迹目录里的自我交代**(判决 D172,owner 2026-10-20)
266
- *
267
- * ## owner 的原话(这是本模块的全部设计依据)
268
- *
269
- * > 「**认知轨迹自己交代永远是最好的交代**」
270
- * > 「绑定表是没有意义的,绑定关系必须**运行时按照规则探测**,绑定表本质就搞成**第二真相源**了」
271
- * > 「依赖文件系统,定义一个 `.dsh_session_bind` 后缀(加入 gitignore);每个认知轨迹目录内放一个
272
- * > `current.dsh_session_bind`,**以这个为绑定真相源**」
273
- *
274
- * ## 为什么这是对的(而不是又一张表)
275
- *
276
- * 旧的 `.bindings.json` + 宿主存储域是**旁路索引**:它描述的对象(轨迹目录)与它自己**分离**,
277
- * ⇒ 一旦两者不一致就必须写代码去对账,而**对账失败没有任何机械信号**(本轨反复踩的形状)。
278
- *
279
- * 新形态把这条信息**放回它描述的对象的内部**:
280
- *
281
- * ```text
282
- * <轨迹目录>/
283
- * SESSION.md ← 身体
284
- * PROMPT.md ← 章程
285
- * trajectory.json ← 声明(约束面 / uid / cro.host)
286
- * current.dsh_session_bind ← 🆕 **此刻谁在承载它**(单个文件 ⇒ 结构上不可能有两个)
287
- * ```
288
- *
289
- * ## 🔴 两条不变量由**形态本身**保证,不靠记账
290
- *
291
- * | 不变量 | 保证方式 |
292
- * |---|---|
293
- * | 一条轨迹**至多一个**当前载体 | 它是**一个文件**,写入即整体覆盖(原子 rename) |
294
- * | 一个载体**至多一条**轨迹 | 写入前扫全部轨迹的 bind 文件,**该会话 id 已出现在别处 ⇒ 拒绝** |
295
- *
296
- * ⚠️ 旧形态靠 `supersededAt` 标记"退掉",而**标记是可以忘的**;覆盖不会忘。
297
- *
298
- * ## 零依赖纪律(同 `policy/files.ts`)
299
- * 只用 `node:fs` / `node:path` ⇒ 谁 import 它都不会成环。每个读函数**永不抛**。
300
- *
301
- * ## 🔴 第三条不变量(owner 2026-10-20 追加):「文件里写的 sessionId 可能已经不存在了
302
- * ——这个情况下就**视同没绑定**,就正确了」
303
- *
304
- * 这一条让整个设计**自洽**:**绑定的有效性由「那个会话真实存在」决定,不由「文件存在」决定。**
305
- *
306
- * ```text
307
- * 文件在 + 会话在 ⇒ 有效绑定
308
- * 文件在 + 会话没了 ⇒ **视同没绑定**(过期文件自动失效,不需要任何人来清理)
309
- * 文件不在 ⇒ 没绑定
310
- * ```
311
- *
312
- * ⇒ 因此**不需要**任何"清理陈旧绑定"的机制,也不需要 `reconcile` 去对账 —— 对账这件事本身消失。
313
- * 代价是**每次取用都要验一次会话是否还在**(`ctx.agents.get` 或 `sessionController.resolveAgent`
314
- * 或宿主会话清单),**这是运行时探测**,正是 owner 要的那种判据。
315
- *
316
- * ⚠️ 本模块**只负责读写文件**;"会话是否还在"必须由**调用方**(持有宿主上下文)来验 ——
317
- * 把那条判断放在这里会迫使本模块拿到 `ctx`,从而破坏零依赖与可单测性。
318
- * 调用方正确用法见 {@link isBindUsable}。
319
- *
320
- * ## 🔴 第四条:**完整生命周期**(owner 2026-10-20 追加:「调用 use 会覆盖掉这个文件,
321
- * 完整生命周期要实现,请注意」)
322
- *
323
- * 文件是**一个槽位**,不是追加日志。⇒ 每次改变归属都是一次**状态迁移**,
324
- * 四个动作必须**成对**出现,否则槽位会留下无法解释的残留:
325
- *
326
- * | 迁移 | 何时 | 必须做什么 |
327
- * |---|---|---|
328
- * | **acquire** | 会话首次认领轨迹 | 写槽位(该会话此前不持有任何轨迹) |
329
- * | **switch** | 会话从轨迹 A **改到** B | 🔴 **先清 A 的槽位,再写 B** —— 只写 B 会让 A 永远指着一个不再属于它的会话 |
330
- * | **release** | 会话主动解绑 / 会话将死 | **清掉槽位**(不是写空串,是删文件) |
331
- * | **takeover** | 载体换代(rebuild / 崩溃后重建) | 覆盖写:旧会话退位,新会话入位(**必须显式声明**) |
332
- *
333
- * 🔴 **最容易漏的是 switch 的"先清 A"**:旧实现(`.bindings.json`)天然支持"一个会话多条记录",
334
- * 所以从来没暴露过这个问题;新形态是**单槽位**,不清 A 就会得到
335
- * 「A 与 B 都说这个会话属于自己」—— 正是 owner 要禁的"一个会话绑两条轨迹",
336
- * 只不过这次是**残留**而不是**主动绑定**,更难发现。
337
- *
338
- * ⇒ 因此**不要**直接调 {@link writeBindDeclaration} 来改归属;一律走
339
- * {@link transitionBinding}(它把四条迁移的配对关系写死在一处)。
340
- */
341
- /** 绑定文件的固定名(**owner 指定**:`current.dsh_session_bind`) */
342
- const BIND_FILENAME = "current.dsh_session_bind";
343
- /**
344
- * 绑定文件的**族**(认族不认名,D167 通用纪律)。
345
- *
346
- * 目前只有一代。留成数组是**为了下一次改名不重演历史**:本仓已在
347
- * `skiff.json → trajectory-constraint.json → trajectory.json` 上栽过两次
348
- * (上游改名、下游写死旧名 ⇒ 症状与真因在读数上完全不像)。
349
- */
350
- const BIND_FILENAME_FAMILY = [BIND_FILENAME];
351
- /** 轨迹归档区的目录名(**自持一份,不从 `core.ts` import**)。 */
352
- const ARCHIVED_DIR_NAME = "_archived";
353
- /** 校验一条声明(容忍手改 / 部分字段缺失) */
354
- function asDeclaration(value) {
355
- const d = value;
356
- if (!d || typeof d !== "object") return null;
357
- if (typeof d.sessionId !== "string" || d.sessionId === "") return null;
358
- return {
359
- sessionId: d.sessionId,
360
- at: typeof d.at === "number" ? d.at : 0,
361
- action: typeof d.action === "string" && d.action !== "" ? d.action : "activate",
362
- ...typeof d.note === "string" && d.note !== "" ? { note: d.note } : {},
363
- ...d.createdAsUser === true ? { createdAsUser: true } : {}
364
- };
365
- }
366
- /** 某轨迹目录里绑定文件的绝对路径 */
367
- function bindFilePath(trajectoryDir) {
368
- return join(trajectoryDir, BIND_FILENAME);
369
- }
370
- /**
371
- * 读一条轨迹的**当前载体声明**。
372
- *
373
- * @param trajectoryDir 轨迹目录绝对路径
374
- * @returns 声明;文件不存在 / 坏 JSON / 形状不符 ⇒ `null`(**永不抛**)
375
- */
376
- function readBindDeclaration(trajectoryDir) {
377
- for (const name of BIND_FILENAME_FAMILY) {
378
- const p = join(trajectoryDir, name);
379
- try {
380
- if (!existsSync(p)) continue;
381
- return asDeclaration(JSON.parse(readFileSync(p, "utf-8")));
382
- } catch {}
383
- }
384
- return null;
385
- }
386
- /**
387
- * 写一条轨迹的**当前载体声明**(原子:同目录 tmp + rename)。
388
- *
389
- * 🔴 **不做唯一性检查** —— 那是 `claimBinding` 的职责。本函数只负责"这份文件长成什么样"。
390
- * 分开的理由:唯一性检查需要**扫全部轨迹**,而写只需要一个目录 ⇒ 让不扫盘的地方能便宜地写。
391
- */
392
- function writeBindDeclaration(trajectoryDir, decl) {
393
- const path = bindFilePath(trajectoryDir);
394
- const tmp = `${path}.tmp`;
395
- try {
396
- mkdirSync(dirname(path), { recursive: true });
397
- writeFileSync(tmp, `${JSON.stringify(decl, null, 2)}\n`, "utf-8");
398
- renameSync(tmp, path);
399
- return true;
400
- } catch {
401
- return false;
402
- }
403
- }
404
- /**
405
- * 清掉一条轨迹的当前载体声明(解绑)。
406
- * @returns 文件原本存在且已删除 ⇒ true
407
- */
408
- function clearBindDeclaration(trajectoryDir) {
409
- try {
410
- const path = bindFilePath(trajectoryDir);
411
- if (!existsSync(path)) return false;
412
- renameSync(path, `${path}.del`);
413
- return true;
414
- } catch {
415
- return false;
416
- }
417
- }
418
- /**
419
- * 列出某 CCC 下**全部轨迹的当前载体声明**(`dirName → 声明`)。
420
- *
421
- * 这是**反向查询**(会话 → 轨迹 / 轨迹 → 会话)的唯一入口,取代旧的旁路表扫描。
422
- * 跳过 `_` / `.` 前缀目录(归档区与运行时区不是轨迹,判据同 `activeTrajectoryDirNames`)。
423
- *
424
- * 🔴 **2026-10-20(owner 裁决「死包含归档判断」配套):`_archived/` 必须**再扫一层**。**
425
- *
426
- * 修之前:归档目录整体被跳过 ⇒ **已归档轨迹的绑定声明对唯一性检查完全隐形**。
427
- * 后果:承载过它的 dsh 会话看起来"无归属",可以被 `use` 到另一条轨迹上 ——
428
- * 这正是 IMMUTABLE 1「一个会话终身只属于一条轨迹」要禁的事,而且是**静默**发生的。
429
- *
430
- * 为什么只扫归档区、不扫别的 `_` 目录:归档区里的**确实是轨迹**(只是不再活跃),
431
- * 而 `_轨迹约束-logs` / `_weixin-logs` / `_corpus` 是日志与数据区,它们里面不会有
432
- * `current.dsh_session_bind`(真有一个也只是垃圾,读到会被 `asDeclaration` 挡掉)。
433
- * ⇒ 只多付一次 `_archived` 的 readdir,不改变"活跃清单"的语义(那是 `readAllSessions` 的事)。
434
- *
435
- * @param sessionsRoot `<CCC>/AGENT_SESSIONS`
436
- * @returns 有交代的轨迹;**永不抛**(目录读不到 ⇒ 空表)
437
- */
438
- function listBindDeclarations(sessionsRoot) {
439
- const out = [];
440
- const collect = (dir, into) => {
441
- let names;
442
- try {
443
- names = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith("_") && !e.name.startsWith(".")).map((e) => e.name);
444
- } catch {
445
- return;
446
- }
447
- for (const dirName of names) {
448
- const decl = readBindDeclaration(join(dir, dirName));
449
- if (decl) into(dirName, decl);
450
- }
451
- };
452
- collect(sessionsRoot, (dirName, decl) => out.push({
453
- dirName,
454
- decl
455
- }));
456
- collect(join(sessionsRoot, ARCHIVED_DIR_NAME), (dirName, decl) => {
457
- if (!out.some((r) => r.dirName === dirName)) out.push({
458
- dirName,
459
- decl
460
- });
461
- });
462
- return out;
463
- }
464
- /**
465
- * **这条绑定还作数吗**(第三条不变量的机械形态)。
466
- *
467
- * 判据(owner 原话:「文件里写的 sessionId 可能已经不存在了,这个情况下就视同没绑定」):
468
- *
469
- * | 文件 | 会话存在? | 结论 |
470
- * |---|---|---|
471
- * | 无 | — | `absent` |
472
- * | 有 | 是 | **`usable`** |
473
- * | 有 | 否 | **`stale`** ⇒ **视同没绑定** |
474
- *
475
- * 🔴 **为什么"会话存在"必须由调用方给**:判断它要问宿主(`ctx.agents.get` /
476
- * `sessionController.resolveAgent` / 会话清单),而本模块是零依赖层。
477
- * ⇒ 这里只收一个 **`alive: (sessionId) => boolean`** 探针,把判断留在外面,本函数仍是纯逻辑、可单测。
478
- *
479
- * ⚠️ **`stale` 不删文件**:删除是写操作,而"视同没绑定"已经足够。
480
- * 留着过期文件反而有用 —— 它记录了"这条轨迹上一次由谁承载"(沿革不丢,同旧 `supersededAt` 的意图)。
481
- *
482
- * @param decl {@link readBindDeclaration} 的结果
483
- * @param alive 探针:该会话 id 此刻是否真实存在(**由调用方用宿主事实实现**)
484
- * @returns `absent` / `usable` / `stale`,以及(usable 时的)声明
485
- */
486
- function isBindUsable(decl, alive) {
487
- if (decl === null) return { state: "absent" };
488
- try {
489
- return alive(decl.sessionId) ? {
490
- state: "usable",
491
- decl
492
- } : {
493
- state: "stale",
494
- decl
495
- };
496
- } catch {
497
- return {
498
- state: "stale",
499
- decl
500
- };
501
- }
502
- }
503
- /**
504
- * 找出**已经占用**某个 dsh 会话 id 的那条轨迹(唯一性判据)。
505
- *
506
- * @param sessionsRoot `<CCC>/AGENT_SESSIONS`
507
- * @param sessionId 待检查的会话 id
508
- * @returns 占用它的轨迹目录名;无人占用 ⇒ `null`
509
- */
510
- function findTrajectoryHolding(sessionsRoot, sessionId) {
511
- for (const { dirName, decl } of listBindDeclarations(sessionsRoot)) if (decl.sessionId === sessionId) return dirName;
512
- return null;
513
- }
514
- /**
515
- * **认领**:把某条轨迹的当前载体设成 `sessionId`,并**当场执行两条不变量**。
516
- *
517
- * 这是全仓**唯一**允许改变绑定关系的写入口:`appendBound` 与迁移脚本都走这里。
518
- * 集中成一处的理由 = 不变量只能有一个实现,否则迟早出现"某条路径忘了检查"(本仓已多次踩)。
519
- *
520
- * ## 判据(任一不成立即**拒绝**,不写)
521
- *
522
- * 1. **载体唯一**:`sessionId` 已安家在**另一条**轨迹 ⇒ 拒绝(一个会话不能绑两条轨迹)。
523
- * ⚠️ 若它已安家在**本条**轨迹 ⇒ 幂等通过(重复 `use` 同一条轨迹是合法重入)。
524
- * 2. **轨迹唯一**:本条轨迹已由**另一个** `sessionId` 承载 ⇒ **拒绝**,除非 `takeover`。
525
- * `takeover` = 载体换代(`rebuild` / 崩溃后新建),由调用方**显式**声明;
526
- * 绝不默认允许 —— 静默换代正是"一条轨迹长出两个载体"的成因。
527
- *
528
- * 🔴 **2026-10-20(owner 裁决,S196 实测逼出):第 ② 条只在"旧载体还活着"时成立。**
529
- *
530
- * 修之前:`claimBinding` **完全不问存活**,只要文件里有别的 `sessionId` 就拒。
531
- * 而读侧(`listBoundSessionIds(…, alive)`)却按 IMMUTABLE 3 把过期声明当"没绑定"
532
- * ⇒ **同一条轨迹,两个函数给出相反的答案**。S196 就是撞上这一格:
533
- * 它的绑定文件指着 `session-52d680ae`(**已在宿主归档集里** = 死),
534
- * 而 `use` 被 `TRAJECTORY_OCCUPIED` 拒掉,回执还说"成功"(另有一条 D186 的静默问题)。
535
- *
536
- * ⇒ 判据(第三条不变量的**写侧形态**):**旧载体会死 ⇒ 本条轨迹算空槽,直接写**。
537
- * `alive` 不传时**沿用"文件在就算占用"**的旧语义(保守、向后兼容)——
538
- * ⚠️ 那是"读不到宿主事实"的降级,**不是**"它一定活着";
539
- * 生产调用点必须显式传 {@link hostAliveProbe}。
540
- *
541
- * @param sessionsRoot `<CCC>/AGENT_SESSIONS`
542
- * @param dirName 目标轨迹目录名
543
- * @param decl 要写入的声明
544
- * @param opts.takeover 允许顶掉本条轨迹现有的另一个载体
545
- * @param opts.alive 探针:**旧载体**是否还活着(缺省 ⇒ 一律当活 = 旧语义)
546
- * @returns `{ ok: true, replaced?, released? }` 或 `{ ok: false, code, detail }`(**不抛**,由调用方决定如何呈现)
547
- *
548
- * 🔴 **2026-10-20 追加 `replaced`(由一条 skiff 轨迹的实测逼出)**:`released` 只覆盖
549
- * 「这个会话此前安家在**另一条**轨迹」那一格(跨轨迹清理);而**同一轨迹换载体**时被顶掉的那个
550
- * 会话 id 此前**没有任何出口** ⇒ 调用方无法在回执里说出"你顶掉了谁"。
551
- * 静默换代正是"一条轨迹感觉有两个载体"的成因 ⇒ 顶掉谁必须能被读出来。
552
- */
553
- function claimBinding(sessionsRoot, dirName, decl, opts = {}) {
554
- const holder = findTrajectoryHolding(sessionsRoot, decl.sessionId);
555
- if (holder !== null && holder !== dirName) return {
556
- ok: false,
557
- code: "SESSION_TAKEN",
558
- detail: `会话 ${decl.sessionId} 已经承载轨迹「${holder}」,不能同时再承载「${dirName}」。一个会话只能属于一条认知轨迹 —— 要给「${dirName}」开一个载体,请新建一条会话。`
559
- };
560
- const cur = readBindDeclaration(join(sessionsRoot, dirName));
561
- if (cur !== null && cur.sessionId !== decl.sessionId && opts.takeover !== true) {
562
- if (opts.alive === void 0 ? true : opts.alive(cur.sessionId)) return {
563
- ok: false,
564
- code: "TRAJECTORY_OCCUPIED",
565
- detail: `轨迹「${dirName}」当前已由会话 ${cur.sessionId} 承载(${cur.action},${new Date(cur.at).toISOString()})。如需换代(rebuild / 重建载体),调用方必须显式声明 takeover —— 静默换代会让一条轨迹出现两个载体。`
566
- };
567
- }
568
- let released;
569
- if (holder !== null && holder !== dirName) {
570
- if (clearBindDeclaration(join(sessionsRoot, holder))) released = holder;
571
- }
572
- if (!writeBindDeclaration(join(sessionsRoot, dirName), decl)) return {
573
- ok: false,
574
- code: "WRITE_FAILED",
575
- detail: `写 ${BIND_FILENAME} 失败:${join(sessionsRoot, dirName)}`
576
- };
577
- const replaced = cur !== null && cur.sessionId !== decl.sessionId ? cur.sessionId : void 0;
578
- return {
579
- ok: true,
580
- ...replaced === void 0 ? {} : { replaced },
581
- ...released === void 0 ? {} : { released }
582
- };
583
- }
584
- //#endregion
585
263
  //#region src/trajectory/bound.ts
586
264
  /**
587
265
  * trajectory/bound.ts — 「会话 ↔ 认知轨迹」绑定(**2026-10-20 重做:归属 = 轨迹自己的交代**)
@@ -930,6 +930,328 @@ function archiveSession(root, key, dryRun = false) {
930
930
  };
931
931
  }
932
932
  //#endregion
933
+ //#region src/trajectory/bind-file.ts
934
+ /**
935
+ * trajectory/bind-file.ts — **当前载体绑定 = 认知轨迹目录里的自我交代**(判决 D172,owner 2026-10-20)
936
+ *
937
+ * ## owner 的原话(这是本模块的全部设计依据)
938
+ *
939
+ * > 「**认知轨迹自己交代永远是最好的交代**」
940
+ * > 「绑定表是没有意义的,绑定关系必须**运行时按照规则探测**,绑定表本质就搞成**第二真相源**了」
941
+ * > 「依赖文件系统,定义一个 `.dsh_session_bind` 后缀(加入 gitignore);每个认知轨迹目录内放一个
942
+ * > `current.dsh_session_bind`,**以这个为绑定真相源**」
943
+ *
944
+ * ## 为什么这是对的(而不是又一张表)
945
+ *
946
+ * 旧的 `.bindings.json` + 宿主存储域是**旁路索引**:它描述的对象(轨迹目录)与它自己**分离**,
947
+ * ⇒ 一旦两者不一致就必须写代码去对账,而**对账失败没有任何机械信号**(本轨反复踩的形状)。
948
+ *
949
+ * 新形态把这条信息**放回它描述的对象的内部**:
950
+ *
951
+ * ```text
952
+ * <轨迹目录>/
953
+ * SESSION.md ← 身体
954
+ * PROMPT.md ← 章程
955
+ * trajectory.json ← 声明(约束面 / uid / cro.host)
956
+ * current.dsh_session_bind ← 🆕 **此刻谁在承载它**(单个文件 ⇒ 结构上不可能有两个)
957
+ * ```
958
+ *
959
+ * ## 🔴 两条不变量由**形态本身**保证,不靠记账
960
+ *
961
+ * | 不变量 | 保证方式 |
962
+ * |---|---|
963
+ * | 一条轨迹**至多一个**当前载体 | 它是**一个文件**,写入即整体覆盖(原子 rename) |
964
+ * | 一个载体**至多一条**轨迹 | 写入前扫全部轨迹的 bind 文件,**该会话 id 已出现在别处 ⇒ 拒绝** |
965
+ *
966
+ * ⚠️ 旧形态靠 `supersededAt` 标记"退掉",而**标记是可以忘的**;覆盖不会忘。
967
+ *
968
+ * ## 零依赖纪律(同 `policy/files.ts`)
969
+ * 只用 `node:fs` / `node:path` ⇒ 谁 import 它都不会成环。每个读函数**永不抛**。
970
+ *
971
+ * ## 🔴 第三条不变量(owner 2026-10-20 追加):「文件里写的 sessionId 可能已经不存在了
972
+ * ——这个情况下就**视同没绑定**,就正确了」
973
+ *
974
+ * 这一条让整个设计**自洽**:**绑定的有效性由「那个会话真实存在」决定,不由「文件存在」决定。**
975
+ *
976
+ * ```text
977
+ * 文件在 + 会话在 ⇒ 有效绑定
978
+ * 文件在 + 会话没了 ⇒ **视同没绑定**(过期文件自动失效,不需要任何人来清理)
979
+ * 文件不在 ⇒ 没绑定
980
+ * ```
981
+ *
982
+ * ⇒ 因此**不需要**任何"清理陈旧绑定"的机制,也不需要 `reconcile` 去对账 —— 对账这件事本身消失。
983
+ * 代价是**每次取用都要验一次会话是否还在**(`ctx.agents.get` 或 `sessionController.resolveAgent`
984
+ * 或宿主会话清单),**这是运行时探测**,正是 owner 要的那种判据。
985
+ *
986
+ * ⚠️ 本模块**只负责读写文件**;"会话是否还在"必须由**调用方**(持有宿主上下文)来验 ——
987
+ * 把那条判断放在这里会迫使本模块拿到 `ctx`,从而破坏零依赖与可单测性。
988
+ * 调用方正确用法见 {@link isBindUsable}。
989
+ *
990
+ * ## 🔴 第四条:**完整生命周期**(owner 2026-10-20 追加:「调用 use 会覆盖掉这个文件,
991
+ * 完整生命周期要实现,请注意」)
992
+ *
993
+ * 文件是**一个槽位**,不是追加日志。⇒ 每次改变归属都是一次**状态迁移**,
994
+ * 四个动作必须**成对**出现,否则槽位会留下无法解释的残留:
995
+ *
996
+ * | 迁移 | 何时 | 必须做什么 |
997
+ * |---|---|---|
998
+ * | **acquire** | 会话首次认领轨迹 | 写槽位(该会话此前不持有任何轨迹) |
999
+ * | **switch** | 会话从轨迹 A **改到** B | 🔴 **先清 A 的槽位,再写 B** —— 只写 B 会让 A 永远指着一个不再属于它的会话 |
1000
+ * | **release** | 会话主动解绑 / 会话将死 | **清掉槽位**(不是写空串,是删文件) |
1001
+ * | **takeover** | 载体换代(rebuild / 崩溃后重建) | 覆盖写:旧会话退位,新会话入位(**必须显式声明**) |
1002
+ *
1003
+ * 🔴 **最容易漏的是 switch 的"先清 A"**:旧实现(`.bindings.json`)天然支持"一个会话多条记录",
1004
+ * 所以从来没暴露过这个问题;新形态是**单槽位**,不清 A 就会得到
1005
+ * 「A 与 B 都说这个会话属于自己」—— 正是 owner 要禁的"一个会话绑两条轨迹",
1006
+ * 只不过这次是**残留**而不是**主动绑定**,更难发现。
1007
+ *
1008
+ * ⇒ 因此**不要**直接调 {@link writeBindDeclaration} 来改归属;一律走
1009
+ * {@link transitionBinding}(它把四条迁移的配对关系写死在一处)。
1010
+ */
1011
+ /** 绑定文件的固定名(**owner 指定**:`current.dsh_session_bind`) */
1012
+ const BIND_FILENAME = "current.dsh_session_bind";
1013
+ /**
1014
+ * 绑定文件的**族**(认族不认名,D167 通用纪律)。
1015
+ *
1016
+ * 目前只有一代。留成数组是**为了下一次改名不重演历史**:本仓已在
1017
+ * `skiff.json → trajectory-constraint.json → trajectory.json` 上栽过两次
1018
+ * (上游改名、下游写死旧名 ⇒ 症状与真因在读数上完全不像)。
1019
+ */
1020
+ const BIND_FILENAME_FAMILY = [BIND_FILENAME];
1021
+ /** 轨迹归档区的目录名(**自持一份,不从 `core.ts` import**)。 */
1022
+ const ARCHIVED_DIR_NAME = "_archived";
1023
+ /** 校验一条声明(容忍手改 / 部分字段缺失) */
1024
+ function asDeclaration(value) {
1025
+ const d = value;
1026
+ if (!d || typeof d !== "object") return null;
1027
+ if (typeof d.sessionId !== "string" || d.sessionId === "") return null;
1028
+ return {
1029
+ sessionId: d.sessionId,
1030
+ at: typeof d.at === "number" ? d.at : 0,
1031
+ action: typeof d.action === "string" && d.action !== "" ? d.action : "activate",
1032
+ ...typeof d.note === "string" && d.note !== "" ? { note: d.note } : {},
1033
+ ...d.createdAsUser === true ? { createdAsUser: true } : {}
1034
+ };
1035
+ }
1036
+ /** 某轨迹目录里绑定文件的绝对路径 */
1037
+ function bindFilePath(trajectoryDir) {
1038
+ return join(trajectoryDir, BIND_FILENAME);
1039
+ }
1040
+ /**
1041
+ * 读一条轨迹的**当前载体声明**。
1042
+ *
1043
+ * @param trajectoryDir 轨迹目录绝对路径
1044
+ * @returns 声明;文件不存在 / 坏 JSON / 形状不符 ⇒ `null`(**永不抛**)
1045
+ */
1046
+ function readBindDeclaration(trajectoryDir) {
1047
+ for (const name of BIND_FILENAME_FAMILY) {
1048
+ const p = join(trajectoryDir, name);
1049
+ try {
1050
+ if (!existsSync(p)) continue;
1051
+ return asDeclaration(JSON.parse(readFileSync(p, "utf-8")));
1052
+ } catch {}
1053
+ }
1054
+ return null;
1055
+ }
1056
+ /**
1057
+ * 写一条轨迹的**当前载体声明**(原子:同目录 tmp + rename)。
1058
+ *
1059
+ * 🔴 **不做唯一性检查** —— 那是 `claimBinding` 的职责。本函数只负责"这份文件长成什么样"。
1060
+ * 分开的理由:唯一性检查需要**扫全部轨迹**,而写只需要一个目录 ⇒ 让不扫盘的地方能便宜地写。
1061
+ */
1062
+ function writeBindDeclaration(trajectoryDir, decl) {
1063
+ const path = bindFilePath(trajectoryDir);
1064
+ const tmp = `${path}.tmp`;
1065
+ try {
1066
+ mkdirSync(dirname(path), { recursive: true });
1067
+ writeFileSync(tmp, `${JSON.stringify(decl, null, 2)}\n`, "utf-8");
1068
+ renameSync(tmp, path);
1069
+ return true;
1070
+ } catch {
1071
+ return false;
1072
+ }
1073
+ }
1074
+ /**
1075
+ * 清掉一条轨迹的当前载体声明(解绑)。
1076
+ * @returns 文件原本存在且已删除 ⇒ true
1077
+ */
1078
+ function clearBindDeclaration(trajectoryDir) {
1079
+ try {
1080
+ const path = bindFilePath(trajectoryDir);
1081
+ if (!existsSync(path)) return false;
1082
+ renameSync(path, `${path}.del`);
1083
+ return true;
1084
+ } catch {
1085
+ return false;
1086
+ }
1087
+ }
1088
+ /**
1089
+ * 列出某 CCC 下**全部轨迹的当前载体声明**(`dirName → 声明`)。
1090
+ *
1091
+ * 这是**反向查询**(会话 → 轨迹 / 轨迹 → 会话)的唯一入口,取代旧的旁路表扫描。
1092
+ * 跳过 `_` / `.` 前缀目录(归档区与运行时区不是轨迹,判据同 `activeTrajectoryDirNames`)。
1093
+ *
1094
+ * 🔴 **2026-10-20(owner 裁决「死包含归档判断」配套):`_archived/` 必须**再扫一层**。**
1095
+ *
1096
+ * 修之前:归档目录整体被跳过 ⇒ **已归档轨迹的绑定声明对唯一性检查完全隐形**。
1097
+ * 后果:承载过它的 dsh 会话看起来"无归属",可以被 `use` 到另一条轨迹上 ——
1098
+ * 这正是 IMMUTABLE 1「一个会话终身只属于一条轨迹」要禁的事,而且是**静默**发生的。
1099
+ *
1100
+ * 为什么只扫归档区、不扫别的 `_` 目录:归档区里的**确实是轨迹**(只是不再活跃),
1101
+ * 而 `_轨迹约束-logs` / `_weixin-logs` / `_corpus` 是日志与数据区,它们里面不会有
1102
+ * `current.dsh_session_bind`(真有一个也只是垃圾,读到会被 `asDeclaration` 挡掉)。
1103
+ * ⇒ 只多付一次 `_archived` 的 readdir,不改变"活跃清单"的语义(那是 `readAllSessions` 的事)。
1104
+ *
1105
+ * @param sessionsRoot `<CCC>/AGENT_SESSIONS`
1106
+ * @returns 有交代的轨迹;**永不抛**(目录读不到 ⇒ 空表)
1107
+ */
1108
+ function listBindDeclarations(sessionsRoot) {
1109
+ const out = [];
1110
+ const collect = (dir, into) => {
1111
+ let names;
1112
+ try {
1113
+ names = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith("_") && !e.name.startsWith(".")).map((e) => e.name);
1114
+ } catch {
1115
+ return;
1116
+ }
1117
+ for (const dirName of names) {
1118
+ const decl = readBindDeclaration(join(dir, dirName));
1119
+ if (decl) into(dirName, decl);
1120
+ }
1121
+ };
1122
+ collect(sessionsRoot, (dirName, decl) => out.push({
1123
+ dirName,
1124
+ decl
1125
+ }));
1126
+ collect(join(sessionsRoot, ARCHIVED_DIR_NAME), (dirName, decl) => {
1127
+ if (!out.some((r) => r.dirName === dirName)) out.push({
1128
+ dirName,
1129
+ decl
1130
+ });
1131
+ });
1132
+ return out;
1133
+ }
1134
+ /**
1135
+ * **这条绑定还作数吗**(第三条不变量的机械形态)。
1136
+ *
1137
+ * 判据(owner 原话:「文件里写的 sessionId 可能已经不存在了,这个情况下就视同没绑定」):
1138
+ *
1139
+ * | 文件 | 会话存在? | 结论 |
1140
+ * |---|---|---|
1141
+ * | 无 | — | `absent` |
1142
+ * | 有 | 是 | **`usable`** |
1143
+ * | 有 | 否 | **`stale`** ⇒ **视同没绑定** |
1144
+ *
1145
+ * 🔴 **为什么"会话存在"必须由调用方给**:判断它要问宿主(`ctx.agents.get` /
1146
+ * `sessionController.resolveAgent` / 会话清单),而本模块是零依赖层。
1147
+ * ⇒ 这里只收一个 **`alive: (sessionId) => boolean`** 探针,把判断留在外面,本函数仍是纯逻辑、可单测。
1148
+ *
1149
+ * ⚠️ **`stale` 不删文件**:删除是写操作,而"视同没绑定"已经足够。
1150
+ * 留着过期文件反而有用 —— 它记录了"这条轨迹上一次由谁承载"(沿革不丢,同旧 `supersededAt` 的意图)。
1151
+ *
1152
+ * @param decl {@link readBindDeclaration} 的结果
1153
+ * @param alive 探针:该会话 id 此刻是否真实存在(**由调用方用宿主事实实现**)
1154
+ * @returns `absent` / `usable` / `stale`,以及(usable 时的)声明
1155
+ */
1156
+ function isBindUsable(decl, alive) {
1157
+ if (decl === null) return { state: "absent" };
1158
+ try {
1159
+ return alive(decl.sessionId) ? {
1160
+ state: "usable",
1161
+ decl
1162
+ } : {
1163
+ state: "stale",
1164
+ decl
1165
+ };
1166
+ } catch {
1167
+ return {
1168
+ state: "stale",
1169
+ decl
1170
+ };
1171
+ }
1172
+ }
1173
+ /**
1174
+ * 找出**已经占用**某个 dsh 会话 id 的那条轨迹(唯一性判据)。
1175
+ *
1176
+ * @param sessionsRoot `<CCC>/AGENT_SESSIONS`
1177
+ * @param sessionId 待检查的会话 id
1178
+ * @returns 占用它的轨迹目录名;无人占用 ⇒ `null`
1179
+ */
1180
+ function findTrajectoryHolding(sessionsRoot, sessionId) {
1181
+ for (const { dirName, decl } of listBindDeclarations(sessionsRoot)) if (decl.sessionId === sessionId) return dirName;
1182
+ return null;
1183
+ }
1184
+ /**
1185
+ * **认领**:把某条轨迹的当前载体设成 `sessionId`,并**当场执行两条不变量**。
1186
+ *
1187
+ * 这是全仓**唯一**允许改变绑定关系的写入口:`appendBound` 与迁移脚本都走这里。
1188
+ * 集中成一处的理由 = 不变量只能有一个实现,否则迟早出现"某条路径忘了检查"(本仓已多次踩)。
1189
+ *
1190
+ * ## 判据(任一不成立即**拒绝**,不写)
1191
+ *
1192
+ * 1. **载体唯一**:`sessionId` 已安家在**另一条**轨迹 ⇒ 拒绝(一个会话不能绑两条轨迹)。
1193
+ * ⚠️ 若它已安家在**本条**轨迹 ⇒ 幂等通过(重复 `use` 同一条轨迹是合法重入)。
1194
+ * 2. **轨迹唯一**:本条轨迹已由**另一个** `sessionId` 承载 ⇒ **拒绝**,除非 `takeover`。
1195
+ * `takeover` = 载体换代(`rebuild` / 崩溃后新建),由调用方**显式**声明;
1196
+ * 绝不默认允许 —— 静默换代正是"一条轨迹长出两个载体"的成因。
1197
+ *
1198
+ * 🔴 **2026-10-20(owner 裁决,S196 实测逼出):第 ② 条只在"旧载体还活着"时成立。**
1199
+ *
1200
+ * 修之前:`claimBinding` **完全不问存活**,只要文件里有别的 `sessionId` 就拒。
1201
+ * 而读侧(`listBoundSessionIds(…, alive)`)却按 IMMUTABLE 3 把过期声明当"没绑定"
1202
+ * ⇒ **同一条轨迹,两个函数给出相反的答案**。S196 就是撞上这一格:
1203
+ * 它的绑定文件指着 `session-52d680ae`(**已在宿主归档集里** = 死),
1204
+ * 而 `use` 被 `TRAJECTORY_OCCUPIED` 拒掉,回执还说"成功"(另有一条 D186 的静默问题)。
1205
+ *
1206
+ * ⇒ 判据(第三条不变量的**写侧形态**):**旧载体会死 ⇒ 本条轨迹算空槽,直接写**。
1207
+ * `alive` 不传时**沿用"文件在就算占用"**的旧语义(保守、向后兼容)——
1208
+ * ⚠️ 那是"读不到宿主事实"的降级,**不是**"它一定活着";
1209
+ * 生产调用点必须显式传 {@link hostAliveProbe}。
1210
+ *
1211
+ * @param sessionsRoot `<CCC>/AGENT_SESSIONS`
1212
+ * @param dirName 目标轨迹目录名
1213
+ * @param decl 要写入的声明
1214
+ * @param opts.takeover 允许顶掉本条轨迹现有的另一个载体
1215
+ * @param opts.alive 探针:**旧载体**是否还活着(缺省 ⇒ 一律当活 = 旧语义)
1216
+ * @returns `{ ok: true, replaced?, released? }` 或 `{ ok: false, code, detail }`(**不抛**,由调用方决定如何呈现)
1217
+ *
1218
+ * 🔴 **2026-10-20 追加 `replaced`(由一条 skiff 轨迹的实测逼出)**:`released` 只覆盖
1219
+ * 「这个会话此前安家在**另一条**轨迹」那一格(跨轨迹清理);而**同一轨迹换载体**时被顶掉的那个
1220
+ * 会话 id 此前**没有任何出口** ⇒ 调用方无法在回执里说出"你顶掉了谁"。
1221
+ * 静默换代正是"一条轨迹感觉有两个载体"的成因 ⇒ 顶掉谁必须能被读出来。
1222
+ */
1223
+ function claimBinding(sessionsRoot, dirName, decl, opts = {}) {
1224
+ const holder = findTrajectoryHolding(sessionsRoot, decl.sessionId);
1225
+ if (holder !== null && holder !== dirName) return {
1226
+ ok: false,
1227
+ code: "SESSION_TAKEN",
1228
+ detail: `会话 ${decl.sessionId} 已经承载轨迹「${holder}」,不能同时再承载「${dirName}」。一个会话只能属于一条认知轨迹 —— 要给「${dirName}」开一个载体,请新建一条会话。`
1229
+ };
1230
+ const cur = readBindDeclaration(join(sessionsRoot, dirName));
1231
+ if (cur !== null && cur.sessionId !== decl.sessionId && opts.takeover !== true) {
1232
+ if (opts.alive === void 0 ? true : opts.alive(cur.sessionId)) return {
1233
+ ok: false,
1234
+ code: "TRAJECTORY_OCCUPIED",
1235
+ detail: `轨迹「${dirName}」当前已由会话 ${cur.sessionId} 承载(${cur.action},${new Date(cur.at).toISOString()})。如需换代(rebuild / 重建载体),调用方必须显式声明 takeover —— 静默换代会让一条轨迹出现两个载体。`
1236
+ };
1237
+ }
1238
+ let released;
1239
+ if (holder !== null && holder !== dirName) {
1240
+ if (clearBindDeclaration(join(sessionsRoot, holder))) released = holder;
1241
+ }
1242
+ if (!writeBindDeclaration(join(sessionsRoot, dirName), decl)) return {
1243
+ ok: false,
1244
+ code: "WRITE_FAILED",
1245
+ detail: `写 ${BIND_FILENAME} 失败:${join(sessionsRoot, dirName)}`
1246
+ };
1247
+ const replaced = cur !== null && cur.sessionId !== decl.sessionId ? cur.sessionId : void 0;
1248
+ return {
1249
+ ok: true,
1250
+ ...replaced === void 0 ? {} : { replaced },
1251
+ ...released === void 0 ? {} : { released }
1252
+ };
1253
+ }
1254
+ //#endregion
933
1255
  //#region src/trajectory/policy/window.ts
934
1256
  /** sessionId → 绑定的轨迹(**进程内**;跨重启的权威来源是 `trajectory/bound.ts` 的持久绑定) */
935
1257
  const trajectorySessions = /* @__PURE__ */ new Map();
@@ -992,6 +1314,7 @@ var profile_exports = /* @__PURE__ */ __exportAll({
992
1314
  readTrajectoryConstraintAt: () => readTrajectoryConstraintAt,
993
1315
  readTrajectoryConstraintByCode: () => readTrajectoryConstraintByCode,
994
1316
  replacesDefaultIdentity: () => replacesDefaultIdentity,
1317
+ trajectoryCodeForSession: () => trajectoryCodeForSession,
995
1318
  trajectoryConstraintForSession: () => trajectoryConstraintForSession
996
1319
  });
997
1320
  /**
@@ -1181,6 +1504,41 @@ function registeredDirName(sessionId) {
1181
1504
  return trajectoryDirNameForSession(sessionId);
1182
1505
  }
1183
1506
  /**
1507
+ * **会话 → 认知轨迹编码**(供工具可见性这类"只有 sessionId、没有 agent 对象"的调用方用)。
1508
+ *
1509
+ * ## 为什么需要它(而不是复用 `trajectoryConstraintForSession`)
1510
+ *
1511
+ * 那条路的判据是"**绑定的轨迹有 `trajectory-constraint.json`**"——它回答的是
1512
+ * 「这条会话是不是一条**约束会话**」。而工具可见性问的是另一个问题:
1513
+ * 「这条会话属于**哪条**轨迹」。前者对**普通轨迹**恒返回 `null`(它们没有约束文件),
1514
+ * ⇒ 拿它去判 IM 接线,会把所有普通轨迹一律判成"无归属"。
1515
+ *
1516
+ * ## 两个来源并取(与 `trajectoryConstraintForSession` 同一条纪律)
1517
+ *
1518
+ * ① **内存注册表**(`window.ts`)——覆盖"刚 `use`、绑定还没落盘"的窗口期;
1519
+ * ② **持久绑定**(`listBindDeclarations` 扫 `current.dsh_session_bind`)——跨重启成立。
1520
+ *
1521
+ * ⇒ 只取 ① 会在重启后全判成"没归属"(工具**静默消失**);
1522
+ * 只取 ② 会在建会话的第一轮判成"没归属"(同样是静默)。
1523
+ * 🔴 **这条纪律不是新发明的**:`trajectoryConstraintForSession` 的注释里已经付过一次代价。
1524
+ *
1525
+ * @param root CCC 根
1526
+ * @param sessionId dsh 会话 id
1527
+ * @returns 轨迹编码(`S###` 或自定义码);解析不到 ⇒ `null`
1528
+ */
1529
+ function trajectoryCodeForSession(root, sessionId) {
1530
+ if (typeof sessionId !== "string" || sessionId === "") return null;
1531
+ const fromMemory = registeredDirName(sessionId);
1532
+ if (fromMemory !== null && fromMemory !== "") return parseTrajectoryCode(fromMemory) ?? fromMemory;
1533
+ try {
1534
+ for (const { dirName, decl } of listBindDeclarations(sessionsRoot(root))) {
1535
+ if (decl.sessionId !== sessionId) continue;
1536
+ return parseTrajectoryCode(dirName) ?? dirName;
1537
+ }
1538
+ } catch {}
1539
+ return null;
1540
+ }
1541
+ /**
1184
1542
  * 该 dsh 会话**是不是** 轨迹约束会话(布尔形态的 `trajectoryConstraintForSession`)。
1185
1543
  *
1186
1544
  * 存在的理由:调用点很多只要一个"是/不是"(seams 的旁路判定),
@@ -1224,4 +1582,4 @@ function replacesDefaultIdentity(profile) {
1224
1582
  return hasFile || hasInline;
1225
1583
  }
1226
1584
  //#endregion
1227
- export { resolveSessionByTitle as A, readTrajectoryFile as B, listActiveTrajectories as C, parseTrajectoryCode as D, parseSessionContextFromEvents as E, summarize as F, localFileStamp as H, trajectoryDescOf as I, useSession as L, sessionsRoot as M, setActiveSessionInfo as N, readActiveSessionMd as O, showSession as P, TRAJECTORY_FILENAME as R, getLastActiveSessionInfo as S, namingTitleFor as T, localHuman as U, isoLocal as V, localIdStamp as W, clearActiveSessionInfo as _, profile_exports as a, findSession as b, replacesDefaultIdentity as c, trajectoryDirNameForSession as d, unregisterTrajectorySession as f, archiveSession as g, UID_FILENAME as h, listTrajectoryConstraints as i, sessionEvents as j, readTrajectoryUid as k, trajectoryConstraintForSession as l, DEFAULT_SESSION_SCOPE as m, findDirNameByCode as n, readTrajectoryConstraintAt as o, COGNITIVE_TRAJECTORY_ACTIONS as p, isTrajectoryConstrained as r, readTrajectoryConstraintByCode as s, TRAJECTORY_CONSTRAINT_FILENAME as t, registerTrajectorySession as u, createSession as v, listSessions as w, getActiveSessionInfo as x, extractSessionMdPathFromText as y, TRAJECTORY_LEGACY_FILENAMES as z };
1585
+ export { namingTitleFor as A, summarize as B, createSession as C, getLastActiveSessionInfo as D, getActiveSessionInfo as E, resolveSessionByTitle as F, readTrajectoryFile as G, useSession as H, sessionEvents as I, localHuman as J, isoLocal as K, sessionsRoot as L, parseTrajectoryCode as M, readActiveSessionMd as N, listActiveTrajectories as O, readTrajectoryUid as P, setActiveSessionInfo as R, clearActiveSessionInfo as S, findSession as T, TRAJECTORY_FILENAME as U, trajectoryDescOf as V, TRAJECTORY_LEGACY_FILENAMES as W, localIdStamp as Y, readBindDeclaration as _, profile_exports as a, UID_FILENAME as b, replacesDefaultIdentity as c, registerTrajectorySession as d, trajectoryDirNameForSession as f, listBindDeclarations as g, isBindUsable as h, listTrajectoryConstraints as i, parseSessionContextFromEvents as j, listSessions as k, trajectoryCodeForSession as l, claimBinding as m, findDirNameByCode as n, readTrajectoryConstraintAt as o, unregisterTrajectorySession as p, localFileStamp as q, isTrajectoryConstrained as r, readTrajectoryConstraintByCode as s, TRAJECTORY_CONSTRAINT_FILENAME as t, trajectoryConstraintForSession as u, COGNITIVE_TRAJECTORY_ACTIONS as v, extractSessionMdPathFromText as w, archiveSession as x, DEFAULT_SESSION_SCOPE as y, showSession as z };
@@ -1,8 +1,8 @@
1
- import { n as agentCwdFor, r as cccRootForCwd, s as hostAgents } from "./ccc-roots-CvgsUnFW.js";
1
+ import { n as agentCwdFor, r as cccRootForCwd, s as hostAgents } from "./ccc-roots-WfktvbN8.js";
2
2
  import { g as readUtf8, o as isSafeModeOn, p as readCognitiveTrajectorySkills, u as readBlacklist } from "./ccc-CyYJL4F2.js";
3
- import { M as sessionsRoot, O as readActiveSessionMd, V as isoLocal, d as trajectoryDirNameForSession, f as unregisterTrajectorySession$1, l as trajectoryConstraintForSession, m as DEFAULT_SESSION_SCOPE, o as readTrajectoryConstraintAt, u as registerTrajectorySession$1 } from "./profile-DGt_e-Nx.js";
3
+ import { K as isoLocal, L as sessionsRoot, N as readActiveSessionMd, d as registerTrajectorySession$1, f as trajectoryDirNameForSession, o as readTrajectoryConstraintAt, p as unregisterTrajectorySession$1, u as trajectoryConstraintForSession, y as DEFAULT_SESSION_SCOPE } from "./profile-zxakCNXy.js";
4
4
  import { a as readGitTrack, i as localstorePath } from "./service-DDmHSglV.js";
5
- import { d as constraintCognitiveTrajectory, m as profileMsmWhitelist, n as PLUGIN_SOURCE, o as readLastBound, p as createProfilePromptReader, u as buildTrajectoryBasePrompt, v as ACC_VERSION } from "./message-source-Cq4PsuES.js";
5
+ import { d as constraintCognitiveTrajectory, m as profileMsmWhitelist, n as PLUGIN_SOURCE, o as readLastBound, p as createProfilePromptReader, u as buildTrajectoryBasePrompt, v as ACC_VERSION } from "./message-source-BAMwXu6i.js";
6
6
  import { basename, dirname, join, resolve } from "node:path";
7
7
  import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
8
8
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
@@ -1,4 +1,4 @@
1
- import { f as hostSettings } from "./ccc-roots-CvgsUnFW.js";
1
+ import { f as hostSettings } from "./ccc-roots-WfktvbN8.js";
2
2
  //#region src/surface/settings.ts
3
3
  /**
4
4
  * 设置面板命名空间 = **profile 条目 id**(v0.1.7 起;与旧 `settings.yaml` 段名逐字相同)。
@@ -161,6 +161,30 @@ export declare function findDirNameByCode(root: string, code: string): string |
161
161
  * @returns 约束面;不是 轨迹约束会话 → `null`
162
162
  */
163
163
  export declare function trajectoryConstraintForSession(root: string, sessionId: string | undefined, boundDirName?: string | null): TrajectoryConstraint | null;
164
+ /**
165
+ * **会话 → 认知轨迹编码**(供工具可见性这类"只有 sessionId、没有 agent 对象"的调用方用)。
166
+ *
167
+ * ## 为什么需要它(而不是复用 `trajectoryConstraintForSession`)
168
+ *
169
+ * 那条路的判据是"**绑定的轨迹有 `trajectory-constraint.json`**"——它回答的是
170
+ * 「这条会话是不是一条**约束会话**」。而工具可见性问的是另一个问题:
171
+ * 「这条会话属于**哪条**轨迹」。前者对**普通轨迹**恒返回 `null`(它们没有约束文件),
172
+ * ⇒ 拿它去判 IM 接线,会把所有普通轨迹一律判成"无归属"。
173
+ *
174
+ * ## 两个来源并取(与 `trajectoryConstraintForSession` 同一条纪律)
175
+ *
176
+ * ① **内存注册表**(`window.ts`)——覆盖"刚 `use`、绑定还没落盘"的窗口期;
177
+ * ② **持久绑定**(`listBindDeclarations` 扫 `current.dsh_session_bind`)——跨重启成立。
178
+ *
179
+ * ⇒ 只取 ① 会在重启后全判成"没归属"(工具**静默消失**);
180
+ * 只取 ② 会在建会话的第一轮判成"没归属"(同样是静默)。
181
+ * 🔴 **这条纪律不是新发明的**:`trajectoryConstraintForSession` 的注释里已经付过一次代价。
182
+ *
183
+ * @param root CCC 根
184
+ * @param sessionId dsh 会话 id
185
+ * @returns 轨迹编码(`S###` 或自定义码);解析不到 ⇒ `null`
186
+ */
187
+ export declare function trajectoryCodeForSession(root: string, sessionId: string | undefined): string | null;
164
188
  /**
165
189
  * 该 dsh 会话**是不是** 轨迹约束会话(布尔形态的 `trajectoryConstraintForSession`)。
166
190
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.87.7",
3
+ "version": "1.88.0",
4
4
  "description": "宁静号 ACC harness(Native Cordis 插件)——给 DeepSeek Harness 装一个「AI 工作区」:14 个工具(container_fs/cognitive_trajectory/dashboard/container_git/msm/praxis/ontology/serenity-skill-creator/cheap-worker/localstore/container_admin;另三个 im-bridge/acc-diag/diagram 按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 认知轨迹日志与原地重建 + 网页登录入口/IM 桥/认知轨迹唤醒注册表。轨迹声明 = 轨迹目录下的 trajectory.json(约束面 + 旧标识 uid + CRO 机器绑定 cro.host;旧名 trajectory-constraint.json / skiff.json 仍在读取族里);轨迹提示词 = 轨迹目录下的 PROMPT.md(一条轨迹「是谁、按什么章程工作」,所有轨迹通用,追加在容器层与质量层之后,不替换它们)。适配 DSH 0.1.7-rc.1 / 0.2.0-rc.1 / 0.2.1-rc.1(三档)。",
5
5
  "license": "MIT",
6
6
  "repository": {