@shgroup/dsh-serenity-hooks 1.80.0 → 1.82.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.80.0",
3
+ "version": "1.82.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-DmWw2M0q.js").then((n) => n.i);
230
+ const { listTrajectoryConstraints } = await import("./profile-CiRWh2a2.js").then((n) => n.i);
231
231
  rolesOf = (root) => {
232
232
  try {
233
233
  return listTrajectoryConstraints(root).map((p) => p.code);
package/lib/im/guard.d.ts CHANGED
@@ -51,13 +51,35 @@ export declare function noteManualOutputSession(sessionId: string, session: Manu
51
51
  export declare function forgetManualOutputSession(sessionId: string): void;
52
52
  /** 是否手动模式会话(测试/诊断用) */
53
53
  export declare function isManualOutputSession(sessionId: string): boolean;
54
- /** 本轮是否已成功发送(测试/诊断用;桥在每轮结束时读它决定是否兜底) */
55
- export declare function hasSentThisTurn(sessionId: string): boolean;
56
54
  /**
57
- * 桥:**每轮开始时**清空本会话的"已发送"标记(turn 边界由桥掌握——v1.30.17)。
58
- * 闸门侧不再于 turn-stopping 结算清空,否则桥读不到本轮结果、无法兜底。
55
+ * 本轮是否已成功发送。
56
+ *
57
+ * @param turn 期望的轮号(`agent/turn-stopping` 的 `payload.turn`)。
58
+ * - **省略** ⇒ 旧语义:"这个会话发过就算"(测试与桥的兜底判定用)。
59
+ * - **给了** ⇒ 按轮比:只有"**这一轮**发的"才算。
60
+ * - **任一侧取不到轮号**(记录是 `null`,或 `turn` 传了 `null`)⇒ **偏向"发过"**。
61
+ * 🔴 方向是刻意的:误判"没发"的代价 = 打回 + 重复回答(用户看到两轮,本缺陷本尊);
62
+ * 误判"发过"的代价 = 少打回一次(用户可能真没收到一条)。前者已经发生过、后者尚未。
63
+ */
64
+ export declare function hasSentThisTurn(sessionId: string, turn?: number | null): boolean;
65
+ /**
66
+ * 🔴 **桥不再调用本函数**(2026-10-21)。
67
+ *
68
+ * 旧语义 `clearSentThisTurn(sessionId)` = "来了新消息就当这个会话没发过",
69
+ * 它**连正在跑那一轮的证据一起抹掉** —— 这正是"同一条消息被处理两轮"的根因:
70
+ * `12:08:25 发送成功 → 12:08:56 新入站(抹) → 12:09:19 那一轮 turn-stopping 判"没发" ⇒ 打回`。
71
+ *
72
+ * ## 现在的分工(判据只有一处:轮号)
73
+ *
74
+ * - **置位**:`tools/post-execute` 发送成功 ⇒ 记下**当前轮号**(`currentTurnOf`)。
75
+ * - **判定**:`agent/turn-stopping` 拿 `payload.turn` 与记录比对(`hasSentThisTurn`)。
76
+ * - **陈旧**:靠"轮号不等"自然淘汰 —— 上一轮发的记录,在下一轮 stopping 时天然不匹配。
77
+ * ⇒ **不再需要任何"清空"动作**,也就不存在"清错人的记录"这件事。
78
+ *
79
+ * 本函数只留给**测试与显式重置**(如切换手动/自动模式)。`turn` 给定时按轮清,
80
+ * 是给诊断用的窄口子,生产路径不调用。
59
81
  */
60
- export declare function clearSentThisTurn(sessionId: string): void;
82
+ export declare function clearSentThisTurn(sessionId: string, turn?: number | null): void;
61
83
  /** 闸门是否装配成功(未装配 → 桥不做兜底:宁可静默也不冒重复发送的风险) */
62
84
  export declare function isWeixinOutputGuardActive(): boolean;
63
85
  /**
@@ -124,6 +124,11 @@ export declare function resolveInboundTarget(ctx: Context, input: {
124
124
  trajectory: string;
125
125
  } | null;
126
126
  }): Promise<ResolvedInboundTarget | null>;
127
+ declare function acquireSessionLock(key: string): Promise<() => void>;
128
+ /** 测试/诊断用:当前有几条会话仍持有锁位(0 = 全部释放) */
129
+ export declare function __sessionLockCountForTest(): number;
130
+ /** 测试专用:直接拿真锁(不复制实现 —— 实现换形状时本导出会先炸,不会静默失效) */
131
+ export declare const __acquireSessionLockForTest: typeof acquireSessionLock;
127
132
  /**
128
133
  * **一条入站消息的主流程**(通道中立)。
129
134
  *
@@ -165,3 +170,4 @@ export declare function manualOutputFallbackNeeded(input: {
165
170
  sentThisTurn: boolean;
166
171
  answer: string;
167
172
  }): boolean;
173
+ export {};
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-DYJvlQhc.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-4LwGLCXa.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 sessionEvents, B as isoLocal, C as listSessions, E as parseTrajectoryCode, F as trajectoryDescOf, H as localHuman, I as useSession, L as TRAJECTORY_FILENAME, M as setActiveSessionInfo, N as showSession, O as readTrajectoryUid, P as summarize, R as TRAJECTORY_LEGACY_FILENAMES, S as listActiveTrajectories, T as parseSessionContextFromEvents, U as localIdStamp, V as localFileStamp, _ as createSession, a as readTrajectoryConstraintAt, b as getActiveSessionInfo, c as trajectoryConstraintForSession, f as COGNITIVE_TRAJECTORY_ACTIONS, g as clearActiveSessionInfo, h as archiveSession, j as sessionsRoot, k as resolveSessionByTitle, m as UID_FILENAME, n as isTrajectoryConstrained, o as readTrajectoryConstraintByCode, r as listTrajectoryConstraints, s as replacesDefaultIdentity, t as TRAJECTORY_CONSTRAINT_FILENAME, v as extractSessionMdPathFromText, w as namingTitleFor, y as findSession, z as readTrajectoryFile } from "./profile-DmWw2M0q.js";
4
+ import { A as sessionEvents, B as isoLocal, C as listSessions, E as parseTrajectoryCode, F as trajectoryDescOf, H as localHuman, I as useSession, L as TRAJECTORY_FILENAME, M as setActiveSessionInfo, N as showSession, O as readTrajectoryUid, P as summarize, R as TRAJECTORY_LEGACY_FILENAMES, S as listActiveTrajectories, T as parseSessionContextFromEvents, U as localIdStamp, V as localFileStamp, _ as createSession, a as readTrajectoryConstraintAt, b as getActiveSessionInfo, c as trajectoryConstraintForSession, f as COGNITIVE_TRAJECTORY_ACTIONS, g as clearActiveSessionInfo, h as archiveSession, j as sessionsRoot, k as resolveSessionByTitle, m as UID_FILENAME, n as isTrajectoryConstrained, o as readTrajectoryConstraintByCode, r as listTrajectoryConstraints, s as replacesDefaultIdentity, t as TRAJECTORY_CONSTRAINT_FILENAME, v as extractSessionMdPathFromText, w as namingTitleFor, y as findSession, z as readTrajectoryFile } from "./profile-CiRWh2a2.js";
5
5
  import { n as checkLocalstoreGitCompliance, o as readStore, s as runLocalStore, t as LOCALSTORE_SCOPES } from "./service-DDmHSglV.js";
6
- import { _ as ACC_VERSION, a as listBoundSessionIds, d as constraintCognitiveTrajectory, f as constraintSystemPromptSource, g as resolveConstraintSystemPrompt, 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 findPackageRoot } from "./message-source-BYO-FyFP.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-sV-kc1gQ.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-CfqdGIi2.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-BF4C3hJL.js";
8
8
  import { n as hasSessionLogById, r as sessionsRootDir } from "./cleanup-C-92qnDs.js";
9
- import { n as readSimpleSettings, r as registerSettingsSection } from "./settings-DdYLVK3O.js";
9
+ import { n as readSimpleSettings, r as registerSettingsSection } from "./settings-Dj4_ppTC.js";
10
10
  import { REGISTERED_TOOLS } from "./invariant.js";
11
11
  import { a as readFeishuCredential, d as weixinSessionIdForTrajectory, i as matchWeixinRoute, l as sanitizeFileName$1, n as extractWeixinText, o as readWeixinCredential, r as hasVoiceItem, s as readWeixinSettings, t as extractWeixinMedia, u as weixinInboundDir } from "./route-Dr0SFD8A.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-BMk2w2KY.js";
@@ -10727,6 +10727,7 @@ becomes a 轨迹约束; delete it and it goes back to being an ordinary trajecto
10727
10727
  "model": "provider/model", // optional; **不写 = 用会话当前模型**(不回退 cheap-worker;每轮生效)
10728
10728
  "tools": ["read","grep","msm",...], // optional; **不写 = 不收窄**。显式 [] = 全隐藏(除 skill)
10729
10729
  "msms": ["msm-a","msm-b"], // optional; **不写 = 不收窄**。register/deregister 恒拒
10730
+ "writes": ["AGENT_SESSIONS/<我的目录>/"], // optional; **不写 = 不收窄**。显式 [] = 一个写都落不下
10730
10731
  "cognitiveTrajectory": { "session": true, "keeper": true, "rebuild": true }, // 缺省 TRUE(见下)
10731
10732
  "resident": { "serves": "..." }, // optional; 对外用途自述(见 trajectory-self 章)
10732
10733
  "cro": { "host": "<machine name>" } // optional: which machine may run this trajectory's CRO program
@@ -10752,6 +10753,20 @@ becomes a 轨迹约束; delete it and it goes back to being an ordinary trajecto
10752
10753
  🔴 判据是**字段在不在**,不是**数组空不空**(D181)。旧版把两者混成"空 = 全拒",
10753
10754
  于是「只想声明常驻」的人被静默锁死 —— 那是本仓实测踩过的坑(D179(b))。
10754
10755
 
10756
+ ── \`writes\`: the THIRD whitelist — the PATH dimension(owner 2026-10-20 令)──
10757
+ \`tools\` 管的是**工具名**,管不了"这个工具能写哪儿":给了 \`write\` 之后,路径守卫只拦
10758
+ 「逃出 CCC」与黑名单,**拦不住它去写别人的轨迹或 docs/**。\`writes\` 补的正是这一维。
10759
+
10760
+ - **不写** ⇒ 不收窄(写权限 = 路径守卫那几条,与之前完全一致)
10761
+ - \`"writes": []\` ⇒ **一个写都落不下**(显式意图;只影响写,不影响读)
10762
+ - \`"writes": ["AGENT_SESSIONS/<我的目录>/", "_tmp/"]\` ⇒ 写类工具的**目标路径**必须落在这些前缀内
10763
+ - 条目可以是**相对 CCC 根**的路径,也可以是绝对路径;判定按"目标在这个目录之内(含自身)"
10764
+ - 只管**写类**工具(\`write\` / \`edit\` / \`container_fs\` 的写动作 / \`append\` / \`touch\`),**读不受影响**
10765
+ - 越界拒绝时**会指名允许的前缀与当前目标**(D186:不许不出声;出声必须指对路)
10766
+
10767
+ ⇒ 典型用法(两条 IM 轨迹):给 \`write\`/\`edit\` 写自己的身体,同时把写面收窄到自己那一格:
10768
+ \`"tools": ["read","write","edit",...], "writes": ["AGENT_SESSIONS/<code 目录>/"]\`
10769
+
10755
10770
  ── cognitiveTrajectory: why the defaults are TRUE ──
10756
10771
  The workspace of a 轨迹约束 is its own SESSION.md. Setting these to false would cut it off from
10757
10772
  its own body, so \`session\`/\`keeper\`/\`rebuild\` all default to true. Set false explicitly only
@@ -10792,6 +10807,11 @@ function validateTrajectoryConstraint(root) {
10792
10807
  }
10793
10808
  })())) issues.push(`trajectory "${p.code}": no trajectory prompt (add ${TRAJECTORY_PROMPT_FILENAME} in the trajectory dir — recommended — or the legacy systemPromptFile/systemPrompt)`);
10794
10809
  for (const m of p.msms ?? []) if (!registered.has(m)) issues.push(`trajectory "${p.code}": msms "${m}" is not registered in mech-registry.json`);
10810
+ for (const w of p.writes ?? []) try {
10811
+ resolveInside(root, w);
10812
+ } catch (err) {
10813
+ issues.push(`trajectory "${p.code}": writes "${w}" is outside the CCC root (${String(err?.message ?? err)}) — a write prefix outside the container can never match; use a path relative to the CCC root`);
10814
+ }
10795
10815
  if (p.model && allowedModels && !allowedModels.has(p.model)) issues.push(`trajectory "${p.code}": model "${p.model}" not in cheap-worker.models (${[...allowedModels].join(", ") || "(none)"})`);
10796
10816
  }
10797
10817
  return {
@@ -10824,6 +10844,7 @@ function listTrajectoryConstraintSummary(root) {
10824
10844
  model: p.model ?? "(unchanged)",
10825
10845
  msms: wl(p.msms, "(unrestricted)"),
10826
10846
  tools: wl(p.tools, "(unrestricted)"),
10847
+ writes: wl(p.writes, "(unrestricted)"),
10827
10848
  ...p.resident !== void 0 ? { resident: p.resident.serves !== void 0 ? p.resident.serves : "(no serves)" } : {},
10828
10849
  cognitiveTrajectory: constraintCognitiveTrajectory(p),
10829
10850
  promptSource: constraintSystemPromptSource(p, hasTrajectoryPrompt(root, p.dirName)),
@@ -10955,6 +10976,7 @@ const TRAJECTORY_SELF_GUIDE = [
10955
10976
  | \`model\` | 用会话当前的模型(**不会**回退到 cheap-worker) | **每轮**(改文件下一轮即生效) |
10956
10977
  | \`tools\` | **不收窄**工具面 | 每轮 |
10957
10978
  | \`msms\` | **不收窄** MSM 面 | 每轮 |
10979
+ | \`writes\` | **不收窄**写路径(写权限 = 路径守卫那几条) | 每轮 |
10958
10980
  | \`resident\` | 不出现在常驻名单里(不参与"被找") | 每轮 |
10959
10981
  | \`cognitiveTrajectory\` | 三项纪律全参与(true) | 每轮 |
10960
10982
  | \`systemPromptFile\` / \`systemPrompt\` | 用 ACC 的通用身份(**不替换**) | 保存即生效 |
@@ -10980,7 +11002,7 @@ const TRAJECTORY_SELF_GUIDE = [
10980
11002
  章程生效了吗 改 PROMPT.md 后**在同一会话里**看下一轮的系统提示词段是否变化(保存即生效)
10981
11003
  注入模式生效了吗 改 trajectory.json 的 prompt 段后,**同一会话下一轮**:replace 下四块消失、
10982
11004
  我的章程紧跟身份块;⛔ 不需要重启(这两者都是每轮现读)
10983
- 我声明了什么 container_admin config list —— 逐轨迹列出 model/tools/msms 的**生效值**;
11005
+ 我声明了什么 container_admin config list —— 逐轨迹列出 model/tools/msms/writes 的**生效值**;
10984
11006
  没写的字段印 \`(unchanged)\` / \`(unrestricted)\`,不是伪装成某个默认值
10985
11007
  配置合法吗 container_admin config validate —— msms 均已注册 / model 在可用表里
10986
11008
  常驻被看见了吗 cognitive_trajectory action=resident —— 我出现在名单里,且 serves 可读
@@ -11565,6 +11587,14 @@ function decideGuard(input) {
11565
11587
  deny: "tool not allowed in this 轨迹约束 trajectory",
11566
11588
  kind: "deny"
11567
11589
  };
11590
+ const writeScopes = profileWriteWhitelist(profile);
11591
+ if (writeScopes !== null && pathArg !== void 0 && isWriteTool(toolName, action)) {
11592
+ const abs = resolve(root, pathArg);
11593
+ if (!writeScopes.some((scope) => pathInside(resolve(root, scope), abs))) return {
11594
+ deny: `${toolName}: write outside this trajectory's declared write scope (allowed: ${writeScopes.length > 0 ? writeScopes.join(" / ") : "(none — \"writes\" is an empty array)"}); target: ${pathArg} — add the prefix to this trajectory's trajectory.json "writes" if the write is intended`,
11595
+ kind: "deny"
11596
+ };
11597
+ }
11568
11598
  }
11569
11599
  if (safeModeOn && toolName === "bash") return {
11570
11600
  deny: `bash: no such tool`,
@@ -14118,7 +14148,7 @@ function registerStatusApi(ctx, opts = {}) {
14118
14148
  });
14119
14149
  }
14120
14150
  const { saveWeixinRoutes } = await import("./route-Dr0SFD8A.js").then((n) => n.c);
14121
- const { findDirNameByCode } = await import("./profile-DmWw2M0q.js").then((n) => n.i);
14151
+ const { findDirNameByCode } = await import("./profile-CiRWh2a2.js").then((n) => n.i);
14122
14152
  for (const r of routes) if (findDirNameByCode(root, r.trajectory) === null) {
14123
14153
  sendJson$2(res, 400, { error: `unknown trajectory: ${r.trajectory} (no such trajectory code in ${root})` });
14124
14154
  return;
@@ -18967,10 +18997,23 @@ function installWeixinAccountImpl(d) {
18967
18997
  }
18968
18998
  const manualSessions = /* @__PURE__ */ new Map();
18969
18999
  /**
18970
- * 本轮是否已成功发送(**由桥在每轮开始时清空** `clearSentThisTurn`;闸门只置位不清空——
18971
- * v1.30.17 起桥要在 `askTrajectory` 返回后读它决定是否兜底,故 turn-stopping 不再结算清空)。
19000
+ * 「已发送」记账 —— 值是**发送发生时的轮号**(`null` = 取不到轮号,退化为旧语义)。
19001
+ *
19002
+ * ## 🔴 为什么必须是「轮号」而不是一个布尔(2026-10-21 实测缺陷)
19003
+ *
19004
+ * 旧形态是 `Set<sessionId>`:桥在**每条入站一到达**就 `clearSentThisTurn()`(`inbound-event.ts`),
19005
+ * 而闸门只在 `tools/post-execute` 置位、`turn-stopping` 只读。
19006
+ * ⇒ **一个还在跑的轮,它"我已经发了"的证据会被后到的那条入站抹掉。**
19007
+ *
19008
+ * 现场时刻线(真机,12:08~12:10):`12:08:25` 发送成功 → `12:08:56` 新入站(抹)→
19009
+ * `12:09:19` 那一轮的 `turn-stopping` 读空 ⇒ 判"没发" ⇒ 打回 ⇒ 模型重答。
19010
+ * 用户看到的就是"同一条消息被处理了两轮"。
19011
+ *
19012
+ * ⇒ 记**轮号**之后:抹除操作可以问"我要抹的这一发,是不是**当前这一轮**发的";
19013
+ * `turn-stopping` 也可以问"这一发是不是**我这一轮**发的"。两处都从"是不是同一轮"出发,
19014
+ * 而不是从"这个会话有没有发过"出发。
18972
19015
  */
18973
- const sentThisTurn = /* @__PURE__ */ new Set();
19016
+ const sentTurn = /* @__PURE__ */ new Map();
18974
19017
  /** 连续打回计数(成功发送即清零) */
18975
19018
  const rebukeCounts = /* @__PURE__ */ new Map();
18976
19019
  /** 闸门是否装配成功(v1.30.17:桥据此判断能否信任"未发送"这一判定——未装配时不兜底,防重复发送) */
@@ -18983,19 +19026,25 @@ function noteManualOutputSession(sessionId, session) {
18983
19026
  /** 桥:非手动模式 / 会话失效 → 注销(闸门随之失效,零干预) */
18984
19027
  function forgetManualOutputSession(sessionId) {
18985
19028
  manualSessions.delete(sessionId);
18986
- sentThisTurn.delete(sessionId);
19029
+ sentTurn.delete(sessionId);
18987
19030
  rebukeCounts.delete(sessionId);
18988
19031
  }
18989
- /** 本轮是否已成功发送(测试/诊断用;桥在每轮结束时读它决定是否兜底) */
18990
- function hasSentThisTurn(sessionId) {
18991
- return sentThisTurn.has(sessionId);
18992
- }
18993
19032
  /**
18994
- * 桥:**每轮开始时**清空本会话的"已发送"标记(turn 边界由桥掌握——v1.30.17)。
18995
- * 闸门侧不再于 turn-stopping 结算清空,否则桥读不到本轮结果、无法兜底。
19033
+ * 本轮是否已成功发送。
19034
+ *
19035
+ * @param turn 期望的轮号(`agent/turn-stopping` 的 `payload.turn`)。
19036
+ * - **省略** ⇒ 旧语义:"这个会话发过就算"(测试与桥的兜底判定用)。
19037
+ * - **给了** ⇒ 按轮比:只有"**这一轮**发的"才算。
19038
+ * - **任一侧取不到轮号**(记录是 `null`,或 `turn` 传了 `null`)⇒ **偏向"发过"**。
19039
+ * 🔴 方向是刻意的:误判"没发"的代价 = 打回 + 重复回答(用户看到两轮,本缺陷本尊);
19040
+ * 误判"发过"的代价 = 少打回一次(用户可能真没收到一条)。前者已经发生过、后者尚未。
18996
19041
  */
18997
- function clearSentThisTurn(sessionId) {
18998
- sentThisTurn.delete(sessionId);
19042
+ function hasSentThisTurn(sessionId, turn) {
19043
+ if (!sentTurn.has(sessionId)) return false;
19044
+ if (turn === void 0) return true;
19045
+ const recorded = sentTurn.get(sessionId) ?? null;
19046
+ if (recorded === null || turn === null) return true;
19047
+ return recorded === turn;
18999
19048
  }
19000
19049
  /** 闸门是否装配成功(未装配 → 桥不做兜底:宁可静默也不冒重复发送的风险) */
19001
19050
  function isWeixinOutputGuardActive() {
@@ -19045,6 +19094,29 @@ function buildManualOutputRebuke(session) {
19045
19094
  ].join("\n");
19046
19095
  }
19047
19096
  /**
19097
+ * 从 `tools/post-execute` / 会话事件里取**当前轮号**(取不到 ⇒ `null`)。
19098
+ *
19099
+ * 为什么要费这个劲:修"同一条消息处理两轮"的判据是「这一发是不是**我这一轮**发的」,
19100
+ * 而 `tools/post-execute` 的 `exec` 上**不一定**带 turn(宿主形状随版本变)。
19101
+ * ⇒ 三级兜底:① `exec.turn` ② `agent.session` 的最后一个 `turn/start` ③ `null`(退化为旧语义)。
19102
+ * ⛔ `null` 时**绝不**当成"发过了/没发过"的强判据 —— 见 `hasSentThisTurn` 的降级说明。
19103
+ */
19104
+ function currentTurnOf(exec, agent) {
19105
+ const direct = exec?.turn;
19106
+ if (typeof direct === "number" && Number.isFinite(direct)) return direct;
19107
+ const events = agent?.session;
19108
+ const list = typeof events?.snapshotEvents === "function" ? events.snapshotEvents() : events?.events;
19109
+ if (!Array.isArray(list)) return null;
19110
+ for (let i = list.length - 1; i >= 0; i--) {
19111
+ const e = list[i];
19112
+ if (e?.type === "turn/start") {
19113
+ const t = e.data?.turn;
19114
+ return typeof t === "number" && Number.isFinite(t) ? t : null;
19115
+ }
19116
+ }
19117
+ return null;
19118
+ }
19119
+ /**
19048
19120
  * 装配闸门(index.ts apply 调用)。
19049
19121
  * 只对**已登记的手动模式会话**生效——其他会话(主舱/其他 轨迹约束会话)零干预。
19050
19122
  */
@@ -19056,7 +19128,7 @@ function registerWeixinOutputGuard(ctx) {
19056
19128
  const sessionId = sessionIdOf$1(exec?.agent);
19057
19129
  if (sessionId && manualSessions.has(sessionId)) {
19058
19130
  if (isSuccessfulImSend(exec, result, manualSessions.get(sessionId)?.channel ?? "weixin")) {
19059
- sentThisTurn.add(sessionId);
19131
+ sentTurn.set(sessionId, currentTurnOf(exec, exec.agent));
19060
19132
  rebukeCounts.delete(sessionId);
19061
19133
  }
19062
19134
  }
@@ -19075,7 +19147,7 @@ function registerWeixinOutputGuard(ctx) {
19075
19147
  if (!sessionId) return;
19076
19148
  const session = manualSessions.get(sessionId);
19077
19149
  if (!session) return;
19078
- if (sentThisTurn.has(sessionId)) return;
19150
+ if (hasSentThisTurn(sessionId, payload?.turn ?? null)) return;
19079
19151
  const count = (rebukeCounts.get(sessionId) ?? 0) + 1;
19080
19152
  if (count > 2) {
19081
19153
  rebukeCounts.delete(sessionId);
@@ -19115,7 +19187,7 @@ const MEDIA_MAX_BYTES = 20971520;
19115
19187
  */
19116
19188
  async function resolveCccRootByAlias(ctx, alias) {
19117
19189
  try {
19118
- const { listCccs } = await import("./ccc-roots-DYJvlQhc.js").then((n) => n.a);
19190
+ const { listCccs } = await import("./ccc-roots-4LwGLCXa.js").then((n) => n.a);
19119
19191
  const entries = await listCccs(ctx);
19120
19192
  const byName = entries.find((e) => e.name === alias);
19121
19193
  if (byName) return byName.root;
@@ -19183,7 +19255,7 @@ async function resolveInboundTarget(ctx, input) {
19183
19255
  });
19184
19256
  return null;
19185
19257
  }
19186
- const { findDirNameByCode } = await import("./profile-DmWw2M0q.js").then((n) => n.i);
19258
+ const { findDirNameByCode } = await import("./profile-CiRWh2a2.js").then((n) => n.i);
19187
19259
  if (findDirNameByCode(root, target.role) === null) {
19188
19260
  const detail = `目标 CCC "${target.ccc}" 内没有编码为 "${target.role}" 的认知轨迹`;
19189
19261
  console.error(`[serenity-hooks] ✗ human-channel: ${detail}(account=${input.account} user=${input.user})—— 该消息**未投递**,请改订阅表里的目标编码`);
@@ -19205,6 +19277,41 @@ async function resolveInboundTarget(ctx, input) {
19205
19277
  };
19206
19278
  }
19207
19279
  /**
19280
+ * 同会话入站**串行化**(2026-10-21,修"同一条消息被处理两轮"的第二个缺陷)。
19281
+ *
19282
+ * ## 为什么必须有锁(R↓,代价换来的)
19283
+ *
19284
+ * `askTrajectory` 的成功判据是"`assistant/message` **条数增长**"。两条入站并发时,
19285
+ * 后一条的快照包含前一条尚未落盘的产出 ⇒ **前一轮的产出会满足后一条的成功判据**
19286
+ * ⇒ 兜底 / `reply-fallback` 可能拿到**错轮**的答案(真机实测:把内部过程话发给了家人)。
19287
+ *
19288
+ * ## 粒度与边界
19289
+ *
19290
+ * - 按**会话 id**加锁:不同轨迹(各 IM 账号)各跑各的,互不拖慢。
19291
+ * - ⚠️ 与投递侧"不排队"**不是一回事**:`send-now` 的"当场注入"是**投递**语义,
19292
+ * 这里排的是**处理**语义 —— 同一会话不该同时开两轮。两者不冲突。
19293
+ * - 用 acquire/release 而不是 callback 形态:主流程已在 `try/catch/finally` 里,
19294
+ * callback 会把整段重新缩进,diff 看不清"到底改了什么"。
19295
+ */
19296
+ const sessionLocks = /* @__PURE__ */ new Map();
19297
+ async function acquireSessionLock(key) {
19298
+ const prev = sessionLocks.get(key) ?? Promise.resolve();
19299
+ let release = () => {};
19300
+ const held = new Promise((resolve) => {
19301
+ release = resolve;
19302
+ });
19303
+ const mine = prev.catch(() => void 0).then(() => held);
19304
+ sessionLocks.set(key, mine);
19305
+ await prev.catch(() => void 0);
19306
+ let released = false;
19307
+ return () => {
19308
+ if (released) return;
19309
+ released = true;
19310
+ if (sessionLocks.get(key) === mine) sessionLocks.delete(key);
19311
+ release();
19312
+ };
19313
+ }
19314
+ /**
19208
19315
  * **一条入站消息的主流程**(通道中立)。
19209
19316
  *
19210
19317
  * 职责:路由 → 固定会话 id 创建/延续 → 提问 → 回复(或被闸门拦下后兜底)。
@@ -19258,8 +19365,21 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
19258
19365
  }).catch(() => {});
19259
19366
  return;
19260
19367
  }
19368
+ let releaseLock = null;
19261
19369
  try {
19262
19370
  const sessionId = weixinSessionIdForTrajectory(trajectory);
19371
+ /**
19372
+ * 🔴 **同会话串行化**(2026-10-21,修"同一条消息被处理两轮"的第二个缺陷)。
19373
+ *
19374
+ * `askTrajectory` 的成功判据是"`assistant/message` **条数增长**"。两条入站并发时,
19375
+ * 后一条的快照包含前一条尚未落盘的产出 ⇒ **前一轮的产出会满足后一条的成功判据**
19376
+ * ⇒ 兜底/`reply-fallback` 可能拿到**错轮**的答案(真机实测:把内部过程话发给了家人)。
19377
+ *
19378
+ * 锁的粒度 = **一个会话同一时刻只跑一条入站**(不同轨迹各跑各的,互不拖慢)。
19379
+ * ⚠️ 与投递侧"不排队"不是一回事:`send-now` 的"当场注入"是**投递**语义,
19380
+ * 这里排的是**处理**语义 —— 两者不冲突。
19381
+ */
19382
+ releaseLock = await acquireSessionLock(sessionId);
19263
19383
  const existing = getTrajectoryAgent(sessionId);
19264
19384
  const hc = readCheapWorkerConfig(root);
19265
19385
  const ref = existing ? {
@@ -19339,7 +19459,18 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
19339
19459
  if (text) parts.push(text);
19340
19460
  parts.push(...mediaNotes, ...degradedNotes);
19341
19461
  if (manualOutput) {
19342
- clearSentThisTurn(sessionId);
19462
+ /**
19463
+ * 🔴 **不在这里清空"已发送"标记**(2026-10-21 修"同一条消息被处理两轮")。
19464
+ *
19465
+ * 旧写法 `clearSentThisTurn(sessionId)` 的语义是"来了新消息就当这个会话没发过"——
19466
+ * 而它**连正在跑那一轮的证据一起抹掉**:真机时刻线
19467
+ * `12:08:25 发送成功 → 12:08:56 新入站(抹) → 12:09:19 那一轮 turn-stopping 判"没发" ⇒ 打回`
19468
+ * ⇒ 用户看到的就是"同一条消息被处理了两轮"。
19469
+ *
19470
+ * 现在**没有任何清空动作**:标记记的是**轮号**,上一轮发的记录在下一轮 `turn-stopping`
19471
+ * 时天然不匹配(`hasSentThisTurn` 按轮比)⇒ 陈旧记录靠"轮号不等"淘汰,
19472
+ * 不靠"谁来抹掉"。清空动作本身就是误伤面。
19473
+ */
19343
19474
  noteManualOutputSession(sessionId, {
19344
19475
  root,
19345
19476
  accountId,
@@ -19415,6 +19546,8 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
19415
19546
  console.log(`[serenity-hooks] im-bridge error (ccc=${root}): ${msg}`);
19416
19547
  const stack = err instanceof Error ? err.stack : void 0;
19417
19548
  if (stack) console.log(`[serenity-hooks] im-bridge stack:\n${stack.slice(0, 1500)}`);
19549
+ } finally {
19550
+ releaseLock?.();
19418
19551
  }
19419
19552
  }
19420
19553
  /**
@@ -20023,7 +20156,7 @@ function syncHumanChannel(ctx) {
20023
20156
  (async () => {
20024
20157
  try {
20025
20158
  stopMachineLoops();
20026
- const { listCccs } = await import("./ccc-roots-DYJvlQhc.js").then((n) => n.a);
20159
+ const { listCccs } = await import("./ccc-roots-4LwGLCXa.js").then((n) => n.a);
20027
20160
  for (const entry of await listCccs(ctx)) syncCccBridge(ctx, entry.root);
20028
20161
  } catch {}
20029
20162
  })();
@@ -20287,7 +20420,7 @@ function matchCcc(input, candidates) {
20287
20420
  }
20288
20421
  /** 组装候选列表(`ccc-roots.listCccs` 投影 + `.serenity` 名;动态 import 保持本模块静态依赖轻量) */
20289
20422
  async function collectCandidates(ctx) {
20290
- const { listCccs } = await import("./ccc-roots-DYJvlQhc.js").then((n) => n.a);
20423
+ const { listCccs } = await import("./ccc-roots-4LwGLCXa.js").then((n) => n.a);
20291
20424
  const entries = await listCccs(ctx, { defaultRoot: process.cwd() });
20292
20425
  const seen = /* @__PURE__ */ new Set();
20293
20426
  const out = [];
@@ -1,6 +1,6 @@
1
- import { r as cccRootForCwd } from "./ccc-roots-DYJvlQhc.js";
1
+ import { r as cccRootForCwd } from "./ccc-roots-4LwGLCXa.js";
2
2
  import { _ as resolveInside, g as readUtf8 } from "./ccc-CyYJL4F2.js";
3
- import { A as sessionEvents, E as parseTrajectoryCode, b as getActiveSessionInfo, j as sessionsRoot, x as getLastActiveSessionInfo } from "./profile-DmWw2M0q.js";
3
+ import { A as sessionEvents, E as parseTrajectoryCode, b as getActiveSessionInfo, j as sessionsRoot, x as getLastActiveSessionInfo } from "./profile-CiRWh2a2.js";
4
4
  import { dirname, join } from "node:path";
5
5
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
6
6
  import { fileURLToPath } from "node:url";
@@ -105,6 +105,28 @@ function profileToolWhitelist(profile) {
105
105
  function profileMsmWhitelist(profile) {
106
106
  return new Set(profile?.msms ?? []);
107
107
  }
108
+ /**
109
+ * 该轨迹可**写**的路径前缀(owner 2026-10-20 令:「轨迹设定自己的写白名单」)。
110
+ *
111
+ * 🔴 与 `profileToolWhitelist` 共用同一条判据(D181/D183):**字段在不在,不是数组空不空**。
112
+ *
113
+ * | `writes` | 返回 | 语义 |
114
+ * |---|---|---|
115
+ * | 缺省 | **`null`** | 不收窄(写权限 = 路径守卫那几条,与今天一致) |
116
+ * | `[]` | `[]`(空数组) | **一个写都落不下**(显式意图) |
117
+ * | `['a/']` | `['a/']` | 写目标必须落在 `a/` 之内 |
118
+ *
119
+ * ⛔ **调用方不许把 `null` 当 `[]`** —— 那会把"我没想到这件事"读成"我不许写任何东西",
120
+ * 正是 S196 被静默锁死的形态(D179(b))。
121
+ *
122
+ * ⚠️ 返回的是**原始条目**(相对 CCC 根或绝对路径都可),解析成绝对路径由调用方按自己的 root 做 ——
123
+ * 本模块不持有 root(判据在 `seams/guards.ts`)。
124
+ */
125
+ function profileWriteWhitelist(profile) {
126
+ if (!profile) return null;
127
+ if (profile.writes === void 0) return null;
128
+ return profile.writes;
129
+ }
108
130
  /** 轨迹纪律参与项(`profile` 缺省已在 `readTrajectoryConstraintAt` 归一为全 true) */
109
131
  function constraintCognitiveTrajectory(profile) {
110
132
  return {
@@ -799,4 +821,4 @@ const ACC_MESSAGE_KIND = "plugin:dsh-serenity-hooks";
799
821
  /** ACC 注入消息的统一来源值(各缝共用;需要 `form` 时按 `ContextFormed` 另加字段)。 */
800
822
  const PLUGIN_SOURCE = { kind: ACC_MESSAGE_KIND };
801
823
  //#endregion
802
- export { ACC_VERSION as _, listBoundSessionIds as a, setBindingStore as c, constraintCognitiveTrajectory as d, constraintSystemPromptSource as f, resolveConstraintSystemPrompt as g, profileToolWhitelist as h, carrierState as i, TRAJECTORY_CONSTRAINT_HINT as l, profileMsmWhitelist as m, PLUGIN_SOURCE as n, readLastBound as o, createProfilePromptReader as p, appendBound as r, resolveSessionCognitiveTrajectoryLabel as s, ACC_MESSAGE_KIND as t, buildTrajectoryBasePrompt as u, findPackageRoot as v };
824
+ export { resolveConstraintSystemPrompt as _, listBoundSessionIds as a, setBindingStore as c, constraintCognitiveTrajectory as d, constraintSystemPromptSource as f, profileWriteWhitelist as g, profileToolWhitelist as h, carrierState as i, TRAJECTORY_CONSTRAINT_HINT as l, profileMsmWhitelist as m, PLUGIN_SOURCE as n, readLastBound as o, createProfilePromptReader as p, appendBound as r, resolveSessionCognitiveTrajectoryLabel as s, ACC_MESSAGE_KIND as t, buildTrajectoryBasePrompt as u, ACC_VERSION as v, findPackageRoot as y };
@@ -1078,6 +1078,7 @@ function readTrajectoryConstraintAt(trajectoryDir, root) {
1078
1078
  }
1079
1079
  const tools = strListMaybe(obj.tools);
1080
1080
  const msms = strListMaybe(obj.msms);
1081
+ const writes = strListMaybe(obj.writes);
1081
1082
  /**
1082
1083
  * 提示词注入模式(owner 2026-10-20):只搬运,不判决。
1083
1084
  *
@@ -1097,6 +1098,7 @@ function readTrajectoryConstraintAt(trajectoryDir, root) {
1097
1098
  ...model !== void 0 ? { model } : {},
1098
1099
  ...tools !== void 0 ? { tools } : {},
1099
1100
  ...msms !== void 0 ? { msms } : {},
1101
+ ...writes !== void 0 ? { writes } : {},
1100
1102
  ...resident !== void 0 ? { resident } : {},
1101
1103
  ...prompt !== void 0 ? { prompt } : {},
1102
1104
  cognitiveTrajectory: {
@@ -1,8 +1,8 @@
1
- import { n as agentCwdFor, r as cccRootForCwd, s as hostAgents } from "./ccc-roots-DYJvlQhc.js";
1
+ import { n as agentCwdFor, r as cccRootForCwd, s as hostAgents } from "./ccc-roots-4LwGLCXa.js";
2
2
  import { g as readUtf8, o as isSafeModeOn, p as readCognitiveTrajectorySkills, u as readBlacklist } from "./ccc-CyYJL4F2.js";
3
- import { B as isoLocal, D as readActiveSessionMd, a as readTrajectoryConstraintAt, c as trajectoryConstraintForSession, d as unregisterTrajectorySession$1, j as sessionsRoot, l as registerTrajectorySession$1, p as DEFAULT_SESSION_SCOPE, u as trajectoryDirNameForSession } from "./profile-DmWw2M0q.js";
3
+ import { B as isoLocal, D as readActiveSessionMd, a as readTrajectoryConstraintAt, c as trajectoryConstraintForSession, d as unregisterTrajectorySession$1, j as sessionsRoot, l as registerTrajectorySession$1, p as DEFAULT_SESSION_SCOPE, u as trajectoryDirNameForSession } from "./profile-CiRWh2a2.js";
4
4
  import { a as readGitTrack, i as localstorePath } from "./service-DDmHSglV.js";
5
- import { _ as ACC_VERSION, d as constraintCognitiveTrajectory, m as profileMsmWhitelist, n as PLUGIN_SOURCE, o as readLastBound, p as createProfilePromptReader, u as buildTrajectoryBasePrompt } from "./message-source-BYO-FyFP.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-CfqdGIi2.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";
@@ -2555,6 +2555,51 @@ function countAssistantMessages(session) {
2555
2555
  return n;
2556
2556
  }
2557
2557
  /**
2558
+ * 会话当前轮号(最后一个 `turn/start` 的 `turn`;取不到 ⇒ `null`)。
2559
+ *
2560
+ * 🔴 为什么需要它(2026-10-21,"同一条消息被处理两轮"的第二处缺陷):
2561
+ * 判"本轮是否产出"用的是 **`assistant/message` 条数增长**。而在**并发入站**下,
2562
+ * 后一条的快照里,**前一条那一轮**还在继续产出 assistant 消息 ⇒ 计数照样增长
2563
+ * ⇒ 后一条会把**别人那一轮的文本**当成自己的答案(真机实测:兜底把内部过程话发给了家人)。
2564
+ * ⇒ 增长必须限定在「**这一轮开始之后**新出现的事件」里。
2565
+ */
2566
+ function currentTurnOfSession(session) {
2567
+ const events = sessionEvents(session);
2568
+ for (let i = events.length - 1; i >= 0; i--) {
2569
+ const e = events[i];
2570
+ if (e?.type === "turn/start") {
2571
+ const t = e.data?.turn;
2572
+ return typeof t === "number" && Number.isFinite(t) ? t : null;
2573
+ }
2574
+ }
2575
+ return null;
2576
+ }
2577
+ function snapshotSession(session) {
2578
+ return {
2579
+ turn: currentTurnOfSession(session),
2580
+ assistantCount: countAssistantMessages(session),
2581
+ eventCount: sessionEvents(session).length
2582
+ };
2583
+ }
2584
+ /**
2585
+ * **本轮**(快照之后)是否产出了 assistant 消息。
2586
+ *
2587
+ * 判据顺序(每一条都是被真账打出来的):
2588
+ * ① 只数**快照之后**新增的事件 ⇒ 前一轮续跑的产出**不进账**(2026-10-21);
2589
+ * ② 若快照时的轮号已知,且现在的轮号变了 ⇒ 那是**别的轮**在产出 ⇒ 不算我的;
2590
+ * ③ 条数确实增长 ⇒ 成功。
2591
+ */
2592
+ function grewWithFreshAssistant(session, snap) {
2593
+ const events = sessionEvents(session);
2594
+ if (events.length <= snap.eventCount) return false;
2595
+ const now = currentTurnOfSession(session);
2596
+ if (snap.turn !== null && now !== null && now !== snap.turn) return false;
2597
+ let fresh = 0;
2598
+ for (let i = snap.eventCount; i < events.length; i++) if (events[i]?.type === "assistant/message") fresh += 1;
2599
+ if (fresh > 0) return true;
2600
+ return countAssistantMessages(session) > snap.assistantCount;
2601
+ }
2602
+ /**
2558
2603
  * 等**本轮**产出一条 assistant 消息。
2559
2604
  *
2560
2605
  * ## 为什么不能用 `waitAgentIdle` 单独当判据(2026-10-19 复读缺陷)
@@ -2570,9 +2615,19 @@ function countAssistantMessages(session) {
2570
2615
  * ② 成功 ⟺ 该计数**增长**;
2571
2616
  * ③ 超时或有界轮询耗尽 ⇒ `false`(调用方据此给空答案,兜底不触发)。
2572
2617
  */
2573
- async function waitForTurnAssistant(ctx, agent, beforeAssistantCount, opts = {}) {
2618
+ async function waitForTurnAssistant(ctx, agent, before, opts = {}) {
2574
2619
  const timeoutMs = opts.timeoutMs ?? ASK_TURN_TIMEOUT_MS;
2575
- const grew = () => countAssistantMessages(agent.session) > beforeAssistantCount;
2620
+ /**
2621
+ * 快照兼容两种入参:数字(旧调用方的"条数")与 `SessionSnapshot`(新,带轮号与事件位)。
2622
+ * 🔴 数字形态**仍然可用**,因为既有测试与调用点按数字传,而它们的语义("条数增长")
2623
+ * 在本轮修法之后依然成立 —— 只是**并发**时不够。给 `SessionSnapshot` 才拿到全部判别力。
2624
+ */
2625
+ const snap = typeof before === "number" ? {
2626
+ turn: null,
2627
+ assistantCount: before,
2628
+ eventCount: 0
2629
+ } : before;
2630
+ const grew = () => typeof before === "number" ? countAssistantMessages(agent.session) > before : grewWithFreshAssistant(agent.session, snap);
2576
2631
  if (await Promise.race([waitAgentIdle(ctx, agent).then(() => true), new Promise((resolve) => {
2577
2632
  setTimeout(() => resolve(false), timeoutMs);
2578
2633
  })])) return grew();
@@ -2581,10 +2636,17 @@ async function waitForTurnAssistant(ctx, agent, beforeAssistantCount, opts = {})
2581
2636
  /**
2582
2637
  * 提问一轮:followup → 等**本轮**回答 → 读答案。
2583
2638
  *
2639
+ * ## 🔴 2026-10-21:快照从"条数"升级为"(轮号, 条数, 事件位)"
2640
+ *
2641
+ * 并发入站时,后一条的快照里**前一条那一轮还在产出** ⇒ 旧判据(条数增长)会被满足
2642
+ * ⇒ 后一条把**别人那一轮的文本**当自己的答案。新快照让"增长"只在**本轮的窗口内**才成立。
2643
+ * 配套:调用侧(`im/inbound-event.ts`)给**同一会话**加了串行锁 —— 两层都要,缺一不可:
2644
+ * 锁防"同时开两轮",快照防"锁被绕过时(如别的路径直接调本函数)把错轮的文本当答案"。
2645
+ *
2584
2646
  * @param options.timeoutMs 本轮等待上限(测试注入;生产用缺省)
2585
2647
  */
2586
2648
  async function askTrajectory(ctx, agent, question, options) {
2587
- const beforeAssistant = countAssistantMessages(agent.session);
2649
+ const beforeSnapshot = snapshotSession(agent.session);
2588
2650
  agent.followup(createUserMessage({
2589
2651
  content: [{
2590
2652
  type: "text",
@@ -2593,7 +2655,7 @@ async function askTrajectory(ctx, agent, question, options) {
2593
2655
  source: PLUGIN_SOURCE
2594
2656
  }));
2595
2657
  return {
2596
- answer: await waitForTurnAssistant(ctx, agent, beforeAssistant, { timeoutMs: options?.timeoutMs }) ? lastAssistantText(agent) : "",
2658
+ answer: await waitForTurnAssistant(ctx, agent, beforeSnapshot, { timeoutMs: options?.timeoutMs }) ? lastAssistantText(agent) : "",
2597
2659
  sessionId: String(agent.session.id ?? "")
2598
2660
  };
2599
2661
  }
@@ -1,4 +1,4 @@
1
- import { f as hostSettings } from "./ccc-roots-DYJvlQhc.js";
1
+ import { f as hostSettings } from "./ccc-roots-4LwGLCXa.js";
2
2
  //#region src/surface/settings.ts
3
3
  /**
4
4
  * 设置面板命名空间 = **profile 条目 id**(v0.1.7 起;与旧 `settings.yaml` 段名逐字相同)。
@@ -67,6 +67,20 @@ export interface TrajectoryConstraint {
67
67
  tools?: string[];
68
68
  /** MSM 白名单;缺省/空数组语义同 `tools`(D181) */
69
69
  msms?: string[];
70
+ /**
71
+ * **写路径白名单**(owner 2026-10-20 令:「ACC 如果支持轨迹设定自己的写白名单就好了」)。
72
+ *
73
+ * `tools` 只管**工具名**,管不了"这个工具能写哪儿"——一旦给了 `write`,路径守卫只拦
74
+ * "逃出 CCC"与黑名单,**拦不住它去写别人的轨迹或 `docs/`**。本字段补的就是这一维。
75
+ *
76
+ * 🔴 判据与 D181 同一条:**字段在不在,不是数组空不空**。
77
+ * - 缺省(`undefined`)⇒ **不收窄**(写权限 = 路径守卫那几条);
78
+ * - `[]` ⇒ **一个写都落不下**(显式意图,不告警);
79
+ * - 非空 ⇒ 写类工具的**目标路径**必须落在这些前缀内(相对 CCC 根,或其绝对路径)。
80
+ *
81
+ * 只约束**写类**工具(`write`/`edit`/`container_fs` 写动作/`append`/`touch`);读不受影响。
82
+ */
83
+ writes?: string[];
70
84
  /** 轨迹纪律参与项(**缺省全 true**,见上方 R↓) */
71
85
  cognitiveTrajectory: {
72
86
  session: boolean;
@@ -45,6 +45,24 @@ export declare const TRAJECTORY_CONSTRAINT_HINT = "systemPromptFile hot-reloads
45
45
  export declare function profileToolWhitelist(profile: TrajectoryConstraint | null | undefined): Set<string> | null;
46
46
  /** 该轨迹会话可 exec 的 MSM 白名单(`msm` 工具内部再过滤;独立于工具白名单) */
47
47
  export declare function profileMsmWhitelist(profile: TrajectoryConstraint | null | undefined): Set<string>;
48
+ /**
49
+ * 该轨迹可**写**的路径前缀(owner 2026-10-20 令:「轨迹设定自己的写白名单」)。
50
+ *
51
+ * 🔴 与 `profileToolWhitelist` 共用同一条判据(D181/D183):**字段在不在,不是数组空不空**。
52
+ *
53
+ * | `writes` | 返回 | 语义 |
54
+ * |---|---|---|
55
+ * | 缺省 | **`null`** | 不收窄(写权限 = 路径守卫那几条,与今天一致) |
56
+ * | `[]` | `[]`(空数组) | **一个写都落不下**(显式意图) |
57
+ * | `['a/']` | `['a/']` | 写目标必须落在 `a/` 之内 |
58
+ *
59
+ * ⛔ **调用方不许把 `null` 当 `[]`** —— 那会把"我没想到这件事"读成"我不许写任何东西",
60
+ * 正是 S196 被静默锁死的形态(D179(b))。
61
+ *
62
+ * ⚠️ 返回的是**原始条目**(相对 CCC 根或绝对路径都可),解析成绝对路径由调用方按自己的 root 做 ——
63
+ * 本模块不持有 root(判据在 `seams/guards.ts`)。
64
+ */
65
+ export declare function profileWriteWhitelist(profile: TrajectoryConstraint | null | undefined): string[] | null;
48
66
  /** 轨迹纪律参与项(`profile` 缺省已在 `readTrajectoryConstraintAt` 归一为全 true) */
49
67
  export declare function constraintCognitiveTrajectory(profile: TrajectoryConstraint | null | undefined): {
50
68
  session: boolean;
@@ -105,6 +105,18 @@ interface TrajectoryAskResult {
105
105
  answer: string;
106
106
  sessionId: string;
107
107
  }
108
+ /**
109
+ * 会话快照:**调用那一刻**的(轮号, assistant 条数, 事件条数)。
110
+ *
111
+ * 三个值一起记,是因为判"增长"要能回答两个问题:
112
+ * ① 有没有新的 assistant 消息?(条数)
113
+ * ② 那些新消息是不是**我这一轮**产的?(轮号 + 只看快照之后的事件)
114
+ */
115
+ interface SessionSnapshot {
116
+ turn: number | null;
117
+ assistantCount: number;
118
+ eventCount: number;
119
+ }
108
120
  /**
109
121
  * 等**本轮**产出一条 assistant 消息。
110
122
  *
@@ -121,13 +133,20 @@ interface TrajectoryAskResult {
121
133
  * ② 成功 ⟺ 该计数**增长**;
122
134
  * ③ 超时或有界轮询耗尽 ⇒ `false`(调用方据此给空答案,兜底不触发)。
123
135
  */
124
- export declare function waitForTurnAssistant(ctx: unknown, agent: Agent, beforeAssistantCount: number, opts?: {
136
+ export declare function waitForTurnAssistant(ctx: unknown, agent: Agent, before: number | SessionSnapshot, opts?: {
125
137
  timeoutMs?: number;
126
138
  pollMs?: number;
127
139
  }): Promise<boolean>;
128
140
  /**
129
141
  * 提问一轮:followup → 等**本轮**回答 → 读答案。
130
142
  *
143
+ * ## 🔴 2026-10-21:快照从"条数"升级为"(轮号, 条数, 事件位)"
144
+ *
145
+ * 并发入站时,后一条的快照里**前一条那一轮还在产出** ⇒ 旧判据(条数增长)会被满足
146
+ * ⇒ 后一条把**别人那一轮的文本**当自己的答案。新快照让"增长"只在**本轮的窗口内**才成立。
147
+ * 配套:调用侧(`im/inbound-event.ts`)给**同一会话**加了串行锁 —— 两层都要,缺一不可:
148
+ * 锁防"同时开两轮",快照防"锁被绕过时(如别的路径直接调本函数)把错轮的文本当答案"。
149
+ *
131
150
  * @param options.timeoutMs 本轮等待上限(测试注入;生产用缺省)
132
151
  */
133
152
  export declare function askTrajectory(ctx: Context, agent: Agent, question: string, options?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.80.0",
3
+ "version": "1.82.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": {