@shgroup/dsh-serenity-hooks 1.87.7 → 1.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/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";
@@ -8631,7 +8631,7 @@ function formatCroLogLine(entry) {
8631
8631
  const head = oneline(entry.promptDigest.head);
8632
8632
  return [
8633
8633
  oneline(entry.at),
8634
- entry.ok ? "ok" : entry.notReady === true ? "not-ready" : "fail",
8634
+ entry.ok ? "ok" : entry.notReady === true ? "not-ready" : entry.noWake === true ? "no-wake" : "fail",
8635
8635
  `tick=${entry.tick}`,
8636
8636
  oneline(entry.detail),
8637
8637
  `reason=${reason}`,
@@ -8652,7 +8652,7 @@ function parseCroLogLine(line) {
8652
8652
  const at = parts[0];
8653
8653
  if (Number.isNaN(Date.parse(at))) return null;
8654
8654
  const mark = parts[1];
8655
- if (mark !== "ok" && mark !== "fail" && mark !== "not-ready") return null;
8655
+ if (mark !== "ok" && mark !== "fail" && mark !== "not-ready" && mark !== "no-wake") return null;
8656
8656
  const tickM = /^tick=(\d+)$/.exec(parts[2]);
8657
8657
  if (!tickM) return null;
8658
8658
  const reasonRaw = parts[4].startsWith("reason=") ? parts[4].slice(7) : "";
@@ -8661,6 +8661,7 @@ function parseCroLogLine(line) {
8661
8661
  at,
8662
8662
  ok: mark === "ok",
8663
8663
  ...mark === "not-ready" ? { notReady: true } : {},
8664
+ ...mark === "no-wake" ? { noWake: true } : {},
8664
8665
  detail: parts[3],
8665
8666
  tick: Number(tickM[1]),
8666
8667
  reason: reasonRaw === "" ? null : reasonRaw,
@@ -8820,6 +8821,7 @@ function appendCroWakeLog(root, dirName, input, nowMs = Date.now()) {
8820
8821
  at: isoLocal(nowMs),
8821
8822
  ok: input.ok,
8822
8823
  ...input.notReady === true ? { notReady: true } : {},
8824
+ ...input.noWake === true ? { noWake: true } : {},
8823
8825
  detail: input.detail,
8824
8826
  tick: input.tick,
8825
8827
  reason: input.reason,
@@ -8848,875 +8850,785 @@ function appendCroWakeLog(root, dirName, input, nowMs = Date.now()) {
8848
8850
  }
8849
8851
  }
8850
8852
  //#endregion
8851
- //#region src/wake/clock.ts
8853
+ //#region src/trajectory/interrupted.ts
8852
8854
  /**
8853
- * wake/clock.ts — **时钟运行时工厂**(S142 §38 复审候选 C6b,路径 **P2**)
8855
+ * 中断轮次发现(`interrupted` 会话扫描)
8854
8856
  *
8855
- * 🔴 2026-09-25(S142 分批清死代码·第 1 批)**更正**:本文件当初是为**两条**独立 5min 时钟抽出的公共引擎:
8856
- * · 唤醒调度器(`wake/scheduler.ts`,一次性唤醒,D58)—— **现存**
8857
- * · autopilot 时钟(`autopilot-trajectory.ts`,周期自唤醒,D59)—— 🔴 **已随 v1.35.0 整段退场**
8858
- * 二者的**业务判据完全不同**(谁来扫 / 扫什么 / 投递给谁 / 按什么周期),但**引擎骨架逐字同形**
8859
- * ——进程态 + 快照 + 复位 + `startTimer` + tick 外壳 + 事件接线 + disposer + `timer.unref()`。
8860
- * 本条把**引擎**抽到本工厂,**各钟仍各持自己的定时器与自己的串行链**(现存的那一钟 = 唤醒调度器)。
8857
+ * ## 这个模块解决什么
8861
8858
  *
8862
- * ───────────────────────────────────────────────────────────────────────────
8863
- * 🔴 硬约束一:**两条时钟绝不可共用"排队执行"的链**(这是 P2 与 P1 的分界判据)
8859
+ * DSH 的会话日志里,**进程在某一轮完成前退出**(被 kill/崩溃/关机超时)时,
8860
+ * 宿主重放时会为该轮补上收尾事件:
8864
8861
  *
8865
- * autopilot 每轮要跑该 CCC 的**偏见脚本**(`fetchBiasContent`,超时 60s),可能阻塞数十秒;
8866
- * 若与唤醒调度器共用一条串行链,会把另一条一起**卡住**(一次性唤醒被无关的偏见脚本拖住)。
8867
- * ⇒ 本工厂把 `chain` 作为**每实例私有状态**:`createClock()` 调用一次 = 一条独立的串行域。
8868
- * 实例之间**零共享**(无模块级单例、无共享 Promise、无共享 map)。
8869
- * ⚠️ 若日后有人把 `chain` 提到模块级"省一次分配",**两条时钟的隔离即刻失效**——不许。
8862
+ * ```jsonl
8863
+ * turn/end {"turn":66,"reason":{"kind":"interrupted"}}
8864
+ * step/end {"turn":66,"step":85}
8865
+ * session/end-seed {}
8866
+ * ```
8870
8867
  *
8871
- * 🔴 硬约束二:**武装门只判全局闸**(`gate()`),**不判"本次有无目标"**
8868
+ * 宿主**只补收尾,不续跑** —— 那一轮的工作就此停在那里。本模块负责把这些会话找出来,
8869
+ * 交给上层(认知轨迹)决定要不要续。
8872
8870
  *
8873
- * 2026-09-14 F 段实测缺陷:旧实现把"有 live CCC"当**武装前置条件**,而宿主刚重启时 live 会话
8874
- * 必为空,且**"恢复旧会话"不触发 `session/created`**(只有新建会)⇒ 启动瞬间判定落空 =
8875
- * **时钟永久不武装**(实测 6.6h 零 tick)。⇒ 现在「**全局闸开即武装**」,"有无目标"降级为
8876
- * tick 内的廉价判定。**武装与"本次有无目标"是两件事,本工厂不得把二者耦合。**
8871
+ * ## 判据(三条同时成立,缺一不可)
8877
8872
  *
8878
- * 🔴 硬约束三:**闸是可选能力,工厂不得替调用方假设"必须有闸"**(v1.34 S-1 解耦 → 2026-09-21 收窄)
8873
+ * 1. 日志里**最后一条 `turn/end`** 的 `reason.kind === 'interrupted'`;
8874
+ * 2. 该 `turn/end` **之后没有新的 `turn/start`**(否则已被后续轮次接续);
8875
+ * 3. ⛔ **不要求**末条是 `session/end-seed`(2026-10-23 放宽,原因见 {@link detectInterruptedEnd})。
8879
8876
  *
8880
- * 🔴 2026-09-25(S142 分批清死代码·第 1 批)**更正:当前零条闸在用** —— 现存唯一那一钟
8881
- * (唤醒调度器)本已无闸,而"曾只余的那一条" `autopilotWakeEnabled` 已随 **v1.35.0 autopilot
8882
- * 整段退场**一并消失 ⇒ `gate` **缺省即常态,不是特例**。**唤醒调度器已无闸**
8883
- * (原 `wakeSchedulerEnabled` 于 2026-09-21 所有者令砍掉 ⇒ 调用方**不传** `gate`
8884
- * = 恒武装、永不因闸跳过、`enabled` 恒 `true`)。工厂取 `gate` **回调**(每次现读),
8885
- * **不做**任何合并或缓存;**缺省无闸是合法形态,不是漏配**。
8877
+ * ⚠️ **为什么不能只看"存在过 interrupted"**:一个长会话历史上可能中断几十次
8878
+ * (本容器实测单会话 33 次)。只看"曾经中断"会把早已恢复的会话全部当成待续 ⇒ 灾难。
8886
8879
  *
8887
- * ───────────────────────────────────────────────────────────────────────────
8888
- * 两条时钟**逐项语义差**(`ClockOptions` 的每个可配项都对应下表一行,不是随意的灵活性):
8880
+ * 🔴 **为什么原判据要求"末条 = end-seed"是错的**(实测 26 条里漏 2 条):
8881
+ * 重启后宿主会在 end-seed **之后**追加 `agent/inbox/spliced`(把待办消息重新塞进收件箱)
8882
+ * ⇒ "末条必须 end-seed"**在恢复动作发生之后就恒不成立**,
8883
+ * 而"被恢复过又再次被打断"恰恰是守护最该覆盖的形态。
8889
8884
  *
8890
- * | 项 | 唤醒调度器 | autopilot | 工厂表达 |
8891
- * |----|-----------|-----------|---------|
8892
- * | `chain` | 有(全局串行) | 有(全局串行) | **总是私有**(硬约束一) |
8893
- * | `lastTickLog` | 有(逐行摘要) | **无** | `logFrom` 不给 = 不记 |
8894
- * | `ticks` 记账 | 工厂在 body **完成后**记 | **本钟**在同步段记 | `bodyCountsTick` + `begin` |
8895
- * | body 返回类型 | `string[]`(人读摘要) | `void` | 泛型 `T` |
8896
- * | 启动日志 | 固定串 | 含启用 CCC 计数 | `startLog: () => string` |
8897
- * | body 异常兜底 | 留痕 + `console.warn` | 同 | 工厂统一(**吞异常不改语义**) |
8885
+ * ## 为什么不整文件解压
8898
8886
  *
8899
- * ───────────────────────────────────────────────────────────────────────────
8900
- * **本工厂是"加法式"重构**(P2 的性质):不新增任何判定逻辑,只把同形骨架收成一份代码;
8901
- * 两个模块仍各自导出 `wakeSchedulerState()` / `autopilotClockState()` 与各自的
8902
- * `__reset…ForTest()`,**对外可观测面(字段名与语义)零变化**——`acc-diag` ①b 段、
8903
- * `surface/observation.ts:containerClocks()` 与面板都在读它们(见该文件 :225–250 的设计红线)。
8887
+ * DSH 日志是 **zstd 多帧追加**(实测单会话 24705 帧 / 47 MB)。原生
8888
+ * `zlib.zstdDecompressSync` 只解**第一帧**,对多帧直接报 `Unknown frame descriptor`。
8889
+ * ⇒ 采用**尾部窗口 + 倒序逐帧解压**:只读文件末尾 N 字节,从后往前找帧魔数,
8890
+ * 解到凑够所需事件数即停。实测 8h 窗口 6 个会话 **20 ms**。
8904
8891
  *
8905
- * 为什么**不**取 P1(真合并成一个 `setInterval` + 两条策略)——2026-09-15 所有者裁决:
8906
- * P1 只多省 ~20 行,却要把 `armed` 拆成"宿主 + 每策略"两层、并强行分开串行域,
8907
- * 即**动两条正在跑的时钟的骨架**;**不值当**(裁决原文见 SESSION §12.8②)。
8892
+ * ## 世代识别复用 `cleanup.ts`
8893
+ *
8894
+ * 日志文件名有多个世代(`session.jsonl` / `session.v3.jsonl.zstd` / `session.v4.jsonl.zstd`…),
8895
+ * 宿主按世代写且**迁移保留源文件**。⇒ 本模块**不硬编码文件名**,走
8896
+ * {@link findSessionLog}(与清理/体积统计同一判据),避免"新世代对本模块不可见"。
8897
+ *
8898
+ * ## 边界
8899
+ *
8900
+ * - **只读**:不改任何会话日志。
8901
+ * - **不判周期**:何时扫、扫完做什么归调用方(CCC 的认知轨迹)。本模块只回答
8902
+ * 「此刻哪些会话以 interrupted 结尾」。
8908
8903
  */
8909
8904
  /**
8910
- * tick 周期(**5 分钟**)。
8905
+ * zstd 解压(**运行时有、类型声明没有**)。
8911
8906
  *
8912
- * 2026-09-15 S142「ACC 侧 autopilot 退场」:本值原从 `autopilot-core.ts` 导入(`TICK_MS`),
8913
- * 但该模块随 autopilot 退场,而**本模块的唯一消费者是唤醒调度器**(`wake-scheduler`)——
8914
- * 值本身与 autopilot 无关 ⇒ 随消费者归位到本模块(不再依赖 autopilot 判据层)。
8907
+ * 🔴 为什么不能用 `import { zstdDecompressSync } from 'node:zlib'`:
8908
+ * 本仓 `@types/node@^20` 的声明里**没有** zstd 系列(Node 22.15+ 才进声明)。
8909
+ * 而运行时本机 v22.22.1 **确实有**(实测 `typeof require('zlib').zstdDecompressSync === 'function'`)。
8910
+ * ⇒ 用命名空间导入 + 局部断言,并**在运行时校验**;缺失时走明确降级(返回空,不静默瞎猜)。
8911
+ */
8912
+ const zstdDecompressSync = nodeZlib.zstdDecompressSync;
8913
+ /** zstd 帧魔数(小端 `28 B5 2F FD`) */
8914
+ const ZSTD_MAGIC = Buffer.from([
8915
+ 40,
8916
+ 181,
8917
+ 47,
8918
+ 253
8919
+ ]);
8920
+ /** 默认回看窗口:8 小时(owner 2026-10-22 定) */
8921
+ const DEFAULT_LOOKBACK_MS = 288e5;
8922
+ /** 每次从文件尾部读取的字节数(够覆盖数百个末尾事件) */
8923
+ const TAIL_WINDOW_BYTES = 1 << 20;
8924
+ /** 需要凑够的末尾事件数(够跨过 turn/end + step/end + end-seed 即可,留余量) */
8925
+ const TAIL_EVENT_TARGET = 60;
8926
+ /**
8927
+ * 从文件尾部倒序解压,凑够 `want` 个事件。
8915
8928
  *
8916
- * ⚠️ 语义不得变:该值同时是**报告文案的来源**("等下个 Nmin tick"),历史上曾因文案写死
8917
- * 10min 与实现 5min 不符而误导排查(§12.7 文案纠错)。
8929
+ * 多帧 zstd 必须**按帧边界**切:从后往前找魔数,每帧用下一帧起始位置当结束边界。
8930
+ * 任何一帧解压失败就停下(返回已拿到的)—— 再往前更可能不完整。
8918
8931
  */
8919
- const TICK_MS = 3e5;
8932
+ function readTailEvents(logPath, want) {
8933
+ if (zstdDecompressSync === void 0) return [];
8934
+ const st = statSync(logPath);
8935
+ const windowBytes = Math.min(TAIL_WINDOW_BYTES, st.size);
8936
+ if (windowBytes < 8) return [];
8937
+ const buf = Buffer.alloc(windowBytes);
8938
+ const fd = openSync(logPath, "r");
8939
+ try {
8940
+ readSync(fd, buf, 0, windowBytes, st.size - windowBytes);
8941
+ } finally {
8942
+ closeSync(fd);
8943
+ }
8944
+ const offsets = [];
8945
+ for (let i = 0; i <= windowBytes - ZSTD_MAGIC.length; i++) if (buf[i] === ZSTD_MAGIC[0] && buf[i + 1] === ZSTD_MAGIC[1] && buf[i + 2] === ZSTD_MAGIC[2] && buf[i + 3] === ZSTD_MAGIC[3]) offsets.push(i);
8946
+ if (offsets.length === 0) return [];
8947
+ const events = [];
8948
+ for (let k = offsets.length - 1; k >= 0 && events.length < want; k--) {
8949
+ const start = offsets[k];
8950
+ const end = k + 1 < offsets.length ? offsets[k + 1] : windowBytes;
8951
+ let text;
8952
+ try {
8953
+ text = zstdDecompressSync(buf.subarray(start, end)).toString("utf8");
8954
+ } catch {
8955
+ break;
8956
+ }
8957
+ const batch = [];
8958
+ for (const line of text.split("\n")) {
8959
+ if (!line.trim()) continue;
8960
+ try {
8961
+ batch.push(JSON.parse(line));
8962
+ } catch {}
8963
+ }
8964
+ events.unshift(...batch);
8965
+ }
8966
+ return events.slice(-want);
8967
+ }
8920
8968
  /**
8921
- * 挂事件 → 触发 `start()`(**每个事件一次 try/catch 吞异常**:事件通道缺失不阻断时钟)。
8969
+ * 判定一组尾部事件是否「以 interrupted 结尾」。
8970
+ * 不命中 → null。
8922
8971
  *
8923
- * 为什么吞异常:宿主(headless profile 等)可能没有事件通道;**启动时已 `start()` 过一次**,
8924
- * 事件只是"跟上面板开关 / 新会话"的热启动补丁,缺它不该让整个装配抛错。
8925
- * @param on 宿主的 `on`(可能不存在)
8926
- * @param ctx `on` 的 `this`(宿主 API 常要求绑定)
8927
- * @param clock 目标时钟
8928
- * @param events 事件名列表(回调恒为 `() => clock.start()`)
8972
+ * ## 🔴 2026-10-23 判据放宽(owner 实测:「不是挂在工具调用上的,也要能被 interrupted 筛选到并唤醒」)
8973
+ *
8974
+ * ### 原判据(v1.87.4 起)为什么漏
8975
+ * 原来只认 **末条事件 = `session/end-seed`**。实测(本机 3337 条会话全扫):
8976
+ * · 末轮 `turn/end[interrupted]` 的会话 **26** 条;
8977
+ * · 其中满足原判据的只有 **24** 条 ⇒ **漏 2 条**,形态逐字为:
8978
+ * `… turn/end[interrupted] -> session/end-seed -> agent/inbox/spliced -> agent/inbox/spliced`
8979
+ * ⇒ 重启后宿主把这**两条**追加在 end-seed 之后(它们是"恢复时把待办消息重新塞进收件箱"),
8980
+ * 于是"末条必须 end-seed"这条**在恢复动作发生之后就恒不成立**。
8981
+ * ⚠️ 讽刺的是:**正是那些被恢复过、又再次被打断的会话**最容易长成这样
8982
+ * ⇒ 原判据恰好对"反复中断"的会话失明,而它们才是最需要守护的。
8983
+ *
8984
+ * ### 新判据(三条,缺一不可)
8985
+ * 1. 日志里**最后一条 `turn/end`** 的 `reason.kind === 'interrupted'`;
8986
+ * 2. 该 `turn/end` **之后没有新的 `turn/start`**(否则说明它已被后续轮次接续);
8987
+ * 3. ⛔ **不再要求末条是 `end-seed`**。
8988
+ *
8989
+ * ### 为什么不改成"末条必须是某个白名单"
8990
+ * 那就是原缺陷换个白名单再犯一次(`agent/inbox/spliced` 之外还会有别的)。
8991
+ * **判据要问的是"最后一轮的结束原因是什么、之后有没有新轮开始"**,
8992
+ * 而不是"日志最后一个 token 长什么样"——后者是**载体形态**,前者是**状态**。
8993
+ *
8994
+ * ### 为什么不会误报"曾经中断但已恢复"
8995
+ * 恢复一定伴随 `turn/start`(新一轮)或新的 `turn/end`(`completed` 等)⇒ 判据 1/2 各自挡住。
8996
+ * 实测该放宽**不新增误报**:原 24 条全含于新判据(`curOnly = 0`),新增 2 条全是真漏。
8929
8997
  */
8930
- function attachClockEvents(ctx, clock, events) {
8931
- const on = ctx?.on;
8932
- if (typeof on !== "function") return;
8933
- for (const name of events) try {
8934
- on.call(ctx, name, () => clock.start());
8935
- } catch {}
8998
+ function detectInterruptedEnd(events) {
8999
+ if (events.length === 0) return null;
9000
+ let idx = -1;
9001
+ for (let i = events.length - 1; i >= 0; i--) if (events[i]?.type === "turn/end") {
9002
+ idx = i;
9003
+ break;
9004
+ }
9005
+ if (idx < 0) return null;
9006
+ const turnEnd = events[idx];
9007
+ if (turnEnd?.data?.reason?.kind !== "interrupted") return null;
9008
+ for (let i = idx + 1; i < events.length; i++) if (events[i]?.type === "turn/start") return null;
9009
+ const turn = typeof turnEnd.data?.turn === "number" ? turnEnd.data.turn : null;
9010
+ return {
9011
+ turn,
9012
+ pendingTools: findPendingTools(events, turn)
9013
+ };
8936
9014
  }
8937
9015
  /**
8938
- * 建一条时钟(**每钟各调一次**——本工厂**不是**单例,调用次数 = 时钟条数)。
9016
+ * 找出末轮里发起了但没有结果的工具调用名。
8939
9017
  *
8940
- * 顺序忠实于既有实现:`start()` 先武装并立即跑一次 tick → 再挂事件。
8941
- * 拆卸由调用方经 `registerDisposer(ctx, label, clock.dispose)` 落到宿主的 `ctx.effect`。
8942
- * @param opts 见 {@link ClockOptions}(每项对应一条既有的语义差)
8943
- * @returns 该钟的 `{start, dispose, snapshot, reset}`
9018
+ * ⚠️ **尽力而为**,非权威:尾部窗口可能截断更早的调用。上层应把它当
9019
+ * "**可能**未完成的副作用"清单,而不是"确定未完成"。
8944
9020
  */
8945
- function createClock(opts) {
8946
- const runtime = {
8947
- armed: false,
8948
- armedAt: null,
8949
- lastTickAt: null,
8950
- ticks: 0,
8951
- lastSkipReason: null
9021
+ function findPendingTools(events, turn) {
9022
+ if (turn === null) return [];
9023
+ const called = /* @__PURE__ */ new Map();
9024
+ const resolved = /* @__PURE__ */ new Set();
9025
+ for (const e of events) {
9026
+ const data = e.data;
9027
+ if (!data || data.turn !== turn) continue;
9028
+ if (e.type === "tool/call" && typeof data.callId === "string" && typeof data.name === "string") called.set(data.callId, data.name);
9029
+ else if (e.type === "tool/result" && typeof data.callId === "string") resolved.add(data.callId);
9030
+ }
9031
+ return [...called].filter(([callId]) => !resolved.has(callId)).map(([, name]) => name);
9032
+ }
9033
+ /**
9034
+ * 扫描一个 **project 目录**(形如 `<sessions-root>/--home-yh-home-home-serenity--/`)。
9035
+ *
9036
+ * @param projectDir DSH 项目目录(其下每个子目录是一个会话)
9037
+ * @param options.lookbackMs 回看窗口(缺省 8h)
9038
+ * @param options.now 注入"当前时刻"(测试用)
9039
+ * @param options.liveIds 需要跳过的会话 id(正在跑的会话不该被判为待续)
9040
+ */
9041
+ function scanInterruptedSessions(projectDir, options = {}) {
9042
+ const t0 = Date.now();
9043
+ const lookbackMs = options.lookbackMs ?? 288e5;
9044
+ const cutoff = (options.now ?? Date.now()) - lookbackMs;
9045
+ const liveIds = options.liveIds ?? /* @__PURE__ */ new Set();
9046
+ const result = {
9047
+ sessions: [],
9048
+ scanned: 0,
9049
+ skipped: 0,
9050
+ errors: [],
9051
+ elapsedMs: 0
8952
9052
  };
8953
- if (opts.logFrom) runtime.lastTickLog = [];
8954
- const st = {
8955
- timer: null,
8956
- chain: Promise.resolve(),
8957
- runtime,
8958
- wired: false,
8959
- opts
8960
- };
8961
- /** 记一次 tick(工厂内部与 body 共用同一实现——只有一处会计逻辑) */
8962
- const countTick = () => {
8963
- runtime.lastTickAt = Date.now();
8964
- runtime.ticks += 1;
8965
- };
8966
- /** 逐行打印(`logFrom` 缺省 ⇒ 本钟无 tick 日志,整段为死代码路径) */
8967
- const emit = (lines) => {
8968
- if (!opts.logFrom) return;
8969
- for (const line of lines) console.log(`${opts.logFrom.prefix}${line}`);
8970
- };
8971
- const tick = () => {
8972
- if (opts.gate !== void 0 && !opts.gate()) {
8973
- runtime.lastSkipReason = opts.gateOffReason ?? null;
8974
- return;
8975
- }
8976
- if (opts.begin) try {
8977
- opts.begin();
9053
+ if (!existsSync(projectDir)) {
9054
+ result.elapsedMs = Date.now() - t0;
9055
+ return result;
9056
+ }
9057
+ let entries;
9058
+ try {
9059
+ entries = readdirSync(projectDir);
9060
+ } catch (err) {
9061
+ result.errors.push({
9062
+ sessionId: "*",
9063
+ message: err instanceof Error ? err.message : String(err)
9064
+ });
9065
+ result.elapsedMs = Date.now() - t0;
9066
+ return result;
9067
+ }
9068
+ for (const sessionId of entries) {
9069
+ if (liveIds.has(sessionId)) continue;
9070
+ const dirPath = join(projectDir, sessionId);
9071
+ const logPath = findSessionLog(dirPath);
9072
+ if (logPath === null) continue;
9073
+ let mtimeMs;
9074
+ try {
9075
+ mtimeMs = statSync(logPath).mtimeMs;
8978
9076
  } catch (err) {
8979
- console.warn(`[serenity-hooks] ✗ tick 前置异常: ${String(err?.message ?? err)}(${opts.label})`);
8980
- return;
9077
+ result.errors.push({
9078
+ sessionId,
9079
+ message: err instanceof Error ? err.message : String(err)
9080
+ });
9081
+ continue;
8981
9082
  }
8982
- st.chain = st.chain.then(async () => {
8983
- try {
8984
- const value = await opts.body();
8985
- if (!opts.bodyCountsTick) countTick();
8986
- if (opts.logFrom) {
8987
- runtime.lastTickLog = opts.logFrom.lines(value);
8988
- emit(runtime.lastTickLog);
8989
- }
8990
- } catch (err) {
8991
- const msg = `✗ tick 异常: ${String(err?.message ?? err)}`;
8992
- if (opts.logFrom) runtime.lastTickLog = [msg];
8993
- console.warn(`[serenity-hooks] ${msg}(${opts.label})`);
9083
+ if (mtimeMs < cutoff) continue;
9084
+ result.scanned++;
9085
+ try {
9086
+ const events = readTailEvents(logPath, TAIL_EVENT_TARGET);
9087
+ const hit = detectInterruptedEnd(events);
9088
+ if (!hit) {
9089
+ result.skipped++;
9090
+ continue;
8994
9091
  }
8995
- });
8996
- };
8997
- /**
8998
- * 幂等启动 + 武装门:**已武装 → 原样返回**(防重复起定时器);
8999
- * **闸关 → 不武装**(零资源占用);闸开则**无条件**武装(硬约束二:不判"有无目标")。
9000
- */
9001
- const start = () => {
9002
- if (st.timer) return;
9003
- if (opts.events && !st.wired) {
9004
- st.wired = true;
9005
- attachClockEvents(opts.ctx, api, opts.events);
9006
- }
9007
- if (opts.gate !== void 0 && !opts.gate()) return;
9008
- st.timer = setInterval(tick, TICK_MS);
9009
- st.timer.unref();
9010
- runtime.armed = true;
9011
- runtime.armedAt = Date.now();
9012
- console.log(opts.startLog());
9013
- if (opts.immediate !== false) tick();
9014
- };
9015
- const dispose = () => {
9016
- if (st.timer) {
9017
- clearInterval(st.timer);
9018
- st.timer = null;
9019
- }
9020
- runtime.armed = false;
9021
- runtime.armedAt = null;
9022
- opts.onDispose?.();
9023
- };
9024
- const api = {
9025
- start,
9026
- dispose,
9027
- snapshot: () => ({
9028
- enabled: opts.gate === void 0 ? true : opts.gate(),
9029
- ...runtime,
9030
- ...runtime.lastTickLog ? { lastTickLog: [...runtime.lastTickLog] } : {}
9031
- }),
9032
- noteSkipReason: (reason) => {
9033
- runtime.lastSkipReason = reason;
9034
- },
9035
- countTick,
9036
- enqueue: (work) => {
9037
- st.chain = st.chain.then(work, () => void 0).then(() => void 0, (err) => {
9038
- console.warn(`[serenity-hooks] ✗ 串行任务异常(${opts.label}): ${String(err?.message ?? err)}`);
9092
+ const tailTime = events[events.length - 1]?.time;
9093
+ result.sessions.push({
9094
+ sessionId,
9095
+ logPath,
9096
+ turn: hit.turn,
9097
+ lastEventAt: typeof tailTime === "number" ? tailTime : null,
9098
+ mtimeMs,
9099
+ pendingTools: hit.pendingTools
9100
+ });
9101
+ } catch (err) {
9102
+ result.errors.push({
9103
+ sessionId,
9104
+ message: err instanceof Error ? err.message : String(err)
9039
9105
  });
9040
- },
9041
- reset: () => {
9042
- if (st.timer) {
9043
- clearInterval(st.timer);
9044
- st.timer = null;
9045
- }
9046
- runtime.armed = false;
9047
- runtime.armedAt = null;
9048
- runtime.lastTickAt = null;
9049
- runtime.ticks = 0;
9050
- runtime.lastSkipReason = null;
9051
- if (opts.logFrom) runtime.lastTickLog = [];
9052
- st.wired = false;
9053
- opts.onReset?.();
9054
9106
  }
9055
- };
9056
- return api;
9107
+ }
9108
+ result.sessions.sort((a, b) => (b.lastEventAt ?? b.mtimeMs) - (a.lastEventAt ?? a.mtimeMs));
9109
+ result.elapsedMs = Date.now() - t0;
9110
+ return result;
9057
9111
  }
9058
- //#endregion
9059
- //#region src/wake/scheduler.ts
9060
9112
  /**
9061
- * wake/scheduler.ts — 认知轨迹唤醒调度器(D58/D59/D60;S142 2026-09-13)
9062
- *
9063
- * 一个**中心调度器**(5min tick,复用 autopilot 的节律常量),处理 CCC 唤醒注册表里
9064
- * 到期的条目:把「未来时刻 + 一条 message」投递给目标认知轨迹,**fire-and-forget**
9065
- * (无阻塞、无等待、无回执——发起方不 await,调度器不替模型结果负责)。
9066
- *
9067
- * 与 autopilot 的关系(D59):**两个 tick 各自独立**。autopilot 是每 CCC 单例的
9068
- * 周期自唤醒,逻辑一行不碰;本模块只处理注册表的一次性唤醒条目。
9069
- *
9070
- * 投递路径(P-1 证据链见 SESSION.md §30.6):
9071
- * 1. target(S### / 目录名)→ 目录名 + SESSION.md
9072
- * 2. 目标 dsh 会话 id:`.bindings.json` 里绑定该目录名的会话(按绑定时间倒序,权威)
9073
- * → 回退:live 会话标题匹配
9074
- * 3. agent:**live 优先**(`ctx.agents.get`)→ 冷会话走 `ctx.sessionController.resolveAgent`
9075
- * (宿主实现:live 优先 + resume 去重 + 由会话元数据恢复 preset)
9076
- * → `sessionController` 缺席 ⇒ 仅投递 live 目标,**不静默**(v1.39.3:**归为"环境未就绪"**
9077
- * 而非"投递失败",见 `WakeDeliveryResult.notReady` —— 启动那一拍早于懒服务就绪是常态)
9078
- * 4. 投递:`agent.followup(...)`("Queue an ordinary follow-up turn and wake the driver")
9113
+ * 由 CCC 根推导它的 DSH 项目目录名(`--home-yh-home-home-serenity--`)。
9079
9114
  *
9080
- * 守卫(2026-09-21 起**本钟无闸**):🔴 **恒武装**——原 `wakeSchedulerEnabled` 已砍,
9081
- * 本钟不再有「可被误关的开关」;旧配置残留该键**静默忽略**,且**不**回退 `autopilotEnabled`|
9082
- * CRO 阶段另有**独立**总闸 `croEnabled`(只停 CRO,**不影响**本注册表投递)|
9083
- * 补跑窗口 2h(超窗置 missed 留痕)|同 tick 串行|唤起后维持 live(人类可介入)。
9084
- */
9085
- /**
9086
- * 解析条目的 target(`S###` 或完整目录名)→ 目录名 + SESSION.md 路径。
9087
- * @param root CCC 根
9088
- * @param target 条目里的目标标识
9089
- * @returns 命中返回目录信息,未命中 null
9115
+ * 规则取自宿主:把绝对路径的 `/` 换成 `-`,并在前后各加 `--`。
9116
+ * ⚠️ 这是**推导**,不是真相源 —— 实际目录名以磁盘为准;调用方应在不存在时
9117
+ * 回退到"扫整个 sessions root"或直接报未找到。
9090
9118
  */
9091
- function resolveWakeTarget(root, target) {
9092
- const found = findSession(sessionsRoot(root), target);
9093
- if (!found) return null;
9094
- const mdPath = join(found.path, "SESSION.md");
9095
- if (!existsSync(mdPath)) return null;
9096
- return {
9097
- dirName: basename(found.path),
9098
- mdPath
9099
- };
9119
+ function projectDirNameForCwd(cwd) {
9120
+ return `--${cwd.replace(/\/+$/, "").split("/").filter(Boolean).join("-")}--`;
9100
9121
  }
9101
9122
  /**
9102
- * 🔴 **本钟的全局闸已于 2026-09-21 砍掉**(所有者令逐字:「**send-later 的开关不再重要了,砍掉**」)。
9103
- *
9104
- * **为什么砍**(R↓):唤醒已从"可选实验能力"长成**全容器的续接绳**(🔵 **术语说明,2026-09-23 补**:
9105
- * **"绳" = 我们对自己用 `send-later` 给自己排的"定时叫醒"条目的俗称** —— 一条链每轮给自己排下一根,
9106
- * 链才不断。本仓注释与 SESSION.md 沿用此说法,**它不是产品名、也不是宿主概念**)——S142/S151/S185 的链、
9107
- * 跨轨迹投递、`send-now`/`send-later` 都走它。留一个"能被误关的开关"的**代价**是
9108
- * "整条链静默停摆且无人告警"(本仓已实测过同族事故:闸关 6.6h 零 tick、断链 1 天 14 小时);
9109
- * 而**收益**只剩"关掉一个 5min unref 空检查"。⇒ **收益 < 代价,故删闸**(不是改为缺省开——是**没有这个键了**)。
9123
+ * 续跑正文(**简单、稳定、不含版本号**)。
9110
9124
  *
9111
- * ⚠️ **兼容**:旧配置文件里残留的 `wakeSchedulerEnabled` **静默忽略**(无回退键、无告警噪声;
9112
- * 与旧 `bootstrap` 段的处置先例一致)。**机制本身一行未动**(所有者:"我说的只是开关")。
9125
+ * 判据(为什么这么短):本动作的用途是**把被打断的会话推回工作状态**,
9126
+ * 不是替它重述任务 —— 被打断那一轮的上下文就在它自己的日志里。
9127
+ * ⇒ 正文只做三件事:① 说明发生了什么;② 提示**先核对副作用**;③ 让它接着做。
9113
9128
  *
9114
- * 历史(勿误读):v1.34 S-1 曾把两闸解耦(周期自唤醒 `autopilotWakeEnabled` / 本钟 `wakeSchedulerEnabled`),
9115
- * 理由是"关 auto-trajectory 不得连带关一次性唤醒";随 ACC 侧 autopilot 于 v1.35.0 整段退场,
9116
- * 两闸只剩本钟一座 ⇒ 现已整座移除。详见 `docs/cognitive-trajectory-scheduling.md` §S-1 与 v1.44 CHANGELOG。
9129
+ * 🔴 **为什么要写"先核对副作用"**:中断时可能有「有 call 无 result」的工具调用,
9130
+ * 它们的副作用**未知**(可能已写文件 / 已发消息)。若模型把那一轮整段重放,
9131
+ * 会**重复执行**。这句话是给模型的护栏,不是客套。
9117
9132
  */
9133
+ function buildContinueText(sessionId, turn, pendingTools) {
9134
+ const lines = [];
9135
+ lines.push("[中断续跑] 上一条进程在本轮完成前退出,该轮已被中止。现在继续。");
9136
+ lines.push("");
9137
+ lines.push(`会话: ${sessionId}${turn === null ? "" : ` / 被中断的轮次: turn ${turn}`}`);
9138
+ if (pendingTools.length > 0) {
9139
+ lines.push("");
9140
+ lines.push(`⚠️ 中断时这些工具「有调用、无结果」(副作用未知,**可能已经执行**): ${pendingTools.join(", ")}`);
9141
+ lines.push(" ⇒ 先核对它们是否已生效(文件是否已写、消息是否已发),再决定要不要重做。");
9142
+ lines.push(" ⛔ 不要把被中断的那一轮整段重放。");
9143
+ }
9144
+ lines.push("");
9145
+ lines.push("请接着完成该轮未完成的工作;完成后把进展写入本轨迹的 SESSION.md。");
9146
+ return lines.join("\n");
9147
+ }
9118
9148
  /**
9119
- * 🔴 **本钟的串行域**(C6b 硬约束):`clock.chain` 是**本实例私有**的——
9120
- * 与 autopilot 的串行链**零共享**。autopilot 每轮跑 CCC 偏见脚本可能阻塞数十秒,
9121
- * 共用链会把一次性唤醒一起卡住(这正是 P1 被否、取 P2 的理由,见 SESSION §12.8②)。
9149
+ * 把一个 agent 唤醒(注入一条续跑消息)。
9122
9150
  *
9123
- * **为什么这里是模块级常量**(而不是 `registerWakeScheduler` 的局部量):本钟的进程态是
9124
- * **进程级可观测面**(`acc-diag` ①b / `containerClocks` 在读),且 `wakeSchedulerState()` /
9125
- * `__resetWakeSchedulerStateForTest()` 是**公开导出**、可在未装配时被调用 ⇒ 实例必须**恒在**
9126
- * (旧实现的 `schedulerRuntime` 也正是模块级常量)。装配只做 `clock.start()`。
9151
+ * 两条路径(与 `wake/scheduler.ts` 的 `sendToCognitiveTrajectory` 同源判据):
9152
+ * - **live**:`agents.get(id)` 拿得到 ⇒ 目标在跑(或被加载)⇒ `steer` 当场注入当前轮;
9153
+ * `steer` 不可用则退回 `followup`(宁可排队,不可静默丢)。
9154
+ * - **cold-resume**:`sessionController.resolveAgent(id)` 冷载入后同样注入。
9155
+ *
9156
+ * ⚠️ 冷会话载入**会真起一个 agent**(消耗真实算力)⇒ 调用方必须自己决定要不要,
9157
+ * 本函数不做数量限制(那是策略,归调用者)。
9127
9158
  */
9128
- /** 本钟的装配项(`ctx` 由 {@link registerWakeScheduler} 在装配点填入) */
9129
- const clockOpts = {
9130
- label: "认知轨迹唤醒调度器",
9131
- ctx: void 0,
9132
- events: ["session/created", "serenity/settings-changed"],
9133
- body: () => runWakeTick(clockOpts.ctx),
9134
- logFrom: {
9135
- prefix: "[serenity-hooks] 认知轨迹唤醒 ",
9136
- lines: (lines) => lines
9137
- },
9138
- startLog: () => "[serenity-hooks] ✓ 认知轨迹唤醒调度器启动(5min tick)"
9139
- };
9140
- const clock = createClock(clockOpts);
9141
- /** 调度器进程态快照(只读)。
9142
- * ⚠️ `enabled` 对**本钟已无意义**(2026-09-21 起无闸 ⇒ 恒 `true`);保留字段只为沿用工厂快照形状。
9143
- * "这轮为什么没被唤起"现在看 `armed` / `lastSkipReason`(tick 内真实原因)+ **CRO 闸**(见 `croEnabled`)。 */
9144
- function wakeSchedulerState() {
9145
- return clock.snapshot();
9146
- }
9147
- /**
9148
- * 取目标 agent:live 优先 → 冷会话走 sessionController.resolveAgent → 标题回退。
9149
- * @param ctx 插件上下文
9150
- * @param root CCC 根
9151
- * @param dirName 目标认知轨迹目录名
9152
- * @returns 命中返回 agent 与取得方式(how 进 lastResult,便于诊断);未命中返回原因
9153
- * (原因带 `notReady` = **环境未就绪**而非投递失败,调用方**不得**据此记一次失败)
9154
- *
9155
- * 🔴 **前置条件:本轨迹名下必须有一条「未取代」的绑定记录**(2026-09-20 回源码答复 S151 实测):
9156
- * 选人是**遍历 `listBoundSessionIds(root, dirName)` 给出的 id**(按绑定时间倒序),**live 只是这条
9157
- * 记录的一个加分项**——「绑定里的那条恰好 live ⇒ 直接注入」,否则走冷载入。
9158
- * ⇒ **「有 live 会话」≠「有绑定」**:会话活着但该轨迹**零条未取代绑定**时,本函数**直接返回
9159
- * `无绑定会话记录…`**(`send-now` / `send-later` 到点投递同此判据)。
9160
- * 实证(2026-09-20):S142 三条旧绑定于 **08:03** 被 `supersededAt` 取代(D69「同轨迹只留一条」),
9161
- * 而当前载体的绑定**直到 17:05 那次 rebuild 才写入**(`queueRebuild` 内的 `appendBound(action:'rebuild')`)
9162
- * ⇒ **10:42 的 `send-now` 落在这个窗口里** ⇒ 报"无绑定会话记录"(当时确有 live 会话)。
9163
- * ⚠️ 已知**注释漂移**:本行上方"→ 标题回退"在函数体内**没有对应分支**(标题回退属 v1.29.2 R2,
9164
- * 现行实现已只认绑定);**仅登记,未改**(改它属 ACC 代码改动,须具名令)。
9165
- */
9166
- async function acquireWakeAgent(ctx, root, dirName) {
9167
- const agents = hostAgents(ctx);
9168
- const ids = listBoundSessionIds(root, dirName, hostAliveProbe(ctx));
9169
- for (const id of ids) {
9170
- const live = agents?.get?.(id);
9171
- if (live) return {
9172
- agent: live,
9173
- how: `live(bound ${id})`
9174
- };
9175
- }
9176
- if (ids.length > 0) {
9177
- const sc = hostService(ctx, "sessionController");
9178
- const resolved = sc?.resolveAgent;
9179
- if (typeof resolved !== "function") return {
9180
- error: `宿主尚未就绪:目标会话未加载,且 sessionController 服务此刻不可用(重启后的启动窗口,或本进程确实无该服务)——绑定 id: ${ids[0]}`,
9181
- notReady: true
9182
- };
9183
- const id = ids[0];
9159
+ function createResumeOne(deps) {
9160
+ return async (hit) => {
9161
+ const { sessionId } = hit;
9162
+ const text = buildContinueText(sessionId, hit.turn, hit.pendingTools);
9163
+ let agent;
9164
+ let how;
9165
+ const live = deps.agents?.get?.(sessionId);
9166
+ if (live) {
9167
+ agent = live;
9168
+ how = "live";
9169
+ } else {
9170
+ const sc = deps.sessionController;
9171
+ const resolve = sc?.resolveAgent;
9172
+ if (typeof resolve !== "function" || sc === void 0) return {
9173
+ sessionId,
9174
+ ok: false,
9175
+ error: "宿主 sessionController 不可用(重启启动窗口?)—— 稍后重试"
9176
+ };
9177
+ try {
9178
+ const out = await resolve.call(sc, sessionId);
9179
+ if (!out?.agent) return {
9180
+ sessionId,
9181
+ ok: false,
9182
+ error: `冷载入未返回 agent: ${String(out?.error ?? "未知原因")}`
9183
+ };
9184
+ agent = out.agent;
9185
+ how = "cold-resume";
9186
+ } catch (err) {
9187
+ return {
9188
+ sessionId,
9189
+ ok: false,
9190
+ error: `冷载入失败: ${err instanceof Error ? err.message : String(err)}`
9191
+ };
9192
+ }
9193
+ }
9194
+ const a = agent;
9184
9195
  try {
9185
- const out = await resolved.call(sc, id);
9186
- if (out?.agent) return {
9187
- agent: out.agent,
9188
- how: `cold-resume(${id})`
9196
+ if (typeof a.steer === "function" && a.status === "running") {
9197
+ a.steer(deps.makeMessage(text));
9198
+ return {
9199
+ sessionId,
9200
+ ok: true,
9201
+ how: `${how}+steer`
9202
+ };
9203
+ }
9204
+ if (typeof a.followup === "function") {
9205
+ a.followup(deps.makeMessage(text));
9206
+ return {
9207
+ sessionId,
9208
+ ok: true,
9209
+ how: `${how}+followup`
9210
+ };
9211
+ }
9212
+ if (typeof a.steer === "function") {
9213
+ a.steer(deps.makeMessage(text));
9214
+ return {
9215
+ sessionId,
9216
+ ok: true,
9217
+ how: `${how}+steer`
9218
+ };
9219
+ }
9220
+ return {
9221
+ sessionId,
9222
+ ok: false,
9223
+ how,
9224
+ error: "agent 既无 steer 也无 followup(宿主契约漂移)"
9189
9225
  };
9190
- return { error: `sessionController.resolveAgent 未返回 agent(${id}): ${String(out?.error ?? "未知原因")}` };
9191
9226
  } catch (err) {
9192
- return { error: `冷会话载入失败(${id}): ${String(err?.message ?? err)}` };
9227
+ if (typeof a.followup === "function") try {
9228
+ a.followup(deps.makeMessage(text));
9229
+ return {
9230
+ sessionId,
9231
+ ok: true,
9232
+ how: `${how}+followup(steer 失败后退回)`
9233
+ };
9234
+ } catch (err2) {
9235
+ return {
9236
+ sessionId,
9237
+ ok: false,
9238
+ error: `steer 与 followup 均失败: ${String(err2?.message ?? err2)}`
9239
+ };
9240
+ }
9241
+ return {
9242
+ sessionId,
9243
+ ok: false,
9244
+ error: `注入失败: ${err instanceof Error ? err.message : String(err)}`
9245
+ };
9193
9246
  }
9194
- }
9195
- return {
9196
- /**
9197
- * 🔴 **文案改指真相源(2026-10-23,guard 实测报障)**:原写「`AGENT_SESSIONS/.bindings.json` 中无 X」,
9198
- * 而 D172 之后绑定真相源 = **`<轨迹目录>/current.dsh_session_bind`**,`.bindings.json`
9199
- * **已退役且不在盘上**(实测不存在)⇒ 照旧文案排查的人会去翻一个**不存在的文件**。
9200
- */
9201
- error: `无绑定会话记录(AGENT_SESSIONS/${dirName}/current.dsh_session_bind 不存在或无有效会话)——目标轨迹需先被 cognitive_trajectory use 激活过,或由 send-now 自动创建载体`,
9202
- noBinding: true
9203
- };
9204
- }
9205
- /** 唤醒消息(唤起即投递正文:身份锚定 + 唤醒信息 + 任务) */
9206
- function buildWakeText(entry, target) {
9207
- return [
9208
- `[认知轨迹唤醒] 到点(登记于 ${entry.createdAt},发起者 ${entry.createdBy || "未知"},id ${entry.id})。`,
9209
- `当前时间:${localHuman()}(当地时区)。`,
9210
- "",
9211
- `身份锚定:继续 ${target.dirName} 的认知轨迹(SESSION.md: ${target.mdPath})。`,
9212
- "",
9213
- "唤醒信息:",
9214
- entry.message,
9215
- "",
9216
- "任务:按上述唤醒信息继续本轨迹的工作;完成后把进展写入 SESSION.md。"
9217
- ].join("\n");
9218
- }
9219
- /**
9220
- * 投递一条唤醒(fire-and-forget:`followup` 入队即返回,不等模型结果)。
9221
- * @param ctx 插件上下文
9222
- * @param root CCC 根
9223
- * @param entry 唤醒条目
9224
- * @returns 投递结果(写回 lastResult)
9225
- */
9226
- async function deliverWake(ctx, root, entry) {
9227
- const target = resolveWakeTarget(root, entry.target);
9228
- if (!target) return {
9229
- ok: false,
9230
- detail: `目标认知轨迹未命中(${entry.target})`
9231
- };
9232
- const acquired = await acquireWakeAgent(ctx, root, target.dirName);
9233
- if ("error" in acquired) return {
9234
- ok: false,
9235
- detail: acquired.error,
9236
- notReady: acquired.notReady === true
9237
- };
9238
- try {
9239
- acquired.agent.followup(createUserMessage({
9240
- content: [{
9241
- type: "text",
9242
- text: buildWakeText(entry, target)
9243
- }],
9244
- source: PLUGIN_SOURCE
9245
- }));
9246
- } catch (err) {
9247
- return {
9248
- ok: false,
9249
- detail: `投递失败(${acquired.how}): ${String(err?.message ?? err)}`
9250
- };
9251
- }
9252
- return {
9253
- ok: true,
9254
- detail: `已投递 ${target.dirName}(${acquired.how})`
9255
9247
  };
9256
9248
  }
9249
+ //#endregion
9250
+ //#region src/wake/clock.ts
9257
9251
  /**
9258
- * 即时消息正文(**刻意不复用** {@link buildWakeText}):唤醒文本以「**到点**(登记于…)」开头,
9259
- * 对即时投递那是**错的事实**。本函数显式区分"这是即时消息、不是到点唤醒"。
9252
+ * wake/clock.ts — **时钟运行时工厂**(S142 §38 复审候选 C6b,路径 **P2**)
9260
9253
  *
9261
- * ⚠️ **v1.39.2 起"已入队"这句话必须删掉**(S142 §0r,所有者令「不排队、直接即时注入」):
9262
- * 旧文案写"消息已进入你的队列;你此前若正在跑轮次,它在本轮结束后生效"——
9263
- * 那是**旧的 followup 语义**;现在在跑的目标走 `steer`(当场注入当前轮),
9264
- * 留着这句话就是**注入一条假陈述**(比没有更坏:目标会误判自己看到它的时机)。
9265
- * @param target 目标轨迹(目录名 + SESSION.md)
9266
- * @param message 递送正文
9267
- * @param sender 发起者轨迹标识(供目标与人类识别来源)
9268
- */
9269
- function buildSendText(target, message, sender) {
9270
- return [
9271
- `[即时消息] 来自 ${sender || "未知轨迹"}。当前时间:${localHuman()}(当地时区)。`,
9272
- "",
9273
- `身份锚定:继续 ${target.dirName} 的认知轨迹(SESSION.md: ${target.mdPath})。`,
9274
- "",
9275
- "消息:",
9276
- message,
9277
- "",
9278
- "说明:这是**即时投递**(不是到点唤醒)——它**不排队**:目标正在跑轮次就**当场注入当前轮**,目标空闲则立即起一轮。处理完照常把进展写入 SESSION.md。"
9279
- ].join("\n");
9280
- }
9281
- /**
9282
- * 即时投递(`cognitive_trajectory send-now`)——与 `send-later`(预约)**共用取用通路**(`acquireWakeAgent`),
9283
- * 区别只在**时刻**(现在 vs 未来)与**回执**(有 vs 无):
9284
- * · `send-later`(原 `wake-later`):未来时刻 + 一条 message → 落注册表 → 本文件的调度器到点投递(**fire-and-forget,无回执**)
9285
- * · `send-now`(原 `send-message`):**此刻**把一条 message 递给目标 → **同步回执** → **不落注册表**
9254
+ * 🔴 2026-09-25(S142 分批清死代码·第 1 批)**更正**:本文件当初是为**两条**独立 5min 时钟抽出的公共引擎:
9255
+ * · 唤醒调度器(`wake/scheduler.ts`,一次性唤醒,D58)—— **现存**
9256
+ * · autopilot 时钟(`autopilot-trajectory.ts`,周期自唤醒,D59)—— 🔴 **已随 v1.35.0 整段退场**
9257
+ * 二者的**业务判据完全不同**(谁来扫 / 扫什么 / 投递给谁 / 按什么周期),但**引擎骨架逐字同形**
9258
+ * ——进程态 + 快照 + 复位 + `startTimer` + tick 外壳 + 事件接线 + disposer + `timer.unref()`。
9259
+ * 本条把**引擎**抽到本工厂,**各钟仍各持自己的定时器与自己的串行链**(现存的那一钟 = 唤醒调度器)。
9286
9260
  *
9287
- * **为什么冷路径落在这里(R↓,2026-09-16 所有者裁决)**:`addWake` 显式拒绝 `at ≤ now`
9288
- * ("时刻必须在未来"——那是"**预约**"语义的地基,不许放宽)⇒ 即时投递**不能**靠一条
9289
- * `at=now` 的注册表条目实现(那还会让调度器把一次性即时投递当 `pending` **每 tick 重试**)。
9290
- * 必须直接走 {@link acquireWakeAgent}(live 优先 → `sessionController.resolveAgent` 冷载入)。
9291
- * 这正对应所有者要的「**会话不活跃则等效于直接 wake**」:**同一条冷载入通路**,只是**不等 tick**。
9261
+ * ───────────────────────────────────────────────────────────────────────────
9262
+ * 🔴 硬约束一:**两条时钟绝不可共用"排队执行"的链**(这是 P2 与 P1 的分界判据)
9292
9263
  *
9293
- * ⚠️ **承诺边界(不许含糊)**:回执只到「**已注入 / 已起轮**」(宿主调用未抛错),
9294
- * **到不了**"目标已执行/已答复" —— 与 `deliverWake` 同一层。
9295
- * 判"目标真的动了"必须用**文件级判据**(它自己的 SESSION.md),
9296
- * **不得凭回执结案**(§12.B 的 `delivered ≠ 跑了一轮`)。
9264
+ * autopilot 每轮要跑该 CCC 的**偏见脚本**(`fetchBiasContent`,超时 60s),可能阻塞数十秒;
9265
+ * 若与唤醒调度器共用一条串行链,会把另一条一起**卡住**(一次性唤醒被无关的偏见脚本拖住)。
9266
+ * ⇒ 本工厂把 `chain` 作为**每实例私有状态**:`createClock()` 调用一次 = 一条独立的串行域。
9267
+ * 实例之间**零共享**(无模块级单例、无共享 Promise、无共享 map)。
9268
+ * ⚠️ 若日后有人把 `chain` 提到模块级"省一次分配",**两条时钟的隔离即刻失效**——不许。
9297
9269
  *
9298
- * 🔴 **v1.39.2 语义变更(S142 §0r,所有者 2026-09-17 令:「希望 send message 时不排队,
9299
- * 可以直接作为提示词即时注入进去」)**:
9300
- * 旧实现一律 `followup`(宿主注释:*Queue an ordinary follow-up turn*)⇒ 目标**正在跑轮次**时
9301
- * 消息**排在轮次边界**(即旧文案的"不打断当前轮")。现按宿主 `agent.status` 分流:
9302
- * · `status === 'idle'` ⇒ `followup`(宿主**同步**把 status 置 running ⇒ **立即起轮**,本就不排队)
9303
- * · 否则(在跑) ⇒ **`steer`** —— **当场注入当前轮**,这才是"不排队"的关键路径
9304
- * ⚠️ **fail-safe**:`steer` 抛错 ⇒ **退回 `followup`**:**宁可排队,不可静默丢消息**。
9305
- * ⚠️ **只改 `send-now`**:`send-later`({@link deliverWake})**保持 `followup`** ——
9306
- * 预约的本职是"到点唤起",且在跑时排队不打断是它被实证验收过的行为,不顺手动它。
9270
+ * 🔴 硬约束二:**武装门只判全局闸**(`gate()`),**不判"本次有无目标"**
9307
9271
  *
9308
- * **归属**:本函数放在调度器侧而非工具侧 —— "怎么把一句话送到一条轨迹"是**调度器的知识**,
9309
- * 工具只该知道"有这个动作"。这样 `cognitive-trajectory.ts` 完全不碰调度器内部(v1.35.0 硬约束①:
9310
- * `send-later` 链路**一行为不动**,本次改动 = **纯新增**)。
9311
- *
9312
- * @param ctx 插件上下文
9313
- * @param root CCC 根
9314
- * @param target `S###` 或 AGENT_SESSIONS 目录名
9315
- * @param message 递送正文
9316
- * @param sender 发起者轨迹标识
9317
- * @returns 投递结果(**同步**返回给调用方)
9318
- */
9319
- async function sendToCognitiveTrajectory(ctx, root, target, message, sender) {
9320
- const t = resolveWakeTarget(root, target);
9321
- if (!t) return {
9322
- ok: false,
9323
- detail: `目标认知轨迹未命中(${target})`
9324
- };
9325
- let acquired = await acquireWakeAgent(ctx, root, t.dirName);
9326
- let createdNote = "";
9327
- if ("error" in acquired) {
9328
- if (!acquired.notReady && acquired.noBinding === true) {
9329
- const carrier = carrierState(root, t.dirName, hostAliveProbe(ctx));
9330
- if (carrier.state !== "never") {
9331
- const why = carrier.state === "occupied" ? `该轨迹曾经由会话 ${carrier.sessionId} 承载,而它此刻不在了` : `该轨迹当前由会话 ${carrier.sessionId} 承载`;
9332
- return {
9333
- ok: false,
9334
- detail: `${t.dirName}:${why} ⇒ **拒绝自动新建载体**(一条轨迹至多一个载体)。\n · 要让它重新跑起来:把那个会话恢复,或用 cognitive_trajectory rebuild 换代(显式 takeover)。\n · 不明所以就先 cognitive_trajectory show ${t.dirName} 看它的交代文件。`
9335
- };
9336
- }
9337
- acquired = await createAndBindTarget(ctx, root, t.dirName);
9338
- if ("error" in acquired) return {
9339
- ok: false,
9340
- detail: acquired.error
9341
- };
9342
- createdNote = `;目标原本无活跃 dsh 会话 ⇒ **已自动创建并绑定**一条(${acquired.how})`;
9343
- } else return {
9344
- ok: false,
9345
- detail: acquired.error
9346
- };
9347
- }
9348
- const agent = acquired.agent;
9349
- const make = () => createUserMessage({
9350
- content: [{
9351
- type: "text",
9352
- text: buildSendText(t, message, sender)
9353
- }],
9354
- source: PLUGIN_SOURCE
9355
- });
9356
- const freshCarrier = createdNote !== "";
9357
- if (freshCarrier || agent.status === "idle") {
9358
- try {
9359
- agent.followup(make());
9360
- } catch (err) {
9361
- return {
9362
- ok: false,
9363
- detail: `投递失败(${acquired.how}): ${String(err?.message ?? err)}`
9364
- };
9365
- }
9366
- return {
9367
- ok: true,
9368
- detail: `已即时投递 ${t.dirName}(${acquired.how};${freshCarrier ? "刚建的载体 ⇒ followup 排队,锚定先于任务" : "目标空闲 ⇒ followup 立即起轮"})${createdNote}`
9369
- };
9370
- }
9371
- try {
9372
- agent.steer(make());
9373
- return {
9374
- ok: true,
9375
- detail: `已即时注入 ${t.dirName}(${acquired.how};目标在跑 ⇒ steer 当场注入当前轮)${createdNote}`
9376
- };
9377
- } catch (err) {
9378
- try {
9379
- agent.followup(make());
9380
- } catch (err2) {
9381
- return {
9382
- ok: false,
9383
- detail: `投递失败(${acquired.how}): steer 与 followup 均失败: ${String(err?.message ?? err)} / ${String(err2?.message ?? err2)}`
9384
- };
9385
- }
9386
- return {
9387
- ok: true,
9388
- detail: `已投递 ${t.dirName}(${acquired.how};steer 不可用 ⇒ 退回 followup): ${String(err?.message ?? err)}${createdNote}`
9389
- };
9390
- }
9391
- }
9392
- /**
9393
- * 🆕 **给一条「从未被激活过」的轨迹就地建一条 dsh 会话并绑定它**(2026-10-16 owner 令,S142)。
9394
- *
9395
- * 何时用:**只有** `send-now` 在 `acquireWakeAgent` 报「无绑定会话记录」时(见该处的判据说明)。
9396
- *
9397
- * ## 🔴 2026-10-18 根治:改成**和用户操作完全同一个调用形态**
9398
- *
9399
- * owner 原话:「应当和用户操作唤起的会话完全一样才行」。
9400
- *
9401
- * 上一版(v1.62.1)传的是 `{ cwd: root }`,然后**事后补** `attachSession()` 去挂工作区。
9402
- * 那是**补救**,不是"一样" —— 宿主里那条路径(`sessionController.create` 的
9403
- * `if (workspace !== void 0) await workspace.attachSession(sessionId)`)**只在 `workspaceId` 分支执行**:
9272
+ * 2026-09-14 F 段实测缺陷:旧实现把"有 live CCC"当**武装前置条件**,而宿主刚重启时 live 会话
9273
+ * 必为空,且**"恢复旧会话"不触发 `session/created`**(只有新建会)⇒ 启动瞬间判定落空 =
9274
+ * **时钟永久不武装**(实测 6.6h 零 tick)。⇒ 现在「**全局闸开即武装**」,"有无目标"降级为
9275
+ * tick 内的廉价判定。**武装与"本次有无目标"是两件事,本工厂不得把二者耦合。**
9404
9276
  *
9405
- * ```js
9406
- * if (request.workspaceId !== void 0) workspace = registry.get(request.workspaceId)
9407
- * const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd
9408
- * ...
9409
- * if (workspace !== void 0) await workspace.attachSession(sessionId) // ← 只有这条分支
9410
- * ```
9277
+ * 🔴 硬约束三:**闸是可选能力,工厂不得替调用方假设"必须有闸"**(v1.34 S-1 解耦 → 2026-09-21 收窄)
9411
9278
  *
9412
- * 而**用户在 UI 里新建会话传的正是 `workspaceId`**(见 `client/sessions/service.d.ts` 的
9413
- * `create({ workspaceId?, cwd?, ... })`)⇒ 用户那条路天然会走到 `attachSession`。
9279
+ * 🔴 2026-09-25(S142 分批清死代码·第 1 批)**更正:当前零条闸在用** —— 现存唯一那一钟
9280
+ * (唤醒调度器)本已无闸,而"曾只余的那一条" `autopilotWakeEnabled` 已随 **v1.35.0 autopilot
9281
+ * 整段退场**一并消失 ⇒ `gate` **缺省即常态,不是特例**。**唤醒调度器已无闸**
9282
+ * (原 `wakeSchedulerEnabled` 于 2026-09-21 所有者令砍掉 ⇒ 调用方**不传** `gate`
9283
+ * = 恒武装、永不因闸跳过、`enabled` 恒 `true`)。工厂取 `gate` **回调**(每次现读),
9284
+ * **不做**任何合并或缓存;**缺省无闸是合法形态,不是漏配**。
9414
9285
  *
9415
- * ⇒ 现在与用户**同一条分支**:先由 CCC 根查出 `workspaceId`,再用 `{ workspaceId }` 建。
9416
- * **`attachToWorkspace` 降级为兜底**(只在"该目录还没有工作区登记"时才会用上),
9417
- * 不再是主路径 —— 因为主路径已经由宿主自己做完。
9286
+ * ───────────────────────────────────────────────────────────────────────────
9287
+ * 两条时钟**逐项语义差**(`ClockOptions` 的每个可配项都对应下表一行,不是随意的灵活性):
9418
9288
  *
9419
- * ## 三步(顺序不可换)
9420
- * ① `sessionController.create({ workspaceId })` —— 与用户同一形态;宿主替我们挂工作区。
9421
- * **idempotently adopt** ⇒ 重复调用不会建出第二条(不必自造"先查再建"的竞态窗口)。
9422
- * ② `resolveAgent(sessionId)` —— 拿到**真 agent**。`create` 只返回 id,不给 agent。
9423
- * ③ `appendBound(...)` —— 落绑定。**必须由本函数写**:`listBoundSessionIds` 是
9424
- * `acquireWakeAgent` 的唯一选人依据,不写绑定则下一条消息**还是**找不到它
9425
- * (那就成了"建了但永远用不上",比不建更坏)。
9289
+ * | 项 | 唤醒调度器 | autopilot | 工厂表达 |
9290
+ * |----|-----------|-----------|---------|
9291
+ * | `chain` | 有(全局串行) | 有(全局串行) | **总是私有**(硬约束一) |
9292
+ * | `lastTickLog` | 有(逐行摘要) | **无** | `logFrom` 不给 = 不记 |
9293
+ * | `ticks` 记账 | 工厂在 body **完成后**记 | **本钟**在同步段记 | `bodyCountsTick` + `begin` |
9294
+ * | body 返回类型 | `string[]`(人读摘要) | `void` | 泛型 `T` |
9295
+ * | 启动日志 | 固定串 | 含启用 CCC 计数 | `startLog: () => string` |
9296
+ * | body 异常兜底 | 留痕 + `console.warn` | 同 | 工厂统一(**吞异常不改语义**) |
9426
9297
  *
9427
- * 🔴 **为什么定位到 CCC 根这一级、不是轨迹目录**:会话的 cwd 决定它落在哪个 CCC
9428
- * (`cccRootForCwd` 上溯 `.serenity`)。目标轨迹本就住在该 CCC 的 `AGENT_SESSIONS/` 下
9429
- * ⇒ 落到 CCC 根才让它**进同一个 CCC**;落到轨迹目录会让它变成一个**不同 CCC**的会话。
9298
+ * ───────────────────────────────────────────────────────────────────────────
9299
+ * **本工厂是"加法式"重构**(P2 的性质):不新增任何判定逻辑,只把同形骨架收成一份代码;
9300
+ * 两个模块仍各自导出 `wakeSchedulerState()` / `autopilotClockState()` 与各自的
9301
+ * `__reset…ForTest()`,**对外可观测面(字段名与语义)零变化**——`acc-diag` ①b 段、
9302
+ * `surface/observation.ts:containerClocks()` 与面板都在读它们(见该文件 :225–250 的设计红线)。
9430
9303
  *
9431
- * @returns 命中返回 agent 与取得方式;任一步失败返回**带原因**的 error(不静默、不误报成功)
9304
+ * 为什么**不**取 P1(真合并成一个 `setInterval` + 两条策略)——2026-09-15 所有者裁决:
9305
+ * P1 只多省 ~20 行,却要把 `armed` 拆成"宿主 + 每策略"两层、并强行分开串行域,
9306
+ * 即**动两条正在跑的时钟的骨架**;**不值当**(裁决原文见 SESSION §12.8②)。
9432
9307
  */
9433
- async function createAndBindTarget(ctx, root, dirName) {
9434
- const sc = hostService(ctx, "sessionController");
9435
- const create = sc?.create;
9436
- if (typeof create !== "function") return {
9437
- error: "目标无活跃 dsh 会话,且 sessionController.create 不可用(绑定 id: 无)——无法就地创建载体;请先对该轨迹执行一次 cognitive_trajectory use",
9438
- notReady: true
9439
- };
9440
- const ws = findWorkspaceByPath(listWorkspaces(ctx) ?? [], root);
9441
- const workspaceId = typeof ws?.id === "string" && ws.id !== "" ? ws.id : void 0;
9442
- let sessionId = "";
9443
- try {
9444
- const out = await create.call(sc, workspaceId !== void 0 ? { workspaceId } : { cwd: root });
9445
- sessionId = String(out?.sessionId ?? "");
9446
- } catch (err) {
9447
- return { error: `就地创建会话失败(${dirName}): ${String(err?.message ?? err)}` };
9448
- }
9449
- if (!sessionId) return { error: `就地创建会话未返回 sessionId(${dirName})` };
9450
- const resolved = sc?.resolveAgent;
9451
- if (typeof resolved !== "function") return {
9452
- error: `会话已建(${sessionId})但 sessionController.resolveAgent 不可用`,
9453
- notReady: true
9454
- };
9455
- let agent;
9456
- try {
9457
- const out = await resolved.call(sc, sessionId);
9458
- agent = out?.agent;
9459
- if (!agent) return { error: `会话已建(${sessionId})但 resolveAgent 未返回 agent: ${String(out?.error ?? "未知原因")}` };
9460
- } catch (err) {
9461
- return { error: `会话已建(${sessionId})但载入失败: ${String(err?.message ?? err)}` };
9462
- }
9463
- const bound = appendBound({ header: {
9464
- id: sessionId,
9465
- cwd: root
9466
- } }, "activate", {
9467
- dirName,
9468
- mdPath: join(root, "AGENT_SESSIONS", dirName, "SESSION.md"),
9469
- note: "auto-created by send-now",
9470
- createdAsUser: true
9471
- });
9472
- const openingNote = deliverCarrierOpening(agent, buildCarrierOpening(parseTrajectoryCode(dirName) ?? dirName, trajectoryDescOf(dirName))) ? ", opening-as-user" : ", ⚠️ 开场白未投递";
9473
- const titleNote = renameCarrierTitle(ctx, agent, root, dirName);
9474
- const wsNote = workspaceId !== void 0 ? "" : await attachToWorkspace(ctx, root, sessionId);
9475
- const viaNote = workspaceId !== void 0 ? ", via-workspaceId(=用户同一形态)" : ", via-cwd+attach";
9476
- const how = `auto-create(${sessionId}${bound.ok ? ", bound" : `, ⚠️ 绑定写入失败(${bound.code})`}${viaNote}${openingNote}${titleNote}${wsNote})`;
9477
- return {
9478
- agent,
9479
- how
9480
- };
9481
- }
9482
9308
  /**
9483
- * 把刚建的载体会话标题**钉成与 `use` 同一形态**(2026-10-21,owner 报「标题不规范」)。
9484
- *
9485
- * ## 为什么单独成函数
9486
- *
9487
- * 它是**全仓第二个**写会话标题的点(第一个是 `use`/`rebuild` 的 `renameDshSessionOnUse`)。
9488
- * 集中在一处,将来若要改命名规则,判据仍在 `namingTitleFor` 一个地方。
9309
+ * tick 周期(**5 分钟**)。
9489
9310
  *
9490
- * ## 三者顺序不可换
9311
+ * 2026-09-15 S142「ACC 侧 autopilot 退场」:本值原从 `autopilot-core.ts` 导入(`TICK_MS`),
9312
+ * 但该模块随 autopilot 退场,而**本模块的唯一消费者是唤醒调度器**(`wake-scheduler`)——
9313
+ * 值本身与 autopilot 无关 ⇒ 随消费者归位到本模块(不再依赖 autopilot 判据层)。
9491
9314
  *
9492
- * 必须在 ③b(投开场白)**之后**调用:
9493
- * - 开场白让 `titleInput.count` > 0,是标题得以成立的前提;
9494
- * - 放在之后还有**降级好处**:万一 `rename` 失败,宿主自己的 fallback 标题仍在
9495
- * (难看但存在),而不是两者皆无。
9315
+ * ⚠️ 语义不得变:该值同时是**报告文案的来源**("等下个 Nmin tick"),历史上曾因文案写死
9316
+ * 10min 与实现 5min 不符而误导排查(§12.7 文案纠错)。
9317
+ */
9318
+ const TICK_MS = 3e5;
9319
+ /**
9320
+ * 挂事件 → 触发 `start()`(**每个事件一次 try/catch 吞异常**:事件通道缺失不阻断时钟)。
9496
9321
  *
9497
- * @returns 回执片段(成功 `, titled` / 失败 `, ⚠️ 标题未写(原因)`)—— **必须出声**,
9498
- * 否则"标题没写"会表现成"这条轨迹就是这名字"(D186:守卫可以拒绝,但不许不出声)。
9322
+ * 为什么吞异常:宿主(headless profile 等)可能没有事件通道;**启动时已 `start()` 过一次**,
9323
+ * 事件只是"跟上面板开关 / 新会话"的热启动补丁,缺它不该让整个装配抛错。
9324
+ * @param on 宿主的 `on`(可能不存在)
9325
+ * @param ctx `on` 的 `this`(宿主 API 常要求绑定)
9326
+ * @param clock 目标时钟
9327
+ * @param events 事件名列表(回调恒为 `() => clock.start()`)
9499
9328
  */
9500
- function renameCarrierTitle(ctx, agent, root, dirName) {
9501
- try {
9502
- const titles = hostService(ctx, "sessionTitle");
9503
- if (!titles || typeof titles.rename !== "function") return ", ⚠️ 标题未写(sessionTitle 不可用)";
9504
- const session = agent.session;
9505
- if (!session || typeof session !== "object") return ", ⚠️ 标题未写(无 session)";
9506
- const title = namingTitleFor({
9507
- sessionId: parseTrajectoryCode(dirName) ?? dirName,
9508
- dirName,
9509
- mdPath: join(root, "AGENT_SESSIONS", dirName, "SESSION.md")
9510
- });
9511
- titles.rename(session, title);
9512
- return ", titled";
9513
- } catch (err) {
9514
- return `, ⚠️ 标题未写(${String(err?.message ?? err)})`;
9515
- }
9329
+ function attachClockEvents(ctx, clock, events) {
9330
+ const on = ctx?.on;
9331
+ if (typeof on !== "function") return;
9332
+ for (const name of events) try {
9333
+ on.call(ctx, name, () => clock.start());
9334
+ } catch {}
9516
9335
  }
9517
9336
  /**
9518
- * 把刚建的会话挂进 **cwd 所属的那个工作区**(2026-10-17 缺陷修复)。
9337
+ * 建一条时钟(**每钟各调一次**——本工厂**不是**单例,调用次数 = 时钟条数)。
9519
9338
  *
9520
- * 🔴 **为什么必须我们自己做(宿主源码取证,不是推断)**:
9521
- * `sessionController.create()` 里 `attachSession` **只在 `workspaceId` 分支执行** ——
9522
- * `dsh-api-session-controller/lib/index.js` 的 `create()`:
9523
- * ```
9524
- * if (request.workspaceId !== void 0) workspace = registry.get(request.workspaceId)
9525
- * const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd
9526
- * ...
9527
- * if (workspace !== void 0) await workspace.attachSession(sessionId) // ← 只有这条分支挂工作区
9528
- * ```
9529
- * 我们传的是 `{ cwd }`(**必须如此**:`workspaceId` 与 `cwd` 互斥,而工作区 id 是宿主的内部 uuid,
9530
- * 我们又得按"这条轨迹住在哪个 CCC"来建会话)⇒ **建出来的会话从不登记进任何工作区**。
9531
- *
9532
- * 🔴 **症状(现场实证,本机真实数据)**:`send-now` 自动建出的那条会话
9533
- * (`note` = `auto-created by send-now`)header `cwd` 确实是某个 CCC 根,
9534
- * **却不在**该工作区的 `sessionIds` 里
9535
- * (同一个 CCC 的另外两条会话都在)⇒ WebUI 里它不属于那个工作区。
9536
- *
9537
- * 🔴 **为什么按 `path` 找而不是"拿第一个"**:多个 CCC 并存(本机实有 5 个工作区)
9538
- * ⇒ 拿第一个必然挂错。判据 = `workspace.path === root`(宿主 `Workspace.path` 是
9539
- * **realpath 规范化**后的规范路径;`root` 已是 `cccRootForCwd` 上溯出来的真实目录,
9540
- * 两侧同一来源,故可直接比)。
9541
- *
9542
- * ⚠️ **失败不阻断投递**:工作区归属影响的是"在 WebUI 里归哪个工作区",
9543
- * 而消息投递已经完成 ⇒ 把原因**如实写进回执**,不吞、也不把成功改成失败。
9544
- *
9545
- * @param ctx 插件上下文
9546
- * @param root CCC 根(= 该会话的 cwd)
9547
- * @param sessionId 刚建出来的会话 id
9548
- * @returns 追加到回执里的短注记(成功/跳过为空串,失败带原因)
9339
+ * 顺序忠实于既有实现:`start()` 先武装并立即跑一次 tick → 再挂事件。
9340
+ * 拆卸由调用方经 `registerDisposer(ctx, label, clock.dispose)` 落到宿主的 `ctx.effect`。
9341
+ * @param opts 见 {@link ClockOptions}(每项对应一条既有的语义差)
9342
+ * @returns 该钟的 `{start, dispose, snapshot, reset}`
9549
9343
  */
9550
- async function attachToWorkspace(ctx, root, sessionId) {
9551
- const workspaces = listWorkspaces(ctx);
9552
- if (workspaces === void 0) return ", ⚠️ 工作区未挂载(workspaceRegistry 不可用)";
9553
- const match = findWorkspaceByPath(workspaces, root);
9554
- if (match === void 0) return "";
9555
- if (typeof match.attachSession !== "function") return ", ⚠️ 工作区未挂载(attachSession 不可用)";
9556
- if (Array.isArray(match.sessionIds) && match.sessionIds.includes(sessionId)) return "";
9557
- try {
9558
- await match.attachSession(sessionId);
9559
- return ", workspace-attached";
9560
- } catch (err) {
9561
- return `, ⚠️ 工作区挂载失败: ${String(err?.message ?? err)}`;
9562
- }
9563
- }
9564
- /** `workspaceRegistry.list()` 的读取收口(不可用返回 `undefined`,**与"空列表"区分**) */
9565
- function listWorkspaces(ctx) {
9566
- const reg = hostService(ctx, "workspaceRegistry");
9567
- if (typeof reg?.list !== "function") return void 0;
9568
- try {
9569
- return reg.list() ?? [];
9570
- } catch {
9571
- return;
9572
- }
9344
+ function createClock(opts) {
9345
+ const runtime = {
9346
+ armed: false,
9347
+ armedAt: null,
9348
+ lastTickAt: null,
9349
+ ticks: 0,
9350
+ lastSkipReason: null
9351
+ };
9352
+ if (opts.logFrom) runtime.lastTickLog = [];
9353
+ const st = {
9354
+ timer: null,
9355
+ chain: Promise.resolve(),
9356
+ runtime,
9357
+ wired: false,
9358
+ opts
9359
+ };
9360
+ /** 记一次 tick(工厂内部与 body 共用同一实现——只有一处会计逻辑) */
9361
+ const countTick = () => {
9362
+ runtime.lastTickAt = Date.now();
9363
+ runtime.ticks += 1;
9364
+ };
9365
+ /** 逐行打印(`logFrom` 缺省 ⇒ 本钟无 tick 日志,整段为死代码路径) */
9366
+ const emit = (lines) => {
9367
+ if (!opts.logFrom) return;
9368
+ for (const line of lines) console.log(`${opts.logFrom.prefix}${line}`);
9369
+ };
9370
+ const tick = () => {
9371
+ if (opts.gate !== void 0 && !opts.gate()) {
9372
+ runtime.lastSkipReason = opts.gateOffReason ?? null;
9373
+ return;
9374
+ }
9375
+ if (opts.begin) try {
9376
+ opts.begin();
9377
+ } catch (err) {
9378
+ console.warn(`[serenity-hooks] ✗ tick 前置异常: ${String(err?.message ?? err)}(${opts.label})`);
9379
+ return;
9380
+ }
9381
+ st.chain = st.chain.then(async () => {
9382
+ try {
9383
+ const value = await opts.body();
9384
+ if (!opts.bodyCountsTick) countTick();
9385
+ if (opts.logFrom) {
9386
+ runtime.lastTickLog = opts.logFrom.lines(value);
9387
+ emit(runtime.lastTickLog);
9388
+ }
9389
+ } catch (err) {
9390
+ const msg = `✗ tick 异常: ${String(err?.message ?? err)}`;
9391
+ if (opts.logFrom) runtime.lastTickLog = [msg];
9392
+ console.warn(`[serenity-hooks] ${msg}(${opts.label})`);
9393
+ }
9394
+ });
9395
+ };
9396
+ /**
9397
+ * 幂等启动 + 武装门:**已武装 → 原样返回**(防重复起定时器);
9398
+ * **闸关 → 不武装**(零资源占用);闸开则**无条件**武装(硬约束二:不判"有无目标")。
9399
+ */
9400
+ const start = () => {
9401
+ if (st.timer) return;
9402
+ if (opts.events && !st.wired) {
9403
+ st.wired = true;
9404
+ attachClockEvents(opts.ctx, api, opts.events);
9405
+ }
9406
+ if (opts.gate !== void 0 && !opts.gate()) return;
9407
+ st.timer = setInterval(tick, TICK_MS);
9408
+ st.timer.unref();
9409
+ runtime.armed = true;
9410
+ runtime.armedAt = Date.now();
9411
+ console.log(opts.startLog());
9412
+ if (opts.immediate !== false) tick();
9413
+ };
9414
+ const dispose = () => {
9415
+ if (st.timer) {
9416
+ clearInterval(st.timer);
9417
+ st.timer = null;
9418
+ }
9419
+ runtime.armed = false;
9420
+ runtime.armedAt = null;
9421
+ opts.onDispose?.();
9422
+ };
9423
+ const api = {
9424
+ start,
9425
+ dispose,
9426
+ snapshot: () => ({
9427
+ enabled: opts.gate === void 0 ? true : opts.gate(),
9428
+ ...runtime,
9429
+ ...runtime.lastTickLog ? { lastTickLog: [...runtime.lastTickLog] } : {}
9430
+ }),
9431
+ noteSkipReason: (reason) => {
9432
+ runtime.lastSkipReason = reason;
9433
+ },
9434
+ countTick,
9435
+ enqueue: (work) => {
9436
+ st.chain = st.chain.then(work, () => void 0).then(() => void 0, (err) => {
9437
+ console.warn(`[serenity-hooks] ✗ 串行任务异常(${opts.label}): ${String(err?.message ?? err)}`);
9438
+ });
9439
+ },
9440
+ reset: () => {
9441
+ if (st.timer) {
9442
+ clearInterval(st.timer);
9443
+ st.timer = null;
9444
+ }
9445
+ runtime.armed = false;
9446
+ runtime.armedAt = null;
9447
+ runtime.lastTickAt = null;
9448
+ runtime.ticks = 0;
9449
+ runtime.lastSkipReason = null;
9450
+ if (opts.logFrom) runtime.lastTickLog = [];
9451
+ st.wired = false;
9452
+ opts.onReset?.();
9453
+ }
9454
+ };
9455
+ return api;
9573
9456
  }
9457
+ //#endregion
9458
+ //#region src/wake/scheduler.ts
9574
9459
  /**
9575
- * 在 `workspaceRegistry.list()` 里找 **path 等于该 CCC 根** 的那一个。
9460
+ * wake/scheduler.ts — 认知轨迹唤醒调度器(D58/D59/D60;S142 2026-09-13)
9576
9461
  *
9577
- * 🔴 **为什么要 realpath 归一(不是画蛇添足)**:宿主的 `Workspace.path` 是
9578
- * **`fs.realpath` 规范化**过的(`entity.d.ts` 逐字:*"Canonical directory path: the
9579
- * `fs.realpath` of the path given at create time (trailing slashes, `..`, and symlinks
9580
- * all resolved)"*)。而 `root` 来自 `cccRootForCwd` 的上溯——**上溯用的是字面路径**。
9581
- * ⇒ 当 CCC 根是一个**符号链接**(部署上常见:家目录/项目目录被链到别处),
9582
- * 两侧**逐字不等**,只比字符串会**挂错或挂不上**。
9462
+ * 一个**中心调度器**(5min tick,复用 autopilot 的节律常量),处理 CCC 唤醒注册表里
9463
+ * 到期的条目:把「未来时刻 + 一条 message」投递给目标认知轨迹,**fire-and-forget**
9464
+ * (无阻塞、无等待、无回执——发起方不 await,调度器不替模型结果负责)。
9583
9465
  *
9584
- * ⚠️ 归一失败(路径不存在等)时**回落字面比较** —— 不改既有语义,只是多给一次机会。
9466
+ * 与 autopilot 的关系(D59):**两个 tick 各自独立**。autopilot 是每 CCC 单例的
9467
+ * 周期自唤醒,逻辑一行不碰;本模块只处理注册表的一次性唤醒条目。
9585
9468
  *
9586
- * @param workspaces registry.list() 的返回
9469
+ * 投递路径(P-1 证据链见 SESSION.md §30.6):
9470
+ * 1. target(S### / 目录名)→ 目录名 + SESSION.md
9471
+ * 2. 目标 dsh 会话 id:`.bindings.json` 里绑定该目录名的会话(按绑定时间倒序,权威)
9472
+ * → 回退:live 会话标题匹配
9473
+ * 3. agent:**live 优先**(`ctx.agents.get`)→ 冷会话走 `ctx.sessionController.resolveAgent`
9474
+ * (宿主实现:live 优先 + resume 去重 + 由会话元数据恢复 preset)
9475
+ * → `sessionController` 缺席 ⇒ 仅投递 live 目标,**不静默**(v1.39.3:**归为"环境未就绪"**
9476
+ * 而非"投递失败",见 `WakeDeliveryResult.notReady` —— 启动那一拍早于懒服务就绪是常态)
9477
+ * 4. 投递:`agent.followup(...)`("Queue an ordinary follow-up turn and wake the driver")
9478
+ *
9479
+ * 守卫(2026-09-21 起**本钟无闸**):🔴 **恒武装**——原 `wakeSchedulerEnabled` 已砍,
9480
+ * 本钟不再有「可被误关的开关」;旧配置残留该键**静默忽略**,且**不**回退 `autopilotEnabled`|
9481
+ * CRO 阶段另有**独立**总闸 `croEnabled`(只停 CRO,**不影响**本注册表投递)|
9482
+ * 补跑窗口 2h(超窗置 missed 留痕)|同 tick 串行|唤起后维持 live(人类可介入)。
9483
+ */
9484
+ /**
9485
+ * 解析条目的 target(`S###` 或完整目录名)→ 目录名 + SESSION.md 路径。
9587
9486
  * @param root CCC 根
9588
- * @returns 命中的工作区,无则 undefined
9487
+ * @param target 条目里的目标标识
9488
+ * @returns 命中返回目录信息,未命中 null
9589
9489
  */
9590
- function findWorkspaceByPath(workspaces, root) {
9591
- const direct = workspaces.find((ws) => ws?.path === root);
9592
- if (direct !== void 0) return direct;
9593
- const canonical = (p) => {
9594
- try {
9595
- return realpathSync(p);
9596
- } catch {
9597
- return p;
9598
- }
9490
+ function resolveWakeTarget(root, target) {
9491
+ const found = findSession(sessionsRoot(root), target);
9492
+ if (!found) return null;
9493
+ const mdPath = join(found.path, "SESSION.md");
9494
+ if (!existsSync(mdPath)) return null;
9495
+ return {
9496
+ dirName: basename(found.path),
9497
+ mdPath
9599
9498
  };
9600
- const want = canonical(root);
9601
- return workspaces.find((ws) => typeof ws?.path === "string" && canonical(ws.path) === want);
9602
9499
  }
9603
9500
  /**
9604
- * 本 tick 要扫的 CCC 根集合(**并集**)。
9501
+ * 🔴 **本钟的全局闸已于 2026-09-21 砍掉**(所有者令逐字:「**send-later 的开关不再重要了,砍掉**」)。
9605
9502
  *
9606
- * C2(S142 2026-09-15 Q5):原实现是 `collectLiveCccs`——**只认 live 会话** ⇒
9607
- * "完全冷掉的 CCC 不能自唤醒自己"(已知边界)。现改为 `ccc-roots.listCccs` 的并集枚举
9608
- * (工作区注册表 ∪ 持久化会话 ∪ live 会话):**没有 live 会话的 CCC 也会被扫到**。
9503
+ * **为什么砍**(R↓):唤醒已从"可选实验能力"长成**全容器的续接绳**(🔵 **术语说明,2026-09-23 补**:
9504
+ * **"绳" = 我们对自己用 `send-later` 给自己排的"定时叫醒"条目的俗称** —— 一条链每轮给自己排下一根,
9505
+ * 链才不断。本仓注释与 SESSION.md 沿用此说法,**它不是产品名、也不是宿主概念**)——S142/S151/S185 的链、
9506
+ * 跨轨迹投递、`send-now`/`send-later` 都走它。留一个"能被误关的开关"的**代价**是
9507
+ * "整条链静默停摆且无人告警"(本仓已实测过同族事故:闸关 6.6h 零 tick、断链 1 天 14 小时);
9508
+ * 而**收益**只剩"关掉一个 5min unref 空检查"。⇒ **收益 < 代价,故删闸**(不是改为缺省开——是**没有这个键了**)。
9609
9509
  *
9610
- * ⚠️ 边界(本步**只**改"枚举哪些 CCC",**不改**"怎么找到要交付的 agent"):
9611
- * 把 message 交给某条认知轨迹仍必须靠 **live 会话**解析(见本文件
9612
- * `acquireWakeAgent`);无 live 会话时该 CCC 只是**被扫到、
9613
- * 无交付对象** ⇒ `deliverWake` 返回 `无绑定会话记录…`,条目回 `pending` 并留痕。
9614
- * 因此本函数**不得**让 tick 抛错(异常一律由调用方 tick 兜住,见 `runWakeTick` 的 try)。
9615
- * @param ctx 插件上下文
9616
- * @returns CCC 根列表(无任何来源 → `[]`)
9510
+ * ⚠️ **兼容**:旧配置文件里残留的 `wakeSchedulerEnabled` **静默忽略**(无回退键、无告警噪声;
9511
+ * 与旧 `bootstrap` 段的处置先例一致)。**机制本身一行未动**(所有者:"我说的只是开关")。
9512
+ *
9513
+ * 历史(勿误读):v1.34 S-1 曾把两闸解耦(周期自唤醒 `autopilotWakeEnabled` / 本钟 `wakeSchedulerEnabled`),
9514
+ * 理由是"关 auto-trajectory 不得连带关一次性唤醒";随 ACC 侧 autopilot 于 v1.35.0 整段退场,
9515
+ * 两闸只剩本钟一座 ⇒ 现已整座移除。详见 `docs/cognitive-trajectory-scheduling.md` §S-1 与 v1.44 CHANGELOG。
9617
9516
  */
9618
- async function collectWakeCccs(ctx) {
9619
- return (await listCccs(ctx)).map((e) => e.root);
9620
- }
9621
9517
  /**
9622
- * CRO 唤起消息正文(**刻意不复用** {@link buildWakeText} / {@link buildSendText})。
9623
- *
9624
- * 三种唤起各有自己的**事实**;混用 = 注入一条假陈述(本容器栽过:`buildSendText` 里
9625
- * 那句"消息已入队"就是一条假陈述,见该函数注释):
9626
- * · `buildWakeText` —— 「**到点**了」(登记过的未来时刻,人/agent 算好的)
9627
- * · `buildSendText` —— 「**有人此刻给我发了一条消息**」
9628
- * · 本函数 —— 「**我自己的程序**看情况判定该叫我了」(**没有到点、也没有人发消息**)
9518
+ * 🔴 **本钟的串行域**(C6b 硬约束):`clock.chain` 是**本实例私有**的——
9519
+ * 与 autopilot 的串行链**零共享**。autopilot 每轮跑 CCC 偏见脚本可能阻塞数十秒,
9520
+ * 共用链会把一次性唤醒一起卡住(这正是 P1 被否、取 P2 的理由,见 SESSION §12.8②)。
9629
9521
  *
9630
- * 🔴 `reason` 的呈现(设计 §4.2):**程序没写就明说没写**,不留空白 ——
9631
- * 改成程序判定后,"当时为什么叫了"**不再能从时间表重建**(原因在程序肚子里);
9632
- * 留空白 = 连"丢在哪一环"都看不出来。写明"未提供"至少把缺口标出来了(CCE:重建 > 保存)。
9633
- * @param dirName 目标轨迹目录名
9634
- * @param mdPath 目标 SESSION.md 绝对路径
9635
- * @param prompt 程序给的唤起提示词
9636
- * @param reason 程序给的判定理由(可为 null)
9637
- * @returns 投递正文
9522
+ * **为什么这里是模块级常量**(而不是 `registerWakeScheduler` 的局部量):本钟的进程态是
9523
+ * **进程级可观测面**(`acc-diag` ①b / `containerClocks` 在读),且 `wakeSchedulerState()` /
9524
+ * `__resetWakeSchedulerStateForTest()` 是**公开导出**、可在未装配时被调用 ⇒ 实例必须**恒在**
9525
+ * (旧实现的 `schedulerRuntime` 也正是模块级常量)。装配只做 `clock.start()`。
9638
9526
  */
9639
- function buildCroWakeText(dirName, mdPath, prompt, reason) {
9527
+ /** 本钟的装配项(`ctx` 由 {@link registerWakeScheduler} 在装配点填入) */
9528
+ const clockOpts = {
9529
+ label: "认知轨迹唤醒调度器",
9530
+ ctx: void 0,
9531
+ events: ["session/created", "serenity/settings-changed"],
9532
+ body: () => runWakeTick(clockOpts.ctx),
9533
+ logFrom: {
9534
+ prefix: "[serenity-hooks] 认知轨迹唤醒 ",
9535
+ lines: (lines) => lines
9536
+ },
9537
+ startLog: () => "[serenity-hooks] ✓ 认知轨迹唤醒调度器启动(5min tick)"
9538
+ };
9539
+ const clock = createClock(clockOpts);
9540
+ /** 调度器进程态快照(只读)。
9541
+ * ⚠️ `enabled` 对**本钟已无意义**(2026-09-21 起无闸 ⇒ 恒 `true`);保留字段只为沿用工厂快照形状。
9542
+ * "这轮为什么没被唤起"现在看 `armed` / `lastSkipReason`(tick 内真实原因)+ **CRO 闸**(见 `croEnabled`)。 */
9543
+ function wakeSchedulerState() {
9544
+ return clock.snapshot();
9545
+ }
9546
+ /**
9547
+ * 取目标 agent:live 优先 → 冷会话走 sessionController.resolveAgent → 标题回退。
9548
+ * @param ctx 插件上下文
9549
+ * @param root CCC 根
9550
+ * @param dirName 目标认知轨迹目录名
9551
+ * @returns 命中返回 agent 与取得方式(how 进 lastResult,便于诊断);未命中返回原因
9552
+ * (原因带 `notReady` = **环境未就绪**而非投递失败,调用方**不得**据此记一次失败)
9553
+ *
9554
+ * 🔴 **前置条件:本轨迹名下必须有一条「未取代」的绑定记录**(2026-09-20 回源码答复 S151 实测):
9555
+ * 选人是**遍历 `listBoundSessionIds(root, dirName)` 给出的 id**(按绑定时间倒序),**live 只是这条
9556
+ * 记录的一个加分项**——「绑定里的那条恰好 live ⇒ 直接注入」,否则走冷载入。
9557
+ * ⇒ **「有 live 会话」≠「有绑定」**:会话活着但该轨迹**零条未取代绑定**时,本函数**直接返回
9558
+ * `无绑定会话记录…`**(`send-now` / `send-later` 到点投递同此判据)。
9559
+ * 实证(2026-09-20):S142 三条旧绑定于 **08:03** 被 `supersededAt` 取代(D69「同轨迹只留一条」),
9560
+ * 而当前载体的绑定**直到 17:05 那次 rebuild 才写入**(`queueRebuild` 内的 `appendBound(action:'rebuild')`)
9561
+ * ⇒ **10:42 的 `send-now` 落在这个窗口里** ⇒ 报"无绑定会话记录"(当时确有 live 会话)。
9562
+ * ⚠️ 已知**注释漂移**:本行上方"→ 标题回退"在函数体内**没有对应分支**(标题回退属 v1.29.2 R2,
9563
+ * 现行实现已只认绑定);**仅登记,未改**(改它属 ACC 代码改动,须具名令)。
9564
+ */
9565
+ async function acquireWakeAgent(ctx, root, dirName) {
9566
+ const agents = hostAgents(ctx);
9567
+ const ids = listBoundSessionIds(root, dirName, hostAliveProbe(ctx));
9568
+ for (const id of ids) {
9569
+ const live = agents?.get?.(id);
9570
+ if (live) return {
9571
+ agent: live,
9572
+ how: `live(bound ${id})`
9573
+ };
9574
+ }
9575
+ if (ids.length > 0) {
9576
+ const sc = hostService(ctx, "sessionController");
9577
+ const resolved = sc?.resolveAgent;
9578
+ if (typeof resolved !== "function") return {
9579
+ error: `宿主尚未就绪:目标会话未加载,且 sessionController 服务此刻不可用(重启后的启动窗口,或本进程确实无该服务)——绑定 id: ${ids[0]}`,
9580
+ notReady: true
9581
+ };
9582
+ const id = ids[0];
9583
+ try {
9584
+ const out = await resolved.call(sc, id);
9585
+ if (out?.agent) return {
9586
+ agent: out.agent,
9587
+ how: `cold-resume(${id})`
9588
+ };
9589
+ return { error: `sessionController.resolveAgent 未返回 agent(${id}): ${String(out?.error ?? "未知原因")}` };
9590
+ } catch (err) {
9591
+ return { error: `冷会话载入失败(${id}): ${String(err?.message ?? err)}` };
9592
+ }
9593
+ }
9594
+ return {
9595
+ /**
9596
+ * 🔴 **文案改指真相源(2026-10-23,guard 实测报障)**:原写「`AGENT_SESSIONS/.bindings.json` 中无 X」,
9597
+ * 而 D172 之后绑定真相源 = **`<轨迹目录>/current.dsh_session_bind`**,`.bindings.json`
9598
+ * **已退役且不在盘上**(实测不存在)⇒ 照旧文案排查的人会去翻一个**不存在的文件**。
9599
+ */
9600
+ error: `无绑定会话记录(AGENT_SESSIONS/${dirName}/current.dsh_session_bind 不存在或无有效会话)——目标轨迹需先被 cognitive_trajectory use 激活过,或由 send-now 自动创建载体`,
9601
+ noBinding: true
9602
+ };
9603
+ }
9604
+ /** 唤醒消息(唤起即投递正文:身份锚定 + 唤醒信息 + 任务) */
9605
+ function buildWakeText(entry, target) {
9640
9606
  return [
9641
- `[CRO 唤起] ${dirName} 自带的 continuous-re-occurrence.ts 判定:现在该唤起。`,
9607
+ `[认知轨迹唤醒] 到点(登记于 ${entry.createdAt},发起者 ${entry.createdBy || "未知"},id ${entry.id})。`,
9642
9608
  `当前时间:${localHuman()}(当地时区)。`,
9643
9609
  "",
9644
- `身份锚定:继续 ${dirName} 的认知轨迹(SESSION.md: ${mdPath})。`,
9645
- "",
9646
- `判定理由:${reason ?? "(程序未提供 reason —— 见 CRO 编写指南 §7 坑 3)"}`,
9610
+ `身份锚定:继续 ${target.dirName} 的认知轨迹(SESSION.md: ${target.mdPath})。`,
9647
9611
  "",
9648
- "唤起提示词:",
9649
- prompt,
9612
+ "唤醒信息:",
9613
+ entry.message,
9650
9614
  "",
9651
- "说明:这是由**本轨迹自己的 CRO 程序**按当时情况做的判定(**不是**定时唤醒,**也不是**别人发的消息)。处理完照常把进展写入 SESSION.md。"
9615
+ "任务:按上述唤醒信息继续本轨迹的工作;完成后把进展写入 SESSION.md。"
9652
9616
  ].join("\n");
9653
9617
  }
9654
9618
  /**
9655
- * 投递一条 CRO 唤起(**fire-and-forget**,与 {@link deliverWake} 同层)。
9656
- *
9657
- * · **取用通路复用** {@link acquireWakeAgent}(live 优先 → 冷载入):这是调度器侧唯一的
9658
- * "怎么把一句话送到一条轨迹"的知识 ⇒ 不另立第二条(设计 §9-2 的倾向:复用投递面)。
9659
- * · 🔴 **不落唤醒表**(设计 §6.3):CRO 是"**持续判定**",不是"**某个未来时刻**"。
9660
- * 落表会给 §0Q(投递即删、表只保在办)**增加一个新的写入源**。
9661
- * · 用 `followup`(不用 `steer`):CRO 由**调度器**发起,与 `send-later` 同层;
9662
- * `steer`(当场注入当前轮)是 `send-now` 的语义 —— 那是人类/agent 的**即时动作**。
9663
- * · 🔴 **失败不回滚、不重试**(与条目投递不同):下一 tick 会**重新判定**——
9664
- * CRO 的判据是"情况",不是"这条消息还没送出去" ⇒ 自愈来自**重判**,不来自重投。
9619
+ * 投递一条唤醒(fire-and-forget:`followup` 入队即返回,不等模型结果)。
9665
9620
  * @param ctx 插件上下文
9666
9621
  * @param root CCC 根
9667
- * @param dirName 目标轨迹目录名(= CRO 只能唤起**自己**,设计 §9-8 的倾向)
9668
- * @param prompt 唤起提示词
9669
- * @param reason 判定理由
9670
- * @returns 投递结果(进 tick 日志;**不写注册表**)
9622
+ * @param entry 唤醒条目
9623
+ * @returns 投递结果(写回 lastResult)
9671
9624
  */
9672
- async function deliverCroWake(ctx, root, dirName, prompt, reason) {
9673
- const mdPath = join(sessionsRoot(root), dirName, "SESSION.md");
9674
- let acquired = await acquireWakeAgent(ctx, root, dirName);
9675
- /**
9676
- * 🔴 **载体自举(2026-10-23,guard 实测报障后补)**
9677
- *
9678
- * ## 缺陷(真实代价)
9679
- *
9680
- * 一条「**生来就是为了被自己的 CRO 叫醒**」的轨迹,**第一次自唤醒在物理上不可能**:
9681
- * 它是程序化 `create` 出来的 ⇒ 没有载体 ⇒ `noBinding` ⇒ 投递被拒
9682
- * ⇒ **恰恰在最需要被叫醒的那一刻没有载体可注入**。实测证据:
9683
- * `cro-wake-log.log` 首行 `fail ... 无绑定会话记录`,而**那一轮的 CRO 判定完全正确**。
9684
- *
9685
- * ## 为什么可以自举,以及它与 `send-now` 的分支差在哪
9686
- *
9687
- * `send-now` 的自举理由是「完成一次**人/agent 的**意图」;CRO 是**每 5min 自动重判**,
9688
- * 若照搬会有「**载体爆炸**」风险。故本路径**多一道闸**:
9689
- *
9690
- * - 只认 `carrierState === 'never'`(**从未有过载体**)⇒ 建一次;
9691
- * - `occupied`(有过但那个会话不在了)⇒ **一律不建**,如实报错。
9692
- *
9693
- * ⇒ 自举**至多发生一次**(建完就有 `current.dsh_session_bind`,此后 `carrierState` 不再是 `never`)。
9694
- * 这正是 `carrierState` 那道守卫的语义,与 `send-now` 共用同一条判据(不另立)。
9695
- *
9696
- * ⚠️ 只处理 `noBinding`;`notReady`(启动窗口)与冷载入失败**照原样返回** ——
9697
- * 那些不是"没载体",建新载体解决不了且可能制造第二条。
9698
- */
9699
- if ("error" in acquired && acquired.noBinding === true) {
9700
- const carrier = carrierState(root, dirName, hostAliveProbe(ctx));
9701
- if (carrier.state === "never") {
9702
- const created = await createAndBindTarget(ctx, root, dirName);
9703
- if (!("error" in created)) {
9704
- acquired = created;
9705
- console.log(`[serenity-hooks] CRO 载体自举:${dirName} 从未有过载体 ⇒ 已自动创建并绑定(${created.how})`);
9706
- }
9707
- } else
9708
- /**
9709
- * 🔴 **拒绝自举时必须指名是哪一半**(D186:守卫可以拒绝,但不许不出声)。
9710
- *
9711
- * 若照原样把 `acquireWakeAgent` 的 `noBinding` 文案透传出去,`cro-wake-log` 里
9712
- * 「`current.dsh_session_bind` 不存在或无有效会话」**两种状态长得一模一样**:
9713
- * · `never` —— 那条轨迹**生来就没有载体**(本次已自举,不会走到这里);
9714
- * · `occupied` —— **有过载体、那个会话现在不在了**(本分支拒绝自建)。
9715
- * ⇒ 读日志的人分不出"该去救那个会话"还是"该去查为什么没建起来",
9716
- * 而这正是本仓反复付代价的那类缺陷(D131:不靠一句话给状态命名,但**状态不同必须说出来**)。
9717
- */
9718
- acquired = { error: carrier.state === "occupied" ? `CRO 唤起未投递:${dirName} 曾经由会话 ${carrier.sessionId} 承载,而它此刻不在了 ⇒ **拒绝自动新建载体**(一条轨迹至多一个载体,而 CRO 每 5min 重判一次,自建会让载体反复膨胀)。\n · 要恢复:把那个会话救回来,或用 cognitive_trajectory rebuild 显式换代(takeover)。` : `CRO 唤起未投递:${dirName} 的载体声明指向会话 ${carrier.sessionId},它按探针**仍可用**,而选人路径却报"无绑定" ⇒ **两处读数打架,不新建载体**(先查绑定文件与归档集)。` };
9719
- }
9625
+ async function deliverWake(ctx, root, entry) {
9626
+ const target = resolveWakeTarget(root, entry.target);
9627
+ if (!target) return {
9628
+ ok: false,
9629
+ detail: `目标认知轨迹未命中(${entry.target})`
9630
+ };
9631
+ const acquired = await acquireWakeAgent(ctx, root, target.dirName);
9720
9632
  if ("error" in acquired) return {
9721
9633
  ok: false,
9722
9634
  detail: acquired.error,
@@ -9726,996 +9638,1172 @@ async function deliverCroWake(ctx, root, dirName, prompt, reason) {
9726
9638
  acquired.agent.followup(createUserMessage({
9727
9639
  content: [{
9728
9640
  type: "text",
9729
- text: buildCroWakeText(dirName, mdPath, prompt, reason)
9641
+ text: buildWakeText(entry, target)
9730
9642
  }],
9731
9643
  source: PLUGIN_SOURCE
9732
9644
  }));
9733
9645
  } catch (err) {
9734
9646
  return {
9735
9647
  ok: false,
9736
- detail: `CRO 投递失败(${acquired.how}): ${String(err?.message ?? err)}`
9648
+ detail: `投递失败(${acquired.how}): ${String(err?.message ?? err)}`
9737
9649
  };
9738
9650
  }
9739
9651
  return {
9740
9652
  ok: true,
9741
- detail: `已投递(${acquired.how})`
9653
+ detail: `已投递 ${target.dirName}(${acquired.how})`
9742
9654
  };
9743
9655
  }
9744
9656
  /**
9745
- * CRO 阶段:本 tick 对**每条启用了 CRO 的轨迹**跑一次它自带的程序,按判定投递。
9657
+ * 即时消息正文(**刻意不复用** {@link buildWakeText}):唤醒文本以「**到点**(登记于…)」开头,
9658
+ * 对即时投递那是**错的事实**。本函数显式区分"这是即时消息、不是到点唤醒"。
9746
9659
  *
9747
- * ## 为什么放在既有投递**之后**(R↓)
9748
- * · 既有条目(`send-later`)**有到点承诺** ⇒ 优先级天然更高;
9749
- * · CRO 是"看着情况叫",**晚一拍无成本**(tick 粒度本就是 5min);
9750
- * · ⇒ 铁律(设计 §5):**CRO 的任何失败都不影响既有链路** —— 整段由调用方兜住,
9751
- * 且**每条轨迹各自 try/catch**(一条坏程序不得连累同 tick 的其它轨迹)。
9660
+ * ⚠️ **v1.39.2 起"已入队"这句话必须删掉**(S142 §0r,所有者令「不排队、直接即时注入」):
9661
+ * 旧文案写"消息已进入你的队列;你此前若正在跑轮次,它在本轮结束后生效"——
9662
+ * 那是**旧的 followup 语义**;现在在跑的目标走 `steer`(当场注入当前轮),
9663
+ * 留着这句话就是**注入一条假陈述**(比没有更坏:目标会误判自己看到它的时机)。
9664
+ * @param target 目标轨迹(目录名 + SESSION.md)
9665
+ * @param message 递送正文
9666
+ * @param sender 发起者轨迹标识(供目标与人类识别来源)
9667
+ */
9668
+ function buildSendText(target, message, sender) {
9669
+ return [
9670
+ `[即时消息] 来自 ${sender || "未知轨迹"}。当前时间:${localHuman()}(当地时区)。`,
9671
+ "",
9672
+ `身份锚定:继续 ${target.dirName} 的认知轨迹(SESSION.md: ${target.mdPath})。`,
9673
+ "",
9674
+ "消息:",
9675
+ message,
9676
+ "",
9677
+ "说明:这是**即时投递**(不是到点唤醒)——它**不排队**:目标正在跑轮次就**当场注入当前轮**,目标空闲则立即起一轮。处理完照常把进展写入 SESSION.md。"
9678
+ ].join("\n");
9679
+ }
9680
+ /**
9681
+ * 即时投递(`cognitive_trajectory send-now`)——与 `send-later`(预约)**共用取用通路**(`acquireWakeAgent`),
9682
+ * 区别只在**时刻**(现在 vs 未来)与**回执**(有 vs 无):
9683
+ * · `send-later`(原 `wake-later`):未来时刻 + 一条 message → 落注册表 → 本文件的调度器到点投递(**fire-and-forget,无回执**)
9684
+ * · `send-now`(原 `send-message`):**此刻**把一条 message 递给目标 → **同步回执** → **不落注册表**
9685
+ *
9686
+ * **为什么冷路径落在这里(R↓,2026-09-16 所有者裁决)**:`addWake` 显式拒绝 `at ≤ now`
9687
+ * ("时刻必须在未来"——那是"**预约**"语义的地基,不许放宽)⇒ 即时投递**不能**靠一条
9688
+ * `at=now` 的注册表条目实现(那还会让调度器把一次性即时投递当 `pending` **每 tick 重试**)。
9689
+ * 必须直接走 {@link acquireWakeAgent}(live 优先 → `sessionController.resolveAgent` 冷载入)。
9690
+ * 这正对应所有者要的「**会话不活跃则等效于直接 wake**」:**同一条冷载入通路**,只是**不等 tick**。
9691
+ *
9692
+ * ⚠️ **承诺边界(不许含糊)**:回执只到「**已注入 / 已起轮**」(宿主调用未抛错),
9693
+ * **到不了**"目标已执行/已答复" —— 与 `deliverWake` 同一层。
9694
+ * 判"目标真的动了"必须用**文件级判据**(它自己的 SESSION.md),
9695
+ * **不得凭回执结案**(§12.B 的 `delivered ≠ 跑了一轮`)。
9696
+ *
9697
+ * 🔴 **v1.39.2 语义变更(S142 §0r,所有者 2026-09-17 令:「希望 send message 时不排队,
9698
+ * 可以直接作为提示词即时注入进去」)**:
9699
+ * 旧实现一律 `followup`(宿主注释:*Queue an ordinary follow-up turn*)⇒ 目标**正在跑轮次**时
9700
+ * 消息**排在轮次边界**(即旧文案的"不打断当前轮")。现按宿主 `agent.status` 分流:
9701
+ * · `status === 'idle'` ⇒ `followup`(宿主**同步**把 status 置 running ⇒ **立即起轮**,本就不排队)
9702
+ * · 否则(在跑) ⇒ **`steer`** —— **当场注入当前轮**,这才是"不排队"的关键路径
9703
+ * ⚠️ **fail-safe**:`steer` 抛错 ⇒ **退回 `followup`**:**宁可排队,不可静默丢消息**。
9704
+ * ⚠️ **只改 `send-now`**:`send-later`({@link deliverWake})**保持 `followup`** ——
9705
+ * 预约的本职是"到点唤起",且在跑时排队不打断是它被实证验收过的行为,不顺手动它。
9706
+ *
9707
+ * **归属**:本函数放在调度器侧而非工具侧 —— "怎么把一句话送到一条轨迹"是**调度器的知识**,
9708
+ * 工具只该知道"有这个动作"。这样 `cognitive-trajectory.ts` 完全不碰调度器内部(v1.35.0 硬约束①:
9709
+ * `send-later` 链路**一行为不动**,本次改动 = **纯新增**)。
9752
9710
  *
9753
- * ## 日志口径(刻意与既有投递不同)
9754
- * "每 tick × 每条轨迹"都写一行 ⇒ 对"**常态是不唤起**"的 CRO 就是刷屏。
9755
- * ⇒ 逐条只记 **唤起**(`reason` 进日志 —— 设计 §4.2 要求可重建)与 **跳过**(异常,必须看见);
9756
- * **不唤起只进汇总行**(`唤起 x,不唤起 y,跳过 z`)。
9757
- * ⚠️ **诚实标注**:因此"某条轨迹当轮**为何没叫**"**不进 tick 日志**(它在程序肚子里)。
9758
- * 要完整的逐次判定审计,须**程序自己**写状态文件(设计 §3.3)——ACC 给不了。
9759
9711
  * @param ctx 插件上下文
9760
9712
  * @param root CCC 根
9761
- * @param registry 本 tick 已载入的唤醒注册表条目(供快照的 `pendingWakes`;读坏时传 `[]`)
9762
- * @param log 本 tick 的人读摘要(就地追加)
9713
+ * @param target `S###` 或 AGENT_SESSIONS 目录名
9714
+ * @param message 递送正文
9715
+ * @param sender 发起者轨迹标识
9716
+ * @returns 投递结果(**同步**返回给调用方)
9763
9717
  */
9764
- async function runCroPhase(ctx, root, registry, log) {
9765
- if (readSimpleSettings().croEnabled === false) {
9766
- log.push("CRO 阶段:已关闭(croEnabled=false)——既有唤醒投递不受影响");
9767
- return;
9768
- }
9769
- let dirs;
9770
- try {
9771
- dirs = listCroCognitiveTrajectories(root);
9772
- } catch {
9773
- return;
9774
- }
9775
- if (dirs.length === 0) return;
9776
- const nowMs = Date.now();
9777
- const s = wakeSchedulerState();
9778
- const scheduler = {
9779
- armed: s.armed,
9780
- enabled: s.enabled,
9781
- ticks: s.ticks,
9782
- lastSkipReason: s.lastSkipReason
9718
+ async function sendToCognitiveTrajectory(ctx, root, target, message, sender) {
9719
+ const t = resolveWakeTarget(root, target);
9720
+ if (!t) return {
9721
+ ok: false,
9722
+ detail: `目标认知轨迹未命中(${target})`
9783
9723
  };
9784
- const agents = hostAgents(ctx);
9785
- const selfHost = safeHostname();
9786
- let hostSkips = 0;
9787
- let wakes = 0;
9788
- let quiet = 0;
9789
- let skips = 0;
9790
- for (const dirName of dirs) try {
9791
- const verdict = resolveCroHostBinding(root, dirName, selfHost);
9792
- if (!verdict.allowed) {
9793
- hostSkips += 1;
9794
- log.push(`· ${root}: ${dirName}: CRO 机器绑定未通过(${verdict.code}):${verdict.detail}`);
9795
- continue;
9796
- }
9797
- const bound = listBoundSessionIds(root, dirName, hostAliveProbe(ctx));
9798
- const running = runningCarriersOf(bound, nowMs);
9799
- const outcome = await evaluateCro(root, dirName, readCroSnapshotInput(root, dirName, nowMs, {
9800
- boundSessionIds: bound,
9801
- liveSessionIds: bound.filter((id) => Boolean(agents?.get?.(id))),
9802
- runningSessionIds: running,
9803
- pendingWakes: registry.filter((e) => e.state === "pending" && resolveWakeTarget(root, e.target)?.dirName === dirName).map((e) => ({
9804
- id: e.id,
9805
- at: e.at,
9806
- createdAt: e.createdAt,
9807
- createdBy: e.createdBy
9808
- })),
9809
- scheduler
9810
- }));
9811
- if (outcome.status === "wake") {
9812
- wakes += 1;
9813
- const res = await deliverCroWake(ctx, root, dirName, outcome.prompt, outcome.decision.reason);
9814
- log.push(`· ${root}: ${renderCroOutcome(dirName, outcome)} → ${res.ok ? "已投递" : `投递未成功:${res.detail}`}`);
9815
- {
9816
- const rec = appendCroWakeLog(root, dirName, {
9817
- ok: res.ok,
9818
- ...res.notReady === true ? { notReady: true } : {},
9819
- detail: res.detail,
9820
- tick: s.ticks,
9821
- reason: outcome.decision.reason ?? null,
9822
- prompt: outcome.prompt
9823
- }, nowMs);
9824
- if (!rec.ok) log.push(`· ${root}: ${dirName}: CRO 流水写入失败(已忽略,不影响投递): ${rec.error}`);
9724
+ let acquired = await acquireWakeAgent(ctx, root, t.dirName);
9725
+ let createdNote = "";
9726
+ if ("error" in acquired) {
9727
+ if (!acquired.notReady && acquired.noBinding === true) {
9728
+ const carrier = carrierState(root, t.dirName, hostAliveProbe(ctx));
9729
+ if (carrier.state !== "never") {
9730
+ const why = carrier.state === "occupied" ? `该轨迹曾经由会话 ${carrier.sessionId} 承载,而它此刻不在了` : `该轨迹当前由会话 ${carrier.sessionId} 承载`;
9731
+ return {
9732
+ ok: false,
9733
+ detail: `${t.dirName}:${why} ⇒ **拒绝自动新建载体**(一条轨迹至多一个载体)。\n · 要让它重新跑起来:把那个会话恢复,或用 cognitive_trajectory rebuild 换代(显式 takeover)。\n · 不明所以就先 cognitive_trajectory show ${t.dirName} 看它的交代文件。`
9734
+ };
9825
9735
  }
9826
- } else if (outcome.status === "skipped") {
9827
- skips += 1;
9828
- log.push(`· ${root}: ${renderCroOutcome(dirName, outcome)}`);
9829
- } else if (outcome.status === "no-wake") quiet += 1;
9830
- } catch (err) {
9831
- skips += 1;
9832
- log.push(`· ${root}: ${dirName}: CRO 内部异常(已忽略,不影响既有链路): ${String(err?.message ?? err)}`);
9833
- }
9834
- log.push(`· ${root}: CRO 评估 ${dirs.length} 条(唤起 ${wakes},不唤起 ${quiet},跳过 ${skips}${hostSkips > 0 ? `,非本机绑定 ${hostSkips}` : ""})`);
9835
- }
9836
- /**
9837
- * 取本机机器名(**永不抛**)。
9838
- *
9839
- * `os.hostname()` 理论上可能抛(极少数环境)⇒ 这里兜底成空串。
9840
- * ⚠️ **空串的后果是"全部不通过"**(`bound === self` 永不成立)⇒ 即 **fail-closed**,
9841
- * 与所有者裁定同向(宁可都不跑,也不会"名字取不到就随便跑")。
9842
- * @returns 本机机器名;取不到 ⇒ `''`
9843
- */
9844
- function safeHostname() {
9845
- try {
9846
- return hostname();
9847
- } catch {
9848
- return "";
9849
- }
9850
- }
9851
- /** 一次调度 tick:遍历已知 CCC → 到期项串行投递 / 超窗项置 missed;返回人读摘要 */
9852
- async function runWakeTick(ctx) {
9853
- const log = [];
9854
- const roots = await collectWakeCccs(ctx).catch(() => []);
9855
- if (roots.length === 0) {
9856
- clock.noteSkipReason("无已知 CCC 可扫(等某个会话/工作区出现)");
9857
- return log;
9736
+ acquired = await createAndBindTarget(ctx, root, t.dirName);
9737
+ if ("error" in acquired) return {
9738
+ ok: false,
9739
+ detail: acquired.error
9740
+ };
9741
+ createdNote = `;目标原本无活跃 dsh 会话 ⇒ **已自动创建并绑定**一条(${acquired.how})`;
9742
+ } else return {
9743
+ ok: false,
9744
+ detail: acquired.error
9745
+ };
9858
9746
  }
9859
- clock.noteSkipReason(null);
9860
- for (const root of roots) {
9861
- const purged = purgeFinalizedWakes(root);
9862
- if (purged.ok && purged.purged > 0) log.push(`· ${root}: 清除 ${purged.purged} 条已结案记录(存量首清)`);
9863
- const { registry, error } = loadWakeRegistry(root);
9864
- if (error) {
9865
- log.push(`✗ ${root}: ${error}`);
9866
- await runCroPhase(ctx, root, [], log);
9867
- continue;
9868
- }
9869
- const { due, expired } = splitDueWakes(registry, Date.now(), WAKE_CATCH_UP_MS);
9870
- for (const e of expired) {
9871
- finalizeWake(root, e.id, "missed", `超过补跑窗口(${WAKE_CATCH_UP_MS / 36e5}h)未投递`);
9872
- log.push(`· ${root}: ${e.id} → missed(超补跑窗口,已清除)`);
9747
+ const agent = acquired.agent;
9748
+ const make = () => createUserMessage({
9749
+ content: [{
9750
+ type: "text",
9751
+ text: buildSendText(t, message, sender)
9752
+ }],
9753
+ source: PLUGIN_SOURCE
9754
+ });
9755
+ const freshCarrier = createdNote !== "";
9756
+ if (freshCarrier || agent.status === "idle") {
9757
+ try {
9758
+ agent.followup(make());
9759
+ } catch (err) {
9760
+ return {
9761
+ ok: false,
9762
+ detail: `投递失败(${acquired.how}): ${String(err?.message ?? err)}`
9763
+ };
9873
9764
  }
9874
- for (const e of due) {
9875
- const res = await deliverWake(ctx, root, e);
9876
- if (res.notReady) {
9877
- log.push(`· ${root}: ${e.id} → 跳过(宿主服务未就绪,下个 tick 再试;不计失败)`);
9878
- continue;
9879
- }
9880
- if (res.ok) {
9881
- finalizeWake(root, e.id, "delivered", res.detail);
9882
- log.push(`· ${root}: ${e.id} → delivered(已清除)`);
9883
- } else {
9884
- updateWake(root, e.id, {
9885
- state: "pending",
9886
- attempts: e.attempts + 1,
9887
- lastResult: res.detail
9888
- });
9889
- log.push(`· ${root}: ${e.id} → 重试(${res.detail})`);
9890
- }
9765
+ return {
9766
+ ok: true,
9767
+ detail: `已即时投递 ${t.dirName}(${acquired.how};${freshCarrier ? "刚建的载体 ⇒ followup 排队,锚定先于任务" : "目标空闲 ⇒ followup 立即起轮"})${createdNote}`
9768
+ };
9769
+ }
9770
+ try {
9771
+ agent.steer(make());
9772
+ return {
9773
+ ok: true,
9774
+ detail: `已即时注入 ${t.dirName}(${acquired.how};目标在跑 ⇒ steer 当场注入当前轮)${createdNote}`
9775
+ };
9776
+ } catch (err) {
9777
+ try {
9778
+ agent.followup(make());
9779
+ } catch (err2) {
9780
+ return {
9781
+ ok: false,
9782
+ detail: `投递失败(${acquired.how}): steer 与 followup 均失败: ${String(err?.message ?? err)} / ${String(err2?.message ?? err2)}`
9783
+ };
9891
9784
  }
9892
- await runCroPhase(ctx, root, registry.entries, log);
9785
+ return {
9786
+ ok: true,
9787
+ detail: `已投递 ${t.dirName}(${acquired.how};steer 不可用 ⇒ 退回 followup): ${String(err?.message ?? err)}${createdNote}`
9788
+ };
9893
9789
  }
9894
- return log;
9895
- }
9896
- /**
9897
- * 装配调度器(index.ts apply 调用):5min tick + 生命周期 disposer +
9898
- * 会话出现/settings 变化时热启动(与 autopilot 同款触发面,互不干扰)。
9899
- * @param ctx 插件上下文
9900
- */
9901
- function registerWakeScheduler(ctx) {
9902
- clockOpts.ctx = ctx;
9903
- clock.start();
9904
- registerDisposer(ctx, "认知轨迹唤醒调度器", () => clock.dispose());
9905
9790
  }
9906
- //#endregion
9907
- //#region src/cro/guide.ts
9908
9791
  /**
9909
- * cro/guide.ts — CRO 编写指南(**ACC 内置**,S142 §7.8,2026-09-19)
9792
+ * 🆕 **给一条「从未被激活过」的轨迹就地建一条 dsh 会话并绑定它**(2026-10-16 owner 令,S142)。
9910
9793
  *
9911
- * ## 为什么指南在 ACC 而不在 CCC(所有者令)
9794
+ * 何时用:**只有** `send-now` 在 `acquireWakeAgent` 报「无绑定会话记录」时(见该处的判据说明)。
9912
9795
  *
9913
- * 所有者 2026-09-19:「**ACC 内置,整个机制属于 ACC,CCC 是用户**」。
9914
- * ⇒ 契约 / 调度 / 执行 / 状态暴露 / 容错 / **编写指南** 全在 ACC;
9915
- * CCC(用户)只负责**写那段程序**。
9796
+ * ## 🔴 2026-10-18 根治:改成**和用户操作完全同一个调用形态**
9916
9797
  *
9917
- * ## 🔴 单真相源纪律(本文件的核心约束)
9798
+ * owner 原话:「应当和用户操作唤起的会话完全一样才行」。
9918
9799
  *
9919
- * 指南**不复制**契约条款,且**样例快照是"派生"而非"手抄"**:
9920
- * {@link CRO_SAMPLE_SNAPSHOT} 由 {@link buildCroSnapshot} **真正装配**出来 ⇒
9921
- * **schema 一变,样例自动跟着变**,不存在"文档里的样例过期"这一失效模式。
9922
- * (本容器栽过多次"第二真相源",此处用代码结构而不是纪律去防它。)
9800
+ * 上一版(v1.62.1)传的是 `{ cwd: root }`,然后**事后补** `attachSession()` 去挂工作区。
9801
+ * 那是**补救**,不是"一样" —— 宿主里那条路径(`sessionController.create` 的
9802
+ * `if (workspace !== void 0) await workspace.attachSession(sessionId)`)**只在 `workspaceId` 分支执行**:
9923
9803
  *
9924
- * ## 所有者对本指南的两条明示要求(2026-09-19)
9804
+ * ```js
9805
+ * if (request.workspaceId !== void 0) workspace = registry.get(request.workspaceId)
9806
+ * const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd
9807
+ * ...
9808
+ * if (workspace !== void 0) await workspace.attachSession(sessionId) // ← 只有这条分支
9809
+ * ```
9925
9810
  *
9926
- * 1. 🔴 **必须备注:CRO 程序是可自测的;测试通过再把文件名改成正式名**;
9927
- * 2. 🔴 **要一并给出测试用的参数**。
9928
- */
9929
- /**
9930
- * 样例快照输入 —— **代表性数据**(不是真实某条轨迹的现状)。
9811
+ * 而**用户在 UI 里新建会话传的正是 `workspaceId`**(见 `client/sessions/service.d.ts` 的
9812
+ * `create({ workspaceId?, cwd?, ... })`)⇒ 用户那条路天然会走到 `attachSession`。
9931
9813
  *
9932
- * 取值刻意让它能演示所有者举的"天亮/有人/非高峰 + 已在干活 + 日志过大"场景。
9933
- * 🔒 **路径与轨迹名一律用中性占位**(`/home/user/workspace`、`<S###>` 式示例),
9934
- * 不写任何真实机器路径或私有会话名 —— 本文件随 npm 包发布(2026-10-14 隐私核查)。
9814
+ * ⇒ 现在与用户**同一条分支**:先由 CCC 根查出 `workspaceId`,再用 `{ workspaceId }` 建。
9815
+ * **`attachToWorkspace` 降级为兜底**(只在"该目录还没有工作区登记"时才会用上),
9816
+ * 不再是主路径 —— 因为主路径已经由宿主自己做完。
9817
+ *
9818
+ * ## 三步(顺序不可换)
9819
+ * ① `sessionController.create({ workspaceId })` —— 与用户同一形态;宿主替我们挂工作区。
9820
+ * **idempotently adopt** ⇒ 重复调用不会建出第二条(不必自造"先查再建"的竞态窗口)。
9821
+ * ② `resolveAgent(sessionId)` —— 拿到**真 agent**。`create` 只返回 id,不给 agent。
9822
+ * ③ `appendBound(...)` —— 落绑定。**必须由本函数写**:`listBoundSessionIds` 是
9823
+ * `acquireWakeAgent` 的唯一选人依据,不写绑定则下一条消息**还是**找不到它
9824
+ * (那就成了"建了但永远用不上",比不建更坏)。
9825
+ *
9826
+ * 🔴 **为什么定位到 CCC 根这一级、不是轨迹目录**:会话的 cwd 决定它落在哪个 CCC
9827
+ * (`cccRootForCwd` 上溯 `.serenity`)。目标轨迹本就住在该 CCC 的 `AGENT_SESSIONS/` 下
9828
+ * ⇒ 落到 CCC 根才让它**进同一个 CCC**;落到轨迹目录会让它变成一个**不同 CCC**的会话。
9829
+ *
9830
+ * @returns 命中返回 agent 与取得方式;任一步失败返回**带原因**的 error(不静默、不误报成功)
9935
9831
  */
9936
- const CRO_SAMPLE_SNAPSHOT_INPUT = {
9937
- dirName: "2026-01-01--S001--example-cognitive-trajectory",
9938
- cccRoot: "/home/user/workspace",
9939
- nowMs: 17898e8,
9940
- sessionMdPath: "/home/user/workspace/AGENT_SESSIONS/2026-01-01--S001--example-cognitive-trajectory/SESSION.md",
9941
- sessionMdBytes: 182e3,
9942
- sessionMdMtimeMs: 1789799e6,
9943
- references: [{
9944
- name: "campaign-100.md",
9945
- bytes: 41200,
9946
- mtimeMs: 178979e7
9947
- }, {
9948
- name: "auto-exposure.md",
9949
- bytes: 8400,
9950
- mtimeMs: 17897e8
9951
- }],
9952
- boundSessionIds: ["session-adccc461-9f32-4d9a-a688-9da81fa1ca35"],
9953
- liveSessionIds: ["session-adccc461-9f32-4d9a-a688-9da81fa1ca35"],
9954
- runningSessionIds: [],
9955
- pendingWakes: [{
9956
- id: "w-20260101-0900-aaaa",
9957
- at: "2026-01-01T09:30:00+08:00",
9958
- createdAt: "2026-01-01T07:00:00+08:00",
9959
- createdBy: "S001"
9960
- }],
9961
- scheduler: {
9962
- armed: true,
9963
- enabled: true,
9964
- ticks: 128,
9965
- lastSkipReason: null
9832
+ async function createAndBindTarget(ctx, root, dirName) {
9833
+ const sc = hostService(ctx, "sessionController");
9834
+ const create = sc?.create;
9835
+ if (typeof create !== "function") return {
9836
+ error: "目标无活跃 dsh 会话,且 sessionController.create 不可用(绑定 id: 无)——无法就地创建载体;请先对该轨迹执行一次 cognitive_trajectory use",
9837
+ notReady: true
9838
+ };
9839
+ const ws = findWorkspaceByPath(listWorkspaces(ctx) ?? [], root);
9840
+ const workspaceId = typeof ws?.id === "string" && ws.id !== "" ? ws.id : void 0;
9841
+ let sessionId = "";
9842
+ try {
9843
+ const out = await create.call(sc, workspaceId !== void 0 ? { workspaceId } : { cwd: root });
9844
+ sessionId = String(out?.sessionId ?? "");
9845
+ } catch (err) {
9846
+ return { error: `就地创建会话失败(${dirName}): ${String(err?.message ?? err)}` };
9966
9847
  }
9967
- };
9848
+ if (!sessionId) return { error: `就地创建会话未返回 sessionId(${dirName})` };
9849
+ const resolved = sc?.resolveAgent;
9850
+ if (typeof resolved !== "function") return {
9851
+ error: `会话已建(${sessionId})但 sessionController.resolveAgent 不可用`,
9852
+ notReady: true
9853
+ };
9854
+ let agent;
9855
+ try {
9856
+ const out = await resolved.call(sc, sessionId);
9857
+ agent = out?.agent;
9858
+ if (!agent) return { error: `会话已建(${sessionId})但 resolveAgent 未返回 agent: ${String(out?.error ?? "未知原因")}` };
9859
+ } catch (err) {
9860
+ return { error: `会话已建(${sessionId})但载入失败: ${String(err?.message ?? err)}` };
9861
+ }
9862
+ const bound = appendBound({ header: {
9863
+ id: sessionId,
9864
+ cwd: root
9865
+ } }, "activate", {
9866
+ dirName,
9867
+ mdPath: join(root, "AGENT_SESSIONS", dirName, "SESSION.md"),
9868
+ note: "auto-created by send-now",
9869
+ createdAsUser: true
9870
+ });
9871
+ const openingNote = deliverCarrierOpening(agent, buildCarrierOpening(parseTrajectoryCode(dirName) ?? dirName, trajectoryDescOf(dirName))) ? ", opening-as-user" : ", ⚠️ 开场白未投递";
9872
+ const titleNote = renameCarrierTitle(ctx, agent, root, dirName);
9873
+ const wsNote = workspaceId !== void 0 ? "" : await attachToWorkspace(ctx, root, sessionId);
9874
+ const viaNote = workspaceId !== void 0 ? ", via-workspaceId(=用户同一形态)" : ", via-cwd+attach";
9875
+ const how = `auto-create(${sessionId}${bound.ok ? ", bound" : `, ⚠️ 绑定写入失败(${bound.code})`}${viaNote}${openingNote}${titleNote}${wsNote})`;
9876
+ return {
9877
+ agent,
9878
+ how
9879
+ };
9880
+ }
9968
9881
  /**
9969
- * 🔴 **样例快照(测试参数)** —— **由真实装配器生成**,故**永不与 schema 漂移**。
9882
+ * 把刚建的载体会话标题**钉成与 `use` 同一形态**(2026-10-21,owner 报「标题不规范」)。
9970
9883
  *
9971
- * 用途:写 CRO 程序时把它喂给你的程序做自测(见指南 §8「自测」)。
9972
- * @returns 缩进过的快照 JSON 文本
9884
+ * ## 为什么单独成函数
9885
+ *
9886
+ * 它是**全仓第二个**写会话标题的点(第一个是 `use`/`rebuild` 的 `renameDshSessionOnUse`)。
9887
+ * 集中在一处,将来若要改命名规则,判据仍在 `namingTitleFor` 一个地方。
9888
+ *
9889
+ * ## 三者顺序不可换
9890
+ *
9891
+ * 必须在 ③b(投开场白)**之后**调用:
9892
+ * - 开场白让 `titleInput.count` > 0,是标题得以成立的前提;
9893
+ * - 放在之后还有**降级好处**:万一 `rename` 失败,宿主自己的 fallback 标题仍在
9894
+ * (难看但存在),而不是两者皆无。
9895
+ *
9896
+ * @returns 回执片段(成功 `, titled` / 失败 `, ⚠️ 标题未写(原因)`)—— **必须出声**,
9897
+ * 否则"标题没写"会表现成"这条轨迹就是这名字"(D186:守卫可以拒绝,但不许不出声)。
9973
9898
  */
9974
- function renderCroSampleSnapshot() {
9975
- return JSON.stringify(buildCroSnapshot(CRO_SAMPLE_SNAPSHOT_INPUT), null, 2);
9899
+ function renameCarrierTitle(ctx, agent, root, dirName) {
9900
+ try {
9901
+ const titles = hostService(ctx, "sessionTitle");
9902
+ if (!titles || typeof titles.rename !== "function") return ", ⚠️ 标题未写(sessionTitle 不可用)";
9903
+ const session = agent.session;
9904
+ if (!session || typeof session !== "object") return ", ⚠️ 标题未写(无 session)";
9905
+ const title = namingTitleFor({
9906
+ sessionId: parseTrajectoryCode(dirName) ?? dirName,
9907
+ dirName,
9908
+ mdPath: join(root, "AGENT_SESSIONS", dirName, "SESSION.md")
9909
+ });
9910
+ titles.rename(session, title);
9911
+ return ", titled";
9912
+ } catch (err) {
9913
+ return `, ⚠️ 标题未写(${String(err?.message ?? err)})`;
9914
+ }
9976
9915
  }
9977
9916
  /**
9978
- * CRO 编写指南正文(ACC 内置手册;由工具的 guide 动作输出)。
9917
+ * 把刚建的会话挂进 **cwd 所属的那个工作区**(2026-10-17 缺陷修复)。
9979
9918
  *
9980
- * 结构照既有 guide 惯例(`MSM_GUIDE` / `cheap-worker(guide=true)`):
9981
- * 用途 → 位置 → 我给什么 → 你给我什么 → 骨架 → 常见模式 → 坑 → **自测**。
9919
+ * 🔴 **为什么必须我们自己做(宿主源码取证,不是推断)**:
9920
+ * `sessionController.create()` 里 `attachSession` **只在 `workspaceId` 分支执行** ——
9921
+ * `dsh-api-session-controller/lib/index.js` 的 `create()`:
9922
+ * ```
9923
+ * if (request.workspaceId !== void 0) workspace = registry.get(request.workspaceId)
9924
+ * const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd
9925
+ * ...
9926
+ * if (workspace !== void 0) await workspace.attachSession(sessionId) // ← 只有这条分支挂工作区
9927
+ * ```
9928
+ * 我们传的是 `{ cwd }`(**必须如此**:`workspaceId` 与 `cwd` 互斥,而工作区 id 是宿主的内部 uuid,
9929
+ * 我们又得按"这条轨迹住在哪个 CCC"来建会话)⇒ **建出来的会话从不登记进任何工作区**。
9930
+ *
9931
+ * 🔴 **症状(现场实证,本机真实数据)**:`send-now` 自动建出的那条会话
9932
+ * (`note` = `auto-created by send-now`)header `cwd` 确实是某个 CCC 根,
9933
+ * **却不在**该工作区的 `sessionIds` 里
9934
+ * (同一个 CCC 的另外两条会话都在)⇒ WebUI 里它不属于那个工作区。
9935
+ *
9936
+ * 🔴 **为什么按 `path` 找而不是"拿第一个"**:多个 CCC 并存(本机实有 5 个工作区)
9937
+ * ⇒ 拿第一个必然挂错。判据 = `workspace.path === root`(宿主 `Workspace.path` 是
9938
+ * **realpath 规范化**后的规范路径;`root` 已是 `cccRootForCwd` 上溯出来的真实目录,
9939
+ * 两侧同一来源,故可直接比)。
9940
+ *
9941
+ * ⚠️ **失败不阻断投递**:工作区归属影响的是"在 WebUI 里归哪个工作区",
9942
+ * 而消息投递已经完成 ⇒ 把原因**如实写进回执**,不吞、也不把成功改成失败。
9943
+ *
9944
+ * @param ctx 插件上下文
9945
+ * @param root CCC 根(= 该会话的 cwd)
9946
+ * @param sessionId 刚建出来的会话 id
9947
+ * @returns 追加到回执里的短注记(成功/跳过为空串,失败带原因)
9982
9948
  */
9983
- const CRO_GUIDE = `CRO(Continuous Re-Occurrence · 持续再发生)编写指南
9984
-
9985
- ## 0. 这是什么(一句话)
9986
- 让一条轨迹**自己带一段程序**,由 ACC 在每次检查时跑它,
9987
- **由这段程序决定「现在该不该叫我、叫我的时候说什么」**。
9988
-
9989
- 今天的唤醒只认时间("3 点叫我");CRO 让它认**情况**("天亮且家里有人才叫我")。
9990
-
9991
- ## 1. 它在系统里的位置
9992
- · **机制属 ACC**(本指南、契约、调度、执行、容错都在 ACC)
9993
- · **程序属 CCC**(你写的那段 TS,放在**你自己轨迹的目录**里)
9994
- · **执行者** = ACC 的唤醒调度器(每 5 分钟一次 tick)
9995
-
9996
- ## 2. 文件放哪、叫什么
9997
- <CCC 根>/AGENT_SESSIONS/<轨迹目录>/continuous-re-occurrence.ts
9998
-
9999
- 🔴 **文件名必须全写**(不许用缩写)。
10000
- 🔴 **程序只有一个**:**文件在 = 启用;文件不在 = 禁用**。
10001
- —— 没有**逐条**开关、没有注册表、没有 enabled 字段。
10002
- 🔴 **但上面那条只管"这一条"** —— 另有一个**全局总闸** \`croEnabled\`
10003
- (设置面板「**CRO(轨迹自编程唤起)**」,缺省**开**):
10004
- 关掉**只停 CRO 阶段** —— \`send-later\` / \`send-now\` / 唤醒表投递**照常工作**。
10005
- ⚠️ 所以「**文件在**」**不等于**「**一定会跑**」:总闸关掉时 ACC **根本不调用**你的程序
10006
- (tick 日志里会记一行跳过理由 —— 这是为了让你事后能分辨
10007
- 「**总闸关着没跑**」和「**跑了、程序自己判了不叫**」这两件完全不同的事)。
10008
-
10009
- ## 2b. 🔴 机器绑定(**必须指定,否则不运行** —— 所有者令 2026-10-15)
10010
-
10011
- 同目录再放一个文件:
10012
-
10013
- <CCC 根>/AGENT_SESSIONS/<轨迹目录>/cro-host-binding
10014
-
10015
- 内容 = **一行机器名**(就是本机 \`hostname\`)。也支持 \`#\` 注释行,方便留痕:
10016
-
10017
- # 这条轨迹的自唤醒只在 CCC 宿主机上跑
10018
- yh-Standard-PC-Q35-ICH9-2009
10019
-
10020
- **语义(三条,缺一不可)**:
10021
- · **只许这一台** —— 文件名里写的那台;别处**不跑**;
10022
- · 🔴 **fail-closed** —— **没写这个文件 ⇒ 不跑**(不是"没写就随便跑");
10023
- · **检查者是唤醒器**(ACC),**不是**你的程序 —— 你的程序**根本不会被调用**。
10024
-
10025
- **为什么要有它**:CRO 程序本身**随 git 走**(它在你轨迹目录里,会跟着 CCC 到第二台机器)。
10026
- 没有这道门,**同一段程序会在两台机器各跑一次**(同一轨迹被叫两次)。
10027
- 有了它,第二台机器读到"绑给了别人"就**正确地不跑**。
10028
-
10029
- ⚠️ **两条与它有关的纪律**:
10030
- 1. 🔴 **不要把这个文件加进 \`.gitignore\`** —— 它**必须**跟着仓走。
10031
- 否则第二台机器读到的是"没绑",结果**碰巧一样**(也不跑),
10032
- 但你的判据从「**明确绑给了别人**」退化成「**什么都没说**」⇒ 排查时看不出真因。
10033
- 2. 🔴 **tick 日志会区分两种跳过**:
10034
- 「\`not-bound\` 你没绑」与「\`bound-to-other\` 你绑给了别人」是**两件事**,文案不同。
10035
- (2026-10-15 有过一次教训:两种不同的原因报同一句话,害排查指错方向。)
10036
-
10037
- **改绑 / 解除**:直接改这个文件(或删掉 ⇒ 变回"没绑 ⇒ 不跑")。**没有额外命令**。
10038
-
10039
- ## 3. 我给你什么(输入)
10040
- ACC 把这条轨迹的状态做成一个 **JSON 快照**,从 **stdin** 喂给你的程序。
10041
- 字段分四组:identity(身份)/ time(时间)/ body(轨迹身体)/ binding + scheduling(绑定与调度)。
10042
-
10043
- ⚠️ **给的是"快照",不是"无限能力"**:ACC 看不见的东西(比如"你上次判了什么")
10044
- **物理上给不了**(那是你进程里的内存)。那类状态**归你自己**——
10045
- 写在你自己的轨迹目录里(例如一个 state.json),自己读自己写。
10046
-
10047
- ## 4. 你给我什么(输出)
10048
- 向 **stdout** 写 **一行 JSON**:
10049
-
10050
- { "wake": true, "prompt": "唤起时的提示词", "reason": "判定理由(可选)" }
10051
- { "wake": false } // 不唤起
10052
-
10053
- · \`wake\` 缺省 / false ⇒ **不唤起**(**这是常态**,缺省是"不打扰")
10054
- · \`wake: true\` 时 \`prompt\` **必须非空**(否则视为非法,本轮跳过、不投递空消息)
10055
- · 🔴 \`reason\` **强烈建议填**:见 §7 坑 3
10056
-
10057
- ## 5. 骨架
10058
-
10059
- // continuous-re-occurrence.ts
10060
- let raw = ''
10061
- process.stdin.on('data', (d) => { raw += d })
10062
- process.stdin.on('end', () => {
10063
- const s = JSON.parse(raw) // 轨迹状态快照
10064
- // ... 在这里写你的判定 ...
10065
- process.stdout.write(JSON.stringify({ wake: false }))
10066
- })
10067
-
10068
- 例(所有者举的 S185 那类情形,仅示意):
10069
-
10070
- const tooBig = (s.body.sessionMdBytes ?? 0) > 150_000
10071
- const busy = s.binding.runningSessionIds.length > 0
10072
- if (busy) return out({ wake: false, reason: '已在干活' })
10073
- if (tooBig) return out({ wake: true, prompt: '你的 SESSION.md 已很大,请先压缩再继续。', reason: '日志超限' })
10074
-
10075
- ## 6. 常见模式
10076
- · **环境谓词**(天亮/有人/非高峰):ACC 给不了这些 —— **你自己去拿**(查设备、读传感器、
10077
- 查你自己的程序)。这就是"程序和程序可以互相访问"的用法。快照里的 \`time\` 只是给你一个时间锚。
10078
- · **已在工作就别叫**:看 \`binding.runningSessionIds\`(非空 = 有载体正在跑轮次)。
10079
- ⚠️ 注意它和 \`liveSessionIds\` 不同:**会话活着 ≠ 在干活**。
10080
- · **要求它先自整理**:不需要新机制 —— **就是 \`prompt\` 的一种写法**
10081
- (如"你的 SESSION.md 已 180 KB,请先压缩再继续")。
10082
- · **次数上限 / 连续 N 次才叫**:ACC 不给你计数器(**给不了**)⇒ **自己目录里写状态文件**。
10083
-
10084
- ## 7. 坑
10085
- 1. **不许阻塞等输入**:你只从 stdin 拿一次快照,不要在程序里 readline / prompt 等用户输入。
10086
- 2. **硬超时 60 秒**:超时会被 SIGKILL、本轮跳过。外部查询请自己设更短的超时。
10087
- 3. 🔴 **一定要填 \`reason\`**:改成程序判定之后,"当时为什么叫了"**不再能从时间表重建**——
10088
- 原因在**你的程序肚子里**(可能有随机、可能看了外部数据)。不写 reason,
10089
- 以后出事**无法复现**。这条不是形式主义,是**可重建性**的要求。
10090
- 4. **失败只影响你自己**:程序报错/超时/输出非法 ⇒ 记一行 + **跳过本轮**,
10091
- **绝不会影响** \`send-later\` / \`send-now\` 等既有机制。所以你可以放心试。
10092
- 5. 🔴 **不要提前用正式文件名**:见 §8。
10093
-
10094
- ## 8. 🔴 自测(**测试通过再把文件名改成正式的**)
10095
- CRO 程序**是可以自测的** —— 因为它只做一件事:**吃一份快照 JSON,吐一行决策 JSON**。
10096
- (纯函数式的边界 ⇒ 不需要真机、不需要等 5 分钟 tick、不需要调度器。)
10097
-
10098
- ### 8.1 推荐流程:**用开发名写,绿了再改名**
10099
- 因为「**文件在 = 启用**」**没有逐条开关**(总闸是全局的,管不了单条),
10100
- **改名这个动作本身就是上线动作**:
10101
-
10102
- 1. 先用**开发名**写(如 \`continuous-re-occurrence.dev.ts\`)
10103
- —— 开发名**不会**被 ACC 启用(启用只认正式名 exact match),可以放心留一半
10104
- 2. 配一个自测(如 \`continuous-re-occurrence.dev.test.ts\`),喂几份快照断言输出
10105
- 3. **全部绿了** ⇒ 改名为 \`continuous-re-occurrence.ts\` ⇒ **这一刻才生效**
10106
-
10107
- ⚠️ 若你跳过自测直接上正式名:半成品会被当成生效程序,**报错并跳过本轮**(不会搞坏系统,
10108
- 但那条轨迹的 CRO 就是空转的)。**报错是安全网,不是工作流。**
10109
-
10110
- ### 8.2 手工试跑(最快的一条路)
10111
- 把样例快照喂给你的程序,看它吐什么:
10112
-
10113
- # 用开发名试跑(推荐 bun;node 需能跑 TS)
10114
- bun continuous-re-occurrence.dev.ts < sample-snapshot.json
10115
-
10116
- # 或直接管道
10117
- echo '<一行快照 JSON>' | bun continuous-re-occurrence.dev.ts
10118
-
10119
- ### 8.3 测试用的参数
10120
- **样例快照 = 由 ACC 的装配器实时生成**(故**永不与 schema 漂移**,schema 一变它跟着变)。
10121
- 取法:**运行本指南所在工具的 guide 动作**,或用同一机制导出;内容是:
10122
-
10123
- \`\`\`json
10124
- ${renderCroSampleSnapshot()}
10125
- \`\`\`
10126
-
10127
- 该样例刻意覆盖了:
10128
- · \`body.sessionMdBytes\` = 182000(**日志过大**那一档)
10129
- · \`binding.runningSessionIds\` = [](**空闲**;把它填成一个 id 就能测"已在干活"分支)
10130
- · \`scheduling.pendingWakes\` 非空(**已有待办唤醒**)
10131
- · 无 S 前缀格式的情形请自己造 \`dirName\`(\`identity.code\` 会是空串——那是**正常**的,
10132
- 编号不是固定格式,硬锚永远是 \`dirName\`)
10133
-
10134
- ### 8.4 自测该断言什么(建议清单)
10135
- · 快照缺字段(\`null\` 体积)时不崩 —— 用 \`?? 0\` 之类兜底
10136
- · \`wake:true\` 时 \`prompt\` **确实非空**
10137
- · "已在干活"分支:\`runningSessionIds\` 非空 ⇒ \`wake:false\`
10138
- · 同一个程序**连跑两次**给同样输入 ⇒ 同样输出(除非你刻意用了随机/外部状态,
10139
- 那就必须写进 \`reason\`)
10140
-
10141
- ## 9. 一句话
10142
- **吃一份快照,吐一行决策。先自测,绿了再改名。**
10143
- `;
10144
- //#endregion
10145
- //#region src/trajectory/resident.ts
9949
+ async function attachToWorkspace(ctx, root, sessionId) {
9950
+ const workspaces = listWorkspaces(ctx);
9951
+ if (workspaces === void 0) return ", ⚠️ 工作区未挂载(workspaceRegistry 不可用)";
9952
+ const match = findWorkspaceByPath(workspaces, root);
9953
+ if (match === void 0) return "";
9954
+ if (typeof match.attachSession !== "function") return ", ⚠️ 工作区未挂载(attachSession 不可用)";
9955
+ if (Array.isArray(match.sessionIds) && match.sessionIds.includes(sessionId)) return "";
9956
+ try {
9957
+ await match.attachSession(sessionId);
9958
+ return ", workspace-attached";
9959
+ } catch (err) {
9960
+ return `, ⚠️ 工作区挂载失败: ${String(err?.message ?? err)}`;
9961
+ }
9962
+ }
9963
+ /** `workspaceRegistry.list()` 的读取收口(不可用返回 `undefined`,**与"空列表"区分**) */
9964
+ function listWorkspaces(ctx) {
9965
+ const reg = hostService(ctx, "workspaceRegistry");
9966
+ if (typeof reg?.list !== "function") return void 0;
9967
+ try {
9968
+ return reg.list() ?? [];
9969
+ } catch {
9970
+ return;
9971
+ }
9972
+ }
10146
9973
  /**
10147
- * trajectory/resident.ts — **常驻名单**(D173 的兑现口,`cognitive_trajectory resident` 的实现)
9974
+ * 在 `workspaceRegistry.list()` 里找 **path 等于该 CCC 根** 的那一个。
10148
9975
  *
10149
- * ## 这条能力的判据(owner 2026-10-20 原话,逐字)
9976
+ * 🔴 **为什么要 realpath 归一(不是画蛇添足)**:宿主的 `Workspace.path` 是
9977
+ * **`fs.realpath` 规范化**过的(`entity.d.ts` 逐字:*"Canonical directory path: the
9978
+ * `fs.realpath` of the path given at create time (trailing slashes, `..`, and symlinks
9979
+ * all resolved)"*)。而 `root` 来自 `cccRootForCwd` 的上溯——**上溯用的是字面路径**。
9980
+ * ⇒ 当 CCC 根是一个**符号链接**(部署上常见:家目录/项目目录被链到别处),
9981
+ * 两侧**逐字不等**,只比字符串会**挂错或挂不上**。
10150
9982
  *
10151
- * > 「常驻的意思就是**自己声明为自己欢迎别人来麻烦自己**,和永不休眠没啥关系,
10152
- * > **trajectory never ends 悖论的一种实现**」
9983
+ * ⚠️ 归一失败(路径不存在等)时**回落字面比较** —— 不改既有语义,只是多给一次机会。
10153
9984
  *
10154
- * ⇒ **常驻不是资源状态**。它不是"永不休眠"、不是"常驻内存"、不是"不许销毁我的会话"。
10155
- * 一条轨迹**永远存在**(没有生命周期),常驻与否都不改变这一点。
9985
+ * @param workspaces registry.list() 的返回
9986
+ * @param root CCC 根
9987
+ * @returns 命中的工作区,无则 undefined
9988
+ */
9989
+ function findWorkspaceByPath(workspaces, root) {
9990
+ const direct = workspaces.find((ws) => ws?.path === root);
9991
+ if (direct !== void 0) return direct;
9992
+ const canonical = (p) => {
9993
+ try {
9994
+ return realpathSync(p);
9995
+ } catch {
9996
+ return p;
9997
+ }
9998
+ };
9999
+ const want = canonical(root);
10000
+ return workspaces.find((ws) => typeof ws?.path === "string" && canonical(ws.path) === want);
10001
+ }
10002
+ /**
10003
+ * 本 tick 要扫的 CCC 根集合(**并集**)。
10156
10004
  *
10157
- * ⇒ **它管的是「被找到」,不是「不被销毁」。**
10158
- * 两件事混为一谈,就会去动调度器的载体生命周期 —— 那正是本轨反复踩的"名字撒谎"。
10005
+ * C2(S142 2026-09-15 Q5):原实现是 `collectLiveCccs`——**只认 live 会话** ⇒
10006
+ * "完全冷掉的 CCC 不能自唤醒自己"(已知边界)。现改为 `ccc-roots.listCccs` 的并集枚举
10007
+ * (工作区注册表 ∪ 持久化会话 ∪ live 会话):**没有 live 会话的 CCC 也会被扫到**。
10159
10008
  *
10160
- * ## 因此本模块的三条硬边界
10009
+ * ⚠️ 边界(本步**只**改"枚举哪些 CCC",**不改**"怎么找到要交付的 agent"):
10010
+ * 把 message 交给某条认知轨迹仍必须靠 **live 会话**解析(见本文件
10011
+ * `acquireWakeAgent`);无 live 会话时该 CCC 只是**被扫到、
10012
+ * 无交付对象** ⇒ `deliverWake` 返回 `无绑定会话记录…`,条目回 `pending` 并留痕。
10013
+ * 因此本函数**不得**让 tick 抛错(异常一律由调用方 tick 兜住,见 `runWakeTick` 的 try)。
10014
+ * @param ctx 插件上下文
10015
+ * @returns CCC 根列表(无任何来源 → `[]`)
10016
+ */
10017
+ async function collectWakeCccs(ctx) {
10018
+ return (await listCccs(ctx)).map((e) => e.root);
10019
+ }
10020
+ /**
10021
+ * CRO 唤起消息正文(**刻意不复用** {@link buildWakeText} / {@link buildSendText})。
10161
10022
  *
10162
- * 1. **纯读**:不写任何文件、不碰注册表、不碰 `wake/scheduler.ts`。
10163
- * 2. **不进系统提示词**(owner 裁决 Q2:说明类信息按需取,不占每轮 token)。
10164
- * 3. **只报写了 `serves` 的轨迹** —— 只写半个(有 `resident` 没 `serves`)等于
10165
- * "把自己挂上去却不说能干什么",按手册的判据那比不写更坏,所以**不列出来**,
10166
- * 但要**单独数出**来,让写的人知道自己的写法没生效。
10023
+ * 三种唤起各有自己的**事实**;混用 = 注入一条假陈述(本容器栽过:`buildSendText` 里
10024
+ * 那句"消息已入队"就是一条假陈述,见该函数注释):
10025
+ * · `buildWakeText` —— 「**到点**了」(登记过的未来时刻,人/agent 算好的)
10026
+ * · `buildSendText` —— 「**有人此刻给我发了一条消息**」
10027
+ * · 本函数 —— 「**我自己的程序**看情况判定该叫我了」(**没有到点、也没有人发消息**)
10167
10028
  *
10168
- * ## 归属(D23)
10169
- * 机制在 ACC(怎么找、按什么顺序、列什么字段);内容在 CCC(`serves` 那句话写什么)。
10029
+ * 🔴 `reason` 的呈现(设计 §4.2):**程序没写就明说没写**,不留空白 ——
10030
+ * 改成程序判定后,"当时为什么叫了"**不再能从时间表重建**(原因在程序肚子里);
10031
+ * 留空白 = 连"丢在哪一环"都看不出来。写明"未提供"至少把缺口标出来了(CCE:重建 > 保存)。
10032
+ * @param dirName 目标轨迹目录名
10033
+ * @param mdPath 目标 SESSION.md 绝对路径
10034
+ * @param prompt 程序给的唤起提示词
10035
+ * @param reason 程序给的判定理由(可为 null)
10036
+ * @returns 投递正文
10170
10037
  */
10038
+ function buildCroWakeText(dirName, mdPath, prompt, reason) {
10039
+ return [
10040
+ `[CRO 唤起] ${dirName} 自带的 continuous-re-occurrence.ts 判定:现在该唤起。`,
10041
+ `当前时间:${localHuman()}(当地时区)。`,
10042
+ "",
10043
+ `身份锚定:继续 ${dirName} 的认知轨迹(SESSION.md: ${mdPath})。`,
10044
+ "",
10045
+ `判定理由:${reason ?? "(程序未提供 reason —— 见 CRO 编写指南 §7 坑 3)"}`,
10046
+ "",
10047
+ "唤起提示词:",
10048
+ prompt,
10049
+ "",
10050
+ "说明:这是由**本轨迹自己的 CRO 程序**按当时情况做的判定(**不是**定时唤醒,**也不是**别人发的消息)。处理完照常把进展写入 SESSION.md。"
10051
+ ].join("\n");
10052
+ }
10171
10053
  /**
10172
- * 列出全部**可被找到**的轨迹(按编码排序,确定性)。
10054
+ * 投递一条 CRO 唤起(**fire-and-forget**,与 {@link deliverWake} 同层)。
10173
10055
  *
10174
- * ⚠️ 读盘失败 / 坏 JSON 一律**跳过该条**(不抛):一份坏的声明不该让整张名单拿不到。
10056
+ * · **取用通路复用** {@link acquireWakeAgent}(live 优先 → 冷载入):这是调度器侧唯一的
10057
+ * "怎么把一句话送到一条轨迹"的知识 ⇒ 不另立第二条(设计 §9-2 的倾向:复用投递面)。
10058
+ * · 🔴 **不落唤醒表**(设计 §6.3):CRO 是"**持续判定**",不是"**某个未来时刻**"。
10059
+ * 落表会给 §0Q(投递即删、表只保在办)**增加一个新的写入源**。
10060
+ * · 用 `followup`(不用 `steer`):CRO 由**调度器**发起,与 `send-later` 同层;
10061
+ * `steer`(当场注入当前轮)是 `send-now` 的语义 —— 那是人类/agent 的**即时动作**。
10062
+ * · 🔴 **失败不回滚、不重试**(与条目投递不同):下一 tick 会**重新判定**——
10063
+ * CRO 的判据是"情况",不是"这条消息还没送出去" ⇒ 自愈来自**重判**,不来自重投。
10064
+ * @param ctx 插件上下文
10065
+ * @param root CCC 根
10066
+ * @param dirName 目标轨迹目录名(= CRO 只能唤起**自己**,设计 §9-8 的倾向)
10067
+ * @param prompt 唤起提示词
10068
+ * @param reason 判定理由
10069
+ * @returns 投递结果(进 tick 日志;**不写注册表**)
10070
+ */
10071
+ async function deliverCroWake(ctx, root, dirName, prompt, reason) {
10072
+ const mdPath = join(sessionsRoot(root), dirName, "SESSION.md");
10073
+ let acquired = await acquireWakeAgent(ctx, root, dirName);
10074
+ /**
10075
+ * 🔴 **载体自举(2026-10-23,guard 实测报障后补)**
10076
+ *
10077
+ * ## 缺陷(真实代价)
10078
+ *
10079
+ * 一条「**生来就是为了被自己的 CRO 叫醒**」的轨迹,**第一次自唤醒在物理上不可能**:
10080
+ * 它是程序化 `create` 出来的 ⇒ 没有载体 ⇒ `noBinding` ⇒ 投递被拒
10081
+ * ⇒ **恰恰在最需要被叫醒的那一刻没有载体可注入**。实测证据:
10082
+ * `cro-wake-log.log` 首行 `fail ... 无绑定会话记录`,而**那一轮的 CRO 判定完全正确**。
10083
+ *
10084
+ * ## 为什么可以自举,以及它与 `send-now` 的分支差在哪
10085
+ *
10086
+ * `send-now` 的自举理由是「完成一次**人/agent 的**意图」;CRO 是**每 5min 自动重判**,
10087
+ * 若照搬会有「**载体爆炸**」风险。故本路径**多一道闸**:
10088
+ *
10089
+ * - 只认 `carrierState === 'never'`(**从未有过载体**)⇒ 建一次;
10090
+ * - `occupied`(有过但那个会话不在了)⇒ **一律不建**,如实报错。
10091
+ *
10092
+ * ⇒ 自举**至多发生一次**(建完就有 `current.dsh_session_bind`,此后 `carrierState` 不再是 `never`)。
10093
+ * 这正是 `carrierState` 那道守卫的语义,与 `send-now` 共用同一条判据(不另立)。
10094
+ *
10095
+ * ⚠️ 只处理 `noBinding`;`notReady`(启动窗口)与冷载入失败**照原样返回** ——
10096
+ * 那些不是"没载体",建新载体解决不了且可能制造第二条。
10097
+ */
10098
+ if ("error" in acquired && acquired.noBinding === true) {
10099
+ const carrier = carrierState(root, dirName, hostAliveProbe(ctx));
10100
+ if (carrier.state === "never") {
10101
+ const created = await createAndBindTarget(ctx, root, dirName);
10102
+ if (!("error" in created)) {
10103
+ acquired = created;
10104
+ console.log(`[serenity-hooks] CRO 载体自举:${dirName} 从未有过载体 ⇒ 已自动创建并绑定(${created.how})`);
10105
+ }
10106
+ } else
10107
+ /**
10108
+ * 🔴 **拒绝自举时必须指名是哪一半**(D186:守卫可以拒绝,但不许不出声)。
10109
+ *
10110
+ * 若照原样把 `acquireWakeAgent` 的 `noBinding` 文案透传出去,`cro-wake-log` 里
10111
+ * 「`current.dsh_session_bind` 不存在或无有效会话」**两种状态长得一模一样**:
10112
+ * · `never` —— 那条轨迹**生来就没有载体**(本次已自举,不会走到这里);
10113
+ * · `occupied` —— **有过载体、那个会话现在不在了**(本分支拒绝自建)。
10114
+ * ⇒ 读日志的人分不出"该去救那个会话"还是"该去查为什么没建起来",
10115
+ * 而这正是本仓反复付代价的那类缺陷(D131:不靠一句话给状态命名,但**状态不同必须说出来**)。
10116
+ */
10117
+ acquired = { error: carrier.state === "occupied" ? `CRO 唤起未投递:${dirName} 曾经由会话 ${carrier.sessionId} 承载,而它此刻不在了 ⇒ **拒绝自动新建载体**(一条轨迹至多一个载体,而 CRO 每 5min 重判一次,自建会让载体反复膨胀)。\n · 要恢复:把那个会话救回来,或用 cognitive_trajectory rebuild 显式换代(takeover)。` : `CRO 唤起未投递:${dirName} 的载体声明指向会话 ${carrier.sessionId},它按探针**仍可用**,而选人路径却报"无绑定" ⇒ **两处读数打架,不新建载体**(先查绑定文件与归档集)。` };
10118
+ }
10119
+ if ("error" in acquired) return {
10120
+ ok: false,
10121
+ detail: acquired.error,
10122
+ notReady: acquired.notReady === true
10123
+ };
10124
+ try {
10125
+ acquired.agent.followup(createUserMessage({
10126
+ content: [{
10127
+ type: "text",
10128
+ text: buildCroWakeText(dirName, mdPath, prompt, reason)
10129
+ }],
10130
+ source: PLUGIN_SOURCE
10131
+ }));
10132
+ } catch (err) {
10133
+ return {
10134
+ ok: false,
10135
+ detail: `CRO 投递失败(${acquired.how}): ${String(err?.message ?? err)}`
10136
+ };
10137
+ }
10138
+ return {
10139
+ ok: true,
10140
+ detail: `已投递(${acquired.how})`
10141
+ };
10142
+ }
10143
+ /**
10144
+ * CRO 阶段:本 tick 对**每条启用了 CRO 的轨迹**跑一次它自带的程序,按判定投递。
10145
+ *
10146
+ * ## 为什么放在既有投递**之后**(R↓)
10147
+ * · 既有条目(`send-later`)**有到点承诺** ⇒ 优先级天然更高;
10148
+ * · CRO 是"看着情况叫",**晚一拍无成本**(tick 粒度本就是 5min);
10149
+ * · ⇒ 铁律(设计 §5):**CRO 的任何失败都不影响既有链路** —— 整段由调用方兜住,
10150
+ * 且**每条轨迹各自 try/catch**(一条坏程序不得连累同 tick 的其它轨迹)。
10175
10151
  *
10152
+ * ## 日志口径(刻意与既有投递不同)
10153
+ * "每 tick × 每条轨迹"都写一行 ⇒ 对"**常态是不唤起**"的 CRO 就是刷屏。
10154
+ * ⇒ 逐条只记 **唤起**(`reason` 进日志 —— 设计 §4.2 要求可重建)与 **跳过**(异常,必须看见);
10155
+ * **不唤起只进汇总行**(`唤起 x,不唤起 y,跳过 z`)。
10156
+ * ⚠️ **诚实标注**:因此"某条轨迹当轮**为何没叫**"**不进 tick 日志**(它在程序肚子里)。
10157
+ * 要完整的逐次判定审计,须**程序自己**写状态文件(设计 §3.3)——ACC 给不了。
10158
+ * @param ctx 插件上下文
10176
10159
  * @param root CCC 根
10160
+ * @param registry 本 tick 已载入的唤醒注册表条目(供快照的 `pendingWakes`;读坏时传 `[]`)
10161
+ * @param log 本 tick 的人读摘要(就地追加)
10177
10162
  */
10178
- function collectResident(root) {
10179
- const entries = [];
10180
- const incomplete = [];
10181
- let names = [];
10163
+ async function runCroPhase(ctx, root, registry, log) {
10164
+ if (readSimpleSettings().croEnabled === false) {
10165
+ log.push("CRO 阶段:已关闭(croEnabled=false)——既有唤醒投递不受影响");
10166
+ return;
10167
+ }
10168
+ let dirs;
10182
10169
  try {
10183
- names = readdirSync(sessionsRoot(root), { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith("_") && !e.name.startsWith(".")).map((e) => e.name);
10170
+ dirs = listCroCognitiveTrajectories(root);
10184
10171
  } catch {
10185
- return {
10186
- entries,
10187
- incomplete
10188
- };
10172
+ return;
10189
10173
  }
10190
- for (const dirName of names) {
10191
- let profile;
10192
- try {
10193
- profile = readTrajectoryConstraintAt(join(sessionsRoot(root), dirName), root);
10194
- } catch {
10174
+ if (dirs.length === 0) return;
10175
+ const nowMs = Date.now();
10176
+ const s = wakeSchedulerState();
10177
+ const scheduler = {
10178
+ armed: s.armed,
10179
+ enabled: s.enabled,
10180
+ ticks: s.ticks,
10181
+ lastSkipReason: s.lastSkipReason
10182
+ };
10183
+ const agents = hostAgents(ctx);
10184
+ const selfHost = safeHostname();
10185
+ let hostSkips = 0;
10186
+ let wakes = 0;
10187
+ let quiet = 0;
10188
+ let skips = 0;
10189
+ for (const dirName of dirs) try {
10190
+ const verdict = resolveCroHostBinding(root, dirName, selfHost);
10191
+ if (!verdict.allowed) {
10192
+ hostSkips += 1;
10193
+ log.push(`· ${root}: ${dirName}: CRO 机器绑定未通过(${verdict.code}):${verdict.detail}`);
10195
10194
  continue;
10196
10195
  }
10197
- if (!profile || profile.resident === void 0) continue;
10198
- const serves = profile.resident.serves;
10199
- if (serves === void 0 || serves === "") {
10200
- incomplete.push(profile.code);
10201
- continue;
10196
+ const bound = listBoundSessionIds(root, dirName, hostAliveProbe(ctx));
10197
+ const running = runningCarriersOf(bound, nowMs);
10198
+ const outcome = await evaluateCro(root, dirName, readCroSnapshotInput(root, dirName, nowMs, {
10199
+ boundSessionIds: bound,
10200
+ liveSessionIds: bound.filter((id) => Boolean(agents?.get?.(id))),
10201
+ runningSessionIds: running,
10202
+ pendingWakes: registry.filter((e) => e.state === "pending" && resolveWakeTarget(root, e.target)?.dirName === dirName).map((e) => ({
10203
+ id: e.id,
10204
+ at: e.at,
10205
+ createdAt: e.createdAt,
10206
+ createdBy: e.createdBy
10207
+ })),
10208
+ scheduler
10209
+ }));
10210
+ if (outcome.status === "wake") {
10211
+ wakes += 1;
10212
+ const res = await deliverCroWake(ctx, root, dirName, outcome.prompt, outcome.decision.reason);
10213
+ log.push(`· ${root}: ${renderCroOutcome(dirName, outcome)} → ${res.ok ? "已投递" : `投递未成功:${res.detail}`}`);
10214
+ {
10215
+ const rec = appendCroWakeLog(root, dirName, {
10216
+ ok: res.ok,
10217
+ ...res.notReady === true ? { notReady: true } : {},
10218
+ detail: res.detail,
10219
+ tick: s.ticks,
10220
+ reason: outcome.decision.reason ?? null,
10221
+ prompt: outcome.prompt
10222
+ }, nowMs);
10223
+ if (!rec.ok) log.push(`· ${root}: ${dirName}: CRO 流水写入失败(已忽略,不影响投递): ${rec.error}`);
10224
+ }
10225
+ } else if (outcome.status === "skipped") {
10226
+ skips += 1;
10227
+ log.push(`· ${root}: ${renderCroOutcome(dirName, outcome)}`);
10228
+ } else if (outcome.status === "no-wake") {
10229
+ quiet += 1;
10230
+ /**
10231
+ * 🔴 **2026-10-23(A102):`no-wake` 也落盘。**
10232
+ *
10233
+ * 修前这里**只累加计数**、一个字都不写该轨迹的流水 ⇒ 流水**永久停在上一次真唤起处**。
10234
+ * 实测后果(guard 轨迹,真机):它的 `cro-wake-log.log` 停在 `01:40:55`,
10235
+ * owner 据此认为"守卫从那以后就没工作过"——而真相是它**此后每个 tick 都在被评估**,
10236
+ * 只是判定为"不打扰"。⇒ **「我判了、只是不叫」与「我根本没被评估」在读数上完全一样。**
10237
+ *
10238
+ * 判据同 D209:**可以不出声地判"不打扰",但"判过"本身要留痕**。
10239
+ * 四态:`ok` / `fail` / `not-ready` / `no-wake`。
10240
+ *
10241
+ * ⚠️ 与 `wake` 分支同一条硬约束:**写流水失败绝不影响投递与既有链路**(设计 §5 铁律)。
10242
+ */
10243
+ {
10244
+ const rec = appendCroWakeLog(root, dirName, {
10245
+ ok: false,
10246
+ noWake: true,
10247
+ detail: outcome.detail,
10248
+ tick: s.ticks,
10249
+ reason: outcome.decision.reason ?? null
10250
+ }, nowMs);
10251
+ if (!rec.ok) log.push(`· ${root}: ${dirName}: CRO 流水写入失败(已忽略,不影响既有链路): ${rec.error}`);
10252
+ }
10202
10253
  }
10203
- entries.push({
10204
- code: profile.code,
10205
- dirName: profile.dirName,
10206
- serves,
10207
- ...profile.model !== void 0 ? { model: profile.model } : {}
10208
- });
10254
+ } catch (err) {
10255
+ skips += 1;
10256
+ log.push(`· ${root}: ${dirName}: CRO 内部异常(已忽略,不影响既有链路): ${String(err?.message ?? err)}`);
10209
10257
  }
10210
- entries.sort((a, b) => a.code < b.code ? -1 : a.code > b.code ? 1 : 0);
10211
- incomplete.sort();
10212
- return {
10213
- entries,
10214
- incomplete
10215
- };
10258
+ log.push(`· ${root}: CRO 评估 ${dirs.length} 条(唤起 ${wakes},不唤起 ${quiet},跳过 ${skips}${hostSkips > 0 ? `,非本机绑定 ${hostSkips}` : ""})`);
10216
10259
  }
10217
10260
  /**
10218
- * 渲染成给模型读的文本(`cognitive_trajectory resident` 的返回值)。
10261
+ * 取本机机器名(**永不抛**)。
10219
10262
  *
10220
- * 🔴 **空名单也是有效结果**,且必须说清"这意味着什么" —— 否则读者会以为工具坏了。
10263
+ * `os.hostname()` 理论上可能抛(极少数环境)⇒ 这里兜底成空串。
10264
+ * ⚠️ **空串的后果是"全部不通过"**(`bound === self` 永不成立)⇒ 即 **fail-closed**,
10265
+ * 与所有者裁定同向(宁可都不跑,也不会"名字取不到就随便跑")。
10266
+ * @returns 本机机器名;取不到 ⇒ `''`
10221
10267
  */
10222
- function listResident(root) {
10223
- const { entries, incomplete } = collectResident(root);
10224
- const lines = ["═══ 常驻轨迹(自己声明「欢迎别人来麻烦我」)═══", ""];
10225
- if (entries.length === 0) {
10226
- lines.push(" (本 CCC 暂无轨迹声明常驻)");
10227
- lines.push(" ⇒ 这不是错误:常驻是**自愿的自我声明**,没写就是\"不参与被找\"。");
10228
- } else for (const e of entries) {
10229
- const model = e.model !== void 0 ? ` [model: ${e.model}]` : "";
10230
- lines.push(` · ${e.code}${model}`);
10231
- lines.push(` ${e.serves}`);
10268
+ function safeHostname() {
10269
+ try {
10270
+ return hostname();
10271
+ } catch {
10272
+ return "";
10232
10273
  }
10233
- if (incomplete.length > 0) {
10234
- lines.push("");
10235
- lines.push(` ⚠️ 写了 resident 但**没写 serves**(不会被列出):${incomplete.join("、")}`);
10236
- lines.push(" 只写半个 = 把自己挂上去却不说能干什么 —— 别人会来问,然后发现问错了人。");
10274
+ }
10275
+ /** 一次调度 tick:遍历已知 CCC → 到期项串行投递 / 超窗项置 missed;返回人读摘要 */
10276
+ async function runWakeTick(ctx) {
10277
+ const log = [];
10278
+ const roots = await collectWakeCccs(ctx).catch(() => []);
10279
+ if (roots.length === 0) {
10280
+ clock.noteSkipReason("无已知 CCC 可扫(等某个会话/工作区出现)");
10281
+ return log;
10237
10282
  }
10238
- lines.push("");
10239
- lines.push("── 怎么让自己出现在这里 ──");
10240
- lines.push(" 在你这条轨迹目录的 trajectory.json 里写:");
10241
- lines.push(" { \"resident\": { \"serves\": \"一句话说清我擅长什么、能帮上什么忙\" } }");
10242
- lines.push(" ⚠️ 只写这一个字段**不影响任何默认行为**(D183);其余 key 全部可选。");
10243
- return lines.join("\n");
10283
+ clock.noteSkipReason(null);
10284
+ for (const root of roots) {
10285
+ const purged = purgeFinalizedWakes(root);
10286
+ if (purged.ok && purged.purged > 0) log.push(`· ${root}: 清除 ${purged.purged} 条已结案记录(存量首清)`);
10287
+ const { registry, error } = loadWakeRegistry(root);
10288
+ if (error) {
10289
+ log.push(`✗ ${root}: ${error}`);
10290
+ await runCroPhase(ctx, root, [], log);
10291
+ continue;
10292
+ }
10293
+ const { due, expired } = splitDueWakes(registry, Date.now(), WAKE_CATCH_UP_MS);
10294
+ for (const e of expired) {
10295
+ finalizeWake(root, e.id, "missed", `超过补跑窗口(${WAKE_CATCH_UP_MS / 36e5}h)未投递`);
10296
+ log.push(`· ${root}: ${e.id} → missed(超补跑窗口,已清除)`);
10297
+ }
10298
+ for (const e of due) {
10299
+ const res = await deliverWake(ctx, root, e);
10300
+ if (res.notReady) {
10301
+ log.push(`· ${root}: ${e.id} → 跳过(宿主服务未就绪,下个 tick 再试;不计失败)`);
10302
+ continue;
10303
+ }
10304
+ if (res.ok) {
10305
+ finalizeWake(root, e.id, "delivered", res.detail);
10306
+ log.push(`· ${root}: ${e.id} → delivered(已清除)`);
10307
+ } else {
10308
+ updateWake(root, e.id, {
10309
+ state: "pending",
10310
+ attempts: e.attempts + 1,
10311
+ lastResult: res.detail
10312
+ });
10313
+ log.push(`· ${root}: ${e.id} → 重试(${res.detail})`);
10314
+ }
10315
+ }
10316
+ await runCroPhase(ctx, root, registry.entries, log);
10317
+ await runInterruptedSafetyNet(ctx, root, log);
10318
+ }
10319
+ return log;
10244
10320
  }
10245
- //#endregion
10246
- //#region src/trajectory/live.ts
10247
10321
  /**
10248
- * trajectory/live.ts — 进程内 live 会话取数 + 精简诊断
10249
- *
10250
- * ── 为什么单独成模块(R↓,2026-09-15 S142「ACC 侧 autopilot 退场」S1 搬迁)──
10251
- * 这两个函数原先住在 `autopilot-trajectory.ts`,但它们的**主语不是 autopilot**:
10252
- * `listLiveSessions` 服务 `acc-diag` ① 段的"**哪个会话属于哪个 CCC**"(会话维度,
10253
- * 不是 CCC 维度),而"**独立进程看不到运行时**"正是 `acc-diag` 存在的理由。
10254
- * autopilot 退场后它们必须活下去 ⇒ 独立成模块,不再挂着已死机制的名字。
10255
- *
10256
- * ── 迁移边界(不搬什么,以及为什么)──────────────────────────────────────
10257
- * 原 `diagLive` 还含两段,**均不搬**(其主语是 autopilot,随机制退场):
10258
- * · `autopilotCccs`(逐 CCC 目标命中 / agent 定位诊断)—— 依赖 `readAutopilotSettings` /
10259
- * `getAutopilotStatus` / `resolveTargetAgent`
10260
- * · `panelResolved`(无参面板请求落到哪个 CCC)—— 面板区块随机制退场
10261
- * 留下的 = **进程 cwd/CCC + live 会话清单**(`acc-diag` ① 段仍需要)。
10322
+ * 🔴🔴 **A104-C′(2026-10-23,owner 点头):容器级「被重启打断的会话」兜底 —— 每 tick 扫一次并注入。**
10262
10323
  *
10263
- * 判据纪律:本模块**不得**反向依赖 autopilot 判据层(那会让退场半途而废)。
10324
+ * ## 为什么必须做(不是"多一层保险",是**已有实测盲窗**)
10264
10325
  *
10265
- * ── 🔴 2026-09-25 I2 收敛(约束文档 §2.7 违反清单,L1 违反之一)────────────────
10266
- * **本模块的 `ctx` 参数类型从 `Context` 改为 `unknown`** —— 消除本文件唯一的 `W2` 接触
10267
- * (直接 import 宿主 `cordis` 的类型)。
10326
+ * 2026-10-23 实测:`08:02` 一次 `restart-web` 打断了一个会话,此后 **13 分钟无人拉起**。
10327
+ * 用真回放(把日志截到争议时刻 + 重压 zstd 帧 + 同一份实现)验证:
10328
+ * - 截到 `08:07:30`/`08:12:40` ⇒ **两次都能判出该会话 `turn/end[interrupted]` 且其后无新 `turn/start`**
10329
+ * - 而那两个 tick **实际都没有叫** ⇒ **判据没错,是"没有被跑起来"**
10268
10330
  *
10269
- * **为什么这是"真解耦"而不是"把类型藏起来"(判据):** `ctx` 在本模块**从不被解引用** ——
10270
- * 它只被**原样透传**给 `hostSessions(ctx)`,而后者(`host/access.ts`,**L0**)的参数
10271
- * 本就声明为 `ctx: unknown` ⇒ `Context` 这个 import **在语义上是多余的**,
10272
- * 它没有参与任何类型检查,只贡献了一处 L1→宿主 的接触点。
10273
- * 🔵 **证据**:grep 本文件 `ctx` 全部 5 处命中 —— 3 处在参数/调用位、2 处在注释,
10274
- * **零处**属性访问(`ctx.xxx`)。
10275
- */
10276
- /**
10277
- * 从 dsh 会话 log 读取标题(latest-wins `session/title` 事件)——
10278
- * **标题不在 `sessions.list()` 条目上**(wire/对象均无 title 字段;F3 命名经
10279
- * `sessionTitle.rename` 写进 session log),必须从 events 提取(rebuild 同款读取模式)。
10280
- */
10281
- function readSessionTitle(session) {
10282
- try {
10283
- const events = sessionEvents(session);
10284
- for (let i = events.length - 1; i >= 0; i--) {
10285
- const e = events[i];
10286
- if (e?.type === "session/title" && typeof e.data?.title === "string" && e.data.title.trim() !== "") return e.data.title.trim();
10287
- }
10288
- } catch {}
10289
- return null;
10290
- }
10291
- /**
10292
- * 逐条 live 会话(`cccRoot` = 该会话 cwd 的 CCC 归属,无 → null)。
10331
+ * ⇒ 结论:**「守护程序自己修好」≠「一定有人拉起」**。任何单条 CRO 轨迹都可能因为
10332
+ * 自身在跑、绑定缺失、阈值保护、程序 bug 而漏掉 —— 而被打断的会话**不会自己恢复**。
10333
+ * ⇒ 兜底必须放在**容器自己**身上,且必须**自己完成"发现 + 注入"**(只扫不动没有用)。
10293
10334
  *
10294
- * CCC 枚举("本机有哪些 CCC")**不在这里**,归 `ccc-roots.listCccs`(并集语义);
10295
- * 本函数保留的是它**独有**的那部分:会话维度 + cwd 归属 + 标题 + id。
10335
+ * ## 边界(写在这里免得后人扩大它)
10296
10336
  *
10297
- * @param ctx 插件上下文(**只透传**给 L0 取数口,本模块不解引用 ⇒ 类型为 `unknown`)
10298
- * @returns 逐条 live 会话
10337
+ * - ⚠️ **这会真的起 agent、真烧算力** —— owner 已被告知并点头(2026-10-23)。
10338
+ * - 🔴 **必须排除 live 会话**:正在跑的会话不该被再注入一次(否则重复投递)。
10339
+ * - 🔴 **与 CRO 并存、不替代**:CRO 是**轨迹自己的策略**(何时该叫由它判),
10340
+ * 本兜底只覆盖「**无论谁都没叫**」这一格。两者重复命中时,靠 `live` 排除天然去重。
10341
+ * - ⚠️ **本函数任何失败都不得连累既有链路**(设计 §5 铁律):整体 try/catch 吞掉并留一行日志。
10299
10342
  */
10300
- function listLiveSessions(ctx) {
10301
- const out = [];
10343
+ async function runInterruptedSafetyNet(ctx, root, log) {
10302
10344
  try {
10303
- const sessions = hostSessions(ctx);
10304
- for (const s of sessions?.list?.() ?? []) {
10305
- const cwd = s?.header?.cwd ?? null;
10306
- out.push({
10307
- id: s?.id ?? "",
10308
- cwd,
10309
- cccRoot: cwd ? cccRootForCwd(cwd) : null,
10310
- title: readSessionTitle(s)
10311
- });
10345
+ const agents = hostAgents(ctx);
10346
+ const sc = hostService(ctx, "sessionController");
10347
+ const liveIds = /* @__PURE__ */ new Set();
10348
+ if (typeof agents?.get === "function") for (const id of collectLiveSessionIdsForSafetyNet(ctx)) liveIds.add(id);
10349
+ const scan = scanInterruptedSessions(`${sessionsRootDir()}/${projectDirNameForCwd(root)}`, { liveIds });
10350
+ if (scan.sessions.length === 0) return;
10351
+ const resumeOne = createResumeOne({
10352
+ agents,
10353
+ sessionController: sc,
10354
+ makeMessage: (text) => createUserMessage({
10355
+ content: [{
10356
+ type: "text",
10357
+ text
10358
+ }],
10359
+ source: PLUGIN_SOURCE
10360
+ })
10361
+ });
10362
+ for (const hit of scan.sessions) try {
10363
+ const out = await resumeOne(hit);
10364
+ log.push(`· ${root}: 中断兜底 ${hit.sessionId}(turn ${hit.turn ?? "?"})→ ` + (out.ok ? `已注入(${out.how ?? "?"})` : `未注入:${out.error ?? "未知"}`));
10365
+ } catch (err) {
10366
+ log.push(`· ${root}: 中断兜底 ${hit.sessionId} 注入抛错(已忽略):${String(err?.message ?? err)}`);
10312
10367
  }
10368
+ } catch (err) {
10369
+ log.push(`· ${root}: 中断兜底整体失败(已忽略,不影响既有链路):${String(err?.message ?? err)}`);
10370
+ }
10371
+ }
10372
+ /** 取容器内**全部** live 会话 id(best-effort,取不到就少排除几个)。 */
10373
+ function collectLiveSessionIdsForSafetyNet(ctx) {
10374
+ const out = [];
10375
+ try {
10376
+ const list = hostSessions(ctx)?.list?.() ?? [];
10377
+ for (const s of list) if (typeof s?.id === "string" && s.id) out.push(s.id);
10313
10378
  } catch {}
10314
10379
  return out;
10315
10380
  }
10316
- function diagLive(ctx) {
10317
- const processCwd = process.cwd();
10318
- return {
10319
- processCwd,
10320
- processCcc: cccRootForCwd(processCwd),
10321
- liveSessions: listLiveSessions(ctx)
10322
- };
10381
+ /**
10382
+ * 装配调度器(index.ts apply 调用):5min tick + 生命周期 disposer +
10383
+ * 会话出现/settings 变化时热启动(与 autopilot 同款触发面,互不干扰)。
10384
+ * @param ctx 插件上下文
10385
+ */
10386
+ function registerWakeScheduler(ctx) {
10387
+ clockOpts.ctx = ctx;
10388
+ clock.start();
10389
+ registerDisposer(ctx, "认知轨迹唤醒调度器", () => clock.dispose());
10323
10390
  }
10324
10391
  //#endregion
10325
- //#region src/trajectory/interrupted.ts
10392
+ //#region src/cro/guide.ts
10326
10393
  /**
10327
- * 中断轮次发现(`interrupted` 会话扫描)
10328
- *
10329
- * ## 这个模块解决什么
10330
- *
10331
- * DSH 的会话日志里,**进程在某一轮完成前退出**(被 kill/崩溃/关机超时)时,
10332
- * 宿主重放时会为该轮补上收尾事件:
10333
- *
10334
- * ```jsonl
10335
- * turn/end {"turn":66,"reason":{"kind":"interrupted"}}
10336
- * step/end {"turn":66,"step":85}
10337
- * session/end-seed {}
10338
- * ```
10339
- *
10340
- * 宿主**只补收尾,不续跑** —— 那一轮的工作就此停在那里。本模块负责把这些会话找出来,
10341
- * 交给上层(认知轨迹)决定要不要续。
10342
- *
10343
- * ## 判据(三条同时成立,缺一不可)
10394
+ * cro/guide.ts — CRO 编写指南(**ACC 内置**,S142 §7.8,2026-09-19)
10344
10395
  *
10345
- * 1. 日志里**最后一条 `turn/end`** 的 `reason.kind === 'interrupted'`;
10346
- * 2. 该 `turn/end` **之后没有新的 `turn/start`**(否则已被后续轮次接续);
10347
- * 3. ⛔ **不要求**末条是 `session/end-seed`(2026-10-23 放宽,原因见 {@link detectInterruptedEnd})。
10396
+ * ## 为什么指南在 ACC 而不在 CCC(所有者令)
10348
10397
  *
10349
- * ⚠️ **为什么不能只看"存在过 interrupted"**:一个长会话历史上可能中断几十次
10350
- * (本容器实测单会话 33 次)。只看"曾经中断"会把早已恢复的会话全部当成待续 ⇒ 灾难。
10398
+ * 所有者 2026-09-19:「**ACC 内置,整个机制属于 ACC,CCC 是用户**」。
10399
+ * ⇒ 契约 / 调度 / 执行 / 状态暴露 / 容错 / **编写指南** 全在 ACC;
10400
+ * CCC(用户)只负责**写那段程序**。
10351
10401
  *
10352
- * 🔴 **为什么原判据要求"末条 = end-seed"是错的**(实测 26 条里漏 2 条):
10353
- * 重启后宿主会在 end-seed **之后**追加 `agent/inbox/spliced`(把待办消息重新塞进收件箱)
10354
- * ⇒ "末条必须 end-seed"**在恢复动作发生之后就恒不成立**,
10355
- * 而"被恢复过又再次被打断"恰恰是守护最该覆盖的形态。
10402
+ * ## 🔴 单真相源纪律(本文件的核心约束)
10356
10403
  *
10357
- * ## 为什么不整文件解压
10404
+ * 指南**不复制**契约条款,且**样例快照是"派生"而非"手抄"**:
10405
+ * {@link CRO_SAMPLE_SNAPSHOT} 由 {@link buildCroSnapshot} **真正装配**出来 ⇒
10406
+ * **schema 一变,样例自动跟着变**,不存在"文档里的样例过期"这一失效模式。
10407
+ * (本容器栽过多次"第二真相源",此处用代码结构而不是纪律去防它。)
10358
10408
  *
10359
- * DSH 日志是 **zstd 多帧追加**(实测单会话 24705 帧 / 47 MB)。原生
10360
- * `zlib.zstdDecompressSync` 只解**第一帧**,对多帧直接报 `Unknown frame descriptor`。
10361
- * ⇒ 采用**尾部窗口 + 倒序逐帧解压**:只读文件末尾 N 字节,从后往前找帧魔数,
10362
- * 解到凑够所需事件数即停。实测 8h 窗口 6 个会话 **20 ms**。
10409
+ * ## 所有者对本指南的两条明示要求(2026-09-19)
10363
10410
  *
10364
- * ## 世代识别复用 `cleanup.ts`
10411
+ * 1. 🔴 **必须备注:CRO 程序是可自测的;测试通过再把文件名改成正式名**;
10412
+ * 2. 🔴 **要一并给出测试用的参数**。
10413
+ */
10414
+ /**
10415
+ * 样例快照输入 —— **代表性数据**(不是真实某条轨迹的现状)。
10365
10416
  *
10366
- * 日志文件名有多个世代(`session.jsonl` / `session.v3.jsonl.zstd` / `session.v4.jsonl.zstd`…),
10367
- * 宿主按世代写且**迁移保留源文件**。⇒ 本模块**不硬编码文件名**,走
10368
- * {@link findSessionLog}(与清理/体积统计同一判据),避免"新世代对本模块不可见"。
10417
+ * 取值刻意让它能演示所有者举的"天亮/有人/非高峰 + 已在干活 + 日志过大"场景。
10418
+ * 🔒 **路径与轨迹名一律用中性占位**(`/home/user/workspace`、`<S###>` 式示例),
10419
+ * 不写任何真实机器路径或私有会话名 —— 本文件随 npm 包发布(2026-10-14 隐私核查)。
10420
+ */
10421
+ const CRO_SAMPLE_SNAPSHOT_INPUT = {
10422
+ dirName: "2026-01-01--S001--example-cognitive-trajectory",
10423
+ cccRoot: "/home/user/workspace",
10424
+ nowMs: 17898e8,
10425
+ sessionMdPath: "/home/user/workspace/AGENT_SESSIONS/2026-01-01--S001--example-cognitive-trajectory/SESSION.md",
10426
+ sessionMdBytes: 182e3,
10427
+ sessionMdMtimeMs: 1789799e6,
10428
+ references: [{
10429
+ name: "campaign-100.md",
10430
+ bytes: 41200,
10431
+ mtimeMs: 178979e7
10432
+ }, {
10433
+ name: "auto-exposure.md",
10434
+ bytes: 8400,
10435
+ mtimeMs: 17897e8
10436
+ }],
10437
+ boundSessionIds: ["session-adccc461-9f32-4d9a-a688-9da81fa1ca35"],
10438
+ liveSessionIds: ["session-adccc461-9f32-4d9a-a688-9da81fa1ca35"],
10439
+ runningSessionIds: [],
10440
+ pendingWakes: [{
10441
+ id: "w-20260101-0900-aaaa",
10442
+ at: "2026-01-01T09:30:00+08:00",
10443
+ createdAt: "2026-01-01T07:00:00+08:00",
10444
+ createdBy: "S001"
10445
+ }],
10446
+ scheduler: {
10447
+ armed: true,
10448
+ enabled: true,
10449
+ ticks: 128,
10450
+ lastSkipReason: null
10451
+ }
10452
+ };
10453
+ /**
10454
+ * 🔴 **样例快照(测试参数)** —— **由真实装配器生成**,故**永不与 schema 漂移**。
10369
10455
  *
10370
- * ## 边界
10456
+ * 用途:写 CRO 程序时把它喂给你的程序做自测(见指南 §8「自测」)。
10457
+ * @returns 缩进过的快照 JSON 文本
10458
+ */
10459
+ function renderCroSampleSnapshot() {
10460
+ return JSON.stringify(buildCroSnapshot(CRO_SAMPLE_SNAPSHOT_INPUT), null, 2);
10461
+ }
10462
+ /**
10463
+ * CRO 编写指南正文(ACC 内置手册;由工具的 guide 动作输出)。
10371
10464
  *
10372
- * - **只读**:不改任何会话日志。
10373
- * - **不判周期**:何时扫、扫完做什么归调用方(CCC 的认知轨迹)。本模块只回答
10374
- * 「此刻哪些会话以 interrupted 结尾」。
10465
+ * 结构照既有 guide 惯例(`MSM_GUIDE` / `cheap-worker(guide=true)`):
10466
+ * 用途 → 位置 → 我给什么 → 你给我什么 → 骨架 → 常见模式 → 坑 → **自测**。
10375
10467
  */
10468
+ const CRO_GUIDE = `CRO(Continuous Re-Occurrence · 持续再发生)编写指南
10469
+
10470
+ ## 0. 这是什么(一句话)
10471
+ 让一条轨迹**自己带一段程序**,由 ACC 在每次检查时跑它,
10472
+ **由这段程序决定「现在该不该叫我、叫我的时候说什么」**。
10473
+
10474
+ 今天的唤醒只认时间("3 点叫我");CRO 让它认**情况**("天亮且家里有人才叫我")。
10475
+
10476
+ ## 1. 它在系统里的位置
10477
+ · **机制属 ACC**(本指南、契约、调度、执行、容错都在 ACC)
10478
+ · **程序属 CCC**(你写的那段 TS,放在**你自己轨迹的目录**里)
10479
+ · **执行者** = ACC 的唤醒调度器(每 5 分钟一次 tick)
10480
+
10481
+ ## 2. 文件放哪、叫什么
10482
+ <CCC 根>/AGENT_SESSIONS/<轨迹目录>/continuous-re-occurrence.ts
10483
+
10484
+ 🔴 **文件名必须全写**(不许用缩写)。
10485
+ 🔴 **程序只有一个**:**文件在 = 启用;文件不在 = 禁用**。
10486
+ —— 没有**逐条**开关、没有注册表、没有 enabled 字段。
10487
+ 🔴 **但上面那条只管"这一条"** —— 另有一个**全局总闸** \`croEnabled\`
10488
+ (设置面板「**CRO(轨迹自编程唤起)**」,缺省**开**):
10489
+ 关掉**只停 CRO 阶段** —— \`send-later\` / \`send-now\` / 唤醒表投递**照常工作**。
10490
+ ⚠️ 所以「**文件在**」**不等于**「**一定会跑**」:总闸关掉时 ACC **根本不调用**你的程序
10491
+ (tick 日志里会记一行跳过理由 —— 这是为了让你事后能分辨
10492
+ 「**总闸关着没跑**」和「**跑了、程序自己判了不叫**」这两件完全不同的事)。
10493
+
10494
+ ## 2b. 🔴 机器绑定(**必须指定,否则不运行** —— 所有者令 2026-10-15)
10495
+
10496
+ 同目录再放一个文件:
10497
+
10498
+ <CCC 根>/AGENT_SESSIONS/<轨迹目录>/cro-host-binding
10499
+
10500
+ 内容 = **一行机器名**(就是本机 \`hostname\`)。也支持 \`#\` 注释行,方便留痕:
10501
+
10502
+ # 这条轨迹的自唤醒只在 CCC 宿主机上跑
10503
+ yh-Standard-PC-Q35-ICH9-2009
10504
+
10505
+ **语义(三条,缺一不可)**:
10506
+ · **只许这一台** —— 文件名里写的那台;别处**不跑**;
10507
+ · 🔴 **fail-closed** —— **没写这个文件 ⇒ 不跑**(不是"没写就随便跑");
10508
+ · **检查者是唤醒器**(ACC),**不是**你的程序 —— 你的程序**根本不会被调用**。
10509
+
10510
+ **为什么要有它**:CRO 程序本身**随 git 走**(它在你轨迹目录里,会跟着 CCC 到第二台机器)。
10511
+ 没有这道门,**同一段程序会在两台机器各跑一次**(同一轨迹被叫两次)。
10512
+ 有了它,第二台机器读到"绑给了别人"就**正确地不跑**。
10513
+
10514
+ ⚠️ **两条与它有关的纪律**:
10515
+ 1. 🔴 **不要把这个文件加进 \`.gitignore\`** —— 它**必须**跟着仓走。
10516
+ 否则第二台机器读到的是"没绑",结果**碰巧一样**(也不跑),
10517
+ 但你的判据从「**明确绑给了别人**」退化成「**什么都没说**」⇒ 排查时看不出真因。
10518
+ 2. 🔴 **tick 日志会区分两种跳过**:
10519
+ 「\`not-bound\` 你没绑」与「\`bound-to-other\` 你绑给了别人」是**两件事**,文案不同。
10520
+ (2026-10-15 有过一次教训:两种不同的原因报同一句话,害排查指错方向。)
10521
+
10522
+ **改绑 / 解除**:直接改这个文件(或删掉 ⇒ 变回"没绑 ⇒ 不跑")。**没有额外命令**。
10523
+
10524
+ ## 3. 我给你什么(输入)
10525
+ ACC 把这条轨迹的状态做成一个 **JSON 快照**,从 **stdin** 喂给你的程序。
10526
+ 字段分四组:identity(身份)/ time(时间)/ body(轨迹身体)/ binding + scheduling(绑定与调度)。
10527
+
10528
+ ⚠️ **给的是"快照",不是"无限能力"**:ACC 看不见的东西(比如"你上次判了什么")
10529
+ **物理上给不了**(那是你进程里的内存)。那类状态**归你自己**——
10530
+ 写在你自己的轨迹目录里(例如一个 state.json),自己读自己写。
10531
+
10532
+ ## 4. 你给我什么(输出)
10533
+ 向 **stdout** 写 **一行 JSON**:
10534
+
10535
+ { "wake": true, "prompt": "唤起时的提示词", "reason": "判定理由(可选)" }
10536
+ { "wake": false } // 不唤起
10537
+
10538
+ · \`wake\` 缺省 / false ⇒ **不唤起**(**这是常态**,缺省是"不打扰")
10539
+ · \`wake: true\` 时 \`prompt\` **必须非空**(否则视为非法,本轮跳过、不投递空消息)
10540
+ · 🔴 \`reason\` **强烈建议填**:见 §7 坑 3
10541
+
10542
+ ## 5. 骨架
10543
+
10544
+ // continuous-re-occurrence.ts
10545
+ let raw = ''
10546
+ process.stdin.on('data', (d) => { raw += d })
10547
+ process.stdin.on('end', () => {
10548
+ const s = JSON.parse(raw) // 轨迹状态快照
10549
+ // ... 在这里写你的判定 ...
10550
+ process.stdout.write(JSON.stringify({ wake: false }))
10551
+ })
10552
+
10553
+ 例(所有者举的 S185 那类情形,仅示意):
10554
+
10555
+ const tooBig = (s.body.sessionMdBytes ?? 0) > 150_000
10556
+ const busy = s.binding.runningSessionIds.length > 0
10557
+ if (busy) return out({ wake: false, reason: '已在干活' })
10558
+ if (tooBig) return out({ wake: true, prompt: '你的 SESSION.md 已很大,请先压缩再继续。', reason: '日志超限' })
10559
+
10560
+ ## 6. 常见模式
10561
+ · **环境谓词**(天亮/有人/非高峰):ACC 给不了这些 —— **你自己去拿**(查设备、读传感器、
10562
+ 查你自己的程序)。这就是"程序和程序可以互相访问"的用法。快照里的 \`time\` 只是给你一个时间锚。
10563
+ · **已在工作就别叫**:看 \`binding.runningSessionIds\`(非空 = 有载体正在跑轮次)。
10564
+ ⚠️ 注意它和 \`liveSessionIds\` 不同:**会话活着 ≠ 在干活**。
10565
+ · **要求它先自整理**:不需要新机制 —— **就是 \`prompt\` 的一种写法**
10566
+ (如"你的 SESSION.md 已 180 KB,请先压缩再继续")。
10567
+ · **次数上限 / 连续 N 次才叫**:ACC 不给你计数器(**给不了**)⇒ **自己目录里写状态文件**。
10568
+
10569
+ ## 7. 坑
10570
+ 1. **不许阻塞等输入**:你只从 stdin 拿一次快照,不要在程序里 readline / prompt 等用户输入。
10571
+ 2. **硬超时 60 秒**:超时会被 SIGKILL、本轮跳过。外部查询请自己设更短的超时。
10572
+ 3. 🔴 **一定要填 \`reason\`**:改成程序判定之后,"当时为什么叫了"**不再能从时间表重建**——
10573
+ 原因在**你的程序肚子里**(可能有随机、可能看了外部数据)。不写 reason,
10574
+ 以后出事**无法复现**。这条不是形式主义,是**可重建性**的要求。
10575
+ 4. **失败只影响你自己**:程序报错/超时/输出非法 ⇒ 记一行 + **跳过本轮**,
10576
+ **绝不会影响** \`send-later\` / \`send-now\` 等既有机制。所以你可以放心试。
10577
+ 5. 🔴 **不要提前用正式文件名**:见 §8。
10578
+
10579
+ ## 8. 🔴 自测(**测试通过再把文件名改成正式的**)
10580
+ CRO 程序**是可以自测的** —— 因为它只做一件事:**吃一份快照 JSON,吐一行决策 JSON**。
10581
+ (纯函数式的边界 ⇒ 不需要真机、不需要等 5 分钟 tick、不需要调度器。)
10582
+
10583
+ ### 8.1 推荐流程:**用开发名写,绿了再改名**
10584
+ 因为「**文件在 = 启用**」**没有逐条开关**(总闸是全局的,管不了单条),
10585
+ **改名这个动作本身就是上线动作**:
10586
+
10587
+ 1. 先用**开发名**写(如 \`continuous-re-occurrence.dev.ts\`)
10588
+ —— 开发名**不会**被 ACC 启用(启用只认正式名 exact match),可以放心留一半
10589
+ 2. 配一个自测(如 \`continuous-re-occurrence.dev.test.ts\`),喂几份快照断言输出
10590
+ 3. **全部绿了** ⇒ 改名为 \`continuous-re-occurrence.ts\` ⇒ **这一刻才生效**
10591
+
10592
+ ⚠️ 若你跳过自测直接上正式名:半成品会被当成生效程序,**报错并跳过本轮**(不会搞坏系统,
10593
+ 但那条轨迹的 CRO 就是空转的)。**报错是安全网,不是工作流。**
10594
+
10595
+ ### 8.2 手工试跑(最快的一条路)
10596
+ 把样例快照喂给你的程序,看它吐什么:
10597
+
10598
+ # 用开发名试跑(推荐 bun;node 需能跑 TS)
10599
+ bun continuous-re-occurrence.dev.ts < sample-snapshot.json
10600
+
10601
+ # 或直接管道
10602
+ echo '<一行快照 JSON>' | bun continuous-re-occurrence.dev.ts
10603
+
10604
+ ### 8.3 测试用的参数
10605
+ **样例快照 = 由 ACC 的装配器实时生成**(故**永不与 schema 漂移**,schema 一变它跟着变)。
10606
+ 取法:**运行本指南所在工具的 guide 动作**,或用同一机制导出;内容是:
10607
+
10608
+ \`\`\`json
10609
+ ${renderCroSampleSnapshot()}
10610
+ \`\`\`
10611
+
10612
+ 该样例刻意覆盖了:
10613
+ · \`body.sessionMdBytes\` = 182000(**日志过大**那一档)
10614
+ · \`binding.runningSessionIds\` = [](**空闲**;把它填成一个 id 就能测"已在干活"分支)
10615
+ · \`scheduling.pendingWakes\` 非空(**已有待办唤醒**)
10616
+ · 无 S 前缀格式的情形请自己造 \`dirName\`(\`identity.code\` 会是空串——那是**正常**的,
10617
+ 编号不是固定格式,硬锚永远是 \`dirName\`)
10618
+
10619
+ ### 8.4 自测该断言什么(建议清单)
10620
+ · 快照缺字段(\`null\` 体积)时不崩 —— 用 \`?? 0\` 之类兜底
10621
+ · \`wake:true\` 时 \`prompt\` **确实非空**
10622
+ · "已在干活"分支:\`runningSessionIds\` 非空 ⇒ \`wake:false\`
10623
+ · 同一个程序**连跑两次**给同样输入 ⇒ 同样输出(除非你刻意用了随机/外部状态,
10624
+ 那就必须写进 \`reason\`)
10625
+
10626
+ ## 9. 一句话
10627
+ **吃一份快照,吐一行决策。先自测,绿了再改名。**
10628
+ `;
10629
+ //#endregion
10630
+ //#region src/trajectory/resident.ts
10376
10631
  /**
10377
- * zstd 解压(**运行时有、类型声明没有**)。
10632
+ * trajectory/resident.ts — **常驻名单**(D173 的兑现口,`cognitive_trajectory resident` 的实现)
10378
10633
  *
10379
- * 🔴 为什么不能用 `import { zstdDecompressSync } from 'node:zlib'`:
10380
- * 本仓 `@types/node@^20` 的声明里**没有** zstd 系列(Node 22.15+ 才进声明)。
10381
- * 而运行时本机 v22.22.1 **确实有**(实测 `typeof require('zlib').zstdDecompressSync === 'function'`)。
10382
- * ⇒ 用命名空间导入 + 局部断言,并**在运行时校验**;缺失时走明确降级(返回空,不静默瞎猜)。
10383
- */
10384
- const zstdDecompressSync = nodeZlib.zstdDecompressSync;
10385
- /** zstd 帧魔数(小端 `28 B5 2F FD`) */
10386
- const ZSTD_MAGIC = Buffer.from([
10387
- 40,
10388
- 181,
10389
- 47,
10390
- 253
10391
- ]);
10392
- /** 默认回看窗口:8 小时(owner 2026-10-22 定) */
10393
- const DEFAULT_LOOKBACK_MS = 288e5;
10394
- /** 每次从文件尾部读取的字节数(够覆盖数百个末尾事件) */
10395
- const TAIL_WINDOW_BYTES = 1 << 20;
10396
- /** 需要凑够的末尾事件数(够跨过 turn/end + step/end + end-seed 即可,留余量) */
10397
- const TAIL_EVENT_TARGET = 60;
10398
- /**
10399
- * 从文件尾部倒序解压,凑够 `want` 个事件。
10634
+ * ## 这条能力的判据(owner 2026-10-20 原话,逐字)
10400
10635
  *
10401
- * 多帧 zstd 必须**按帧边界**切:从后往前找魔数,每帧用下一帧起始位置当结束边界。
10402
- * 任何一帧解压失败就停下(返回已拿到的)—— 再往前更可能不完整。
10403
- */
10404
- function readTailEvents(logPath, want) {
10405
- if (zstdDecompressSync === void 0) return [];
10406
- const st = statSync(logPath);
10407
- const windowBytes = Math.min(TAIL_WINDOW_BYTES, st.size);
10408
- if (windowBytes < 8) return [];
10409
- const buf = Buffer.alloc(windowBytes);
10410
- const fd = openSync(logPath, "r");
10411
- try {
10412
- readSync(fd, buf, 0, windowBytes, st.size - windowBytes);
10413
- } finally {
10414
- closeSync(fd);
10415
- }
10416
- const offsets = [];
10417
- for (let i = 0; i <= windowBytes - ZSTD_MAGIC.length; i++) if (buf[i] === ZSTD_MAGIC[0] && buf[i + 1] === ZSTD_MAGIC[1] && buf[i + 2] === ZSTD_MAGIC[2] && buf[i + 3] === ZSTD_MAGIC[3]) offsets.push(i);
10418
- if (offsets.length === 0) return [];
10419
- const events = [];
10420
- for (let k = offsets.length - 1; k >= 0 && events.length < want; k--) {
10421
- const start = offsets[k];
10422
- const end = k + 1 < offsets.length ? offsets[k + 1] : windowBytes;
10423
- let text;
10424
- try {
10425
- text = zstdDecompressSync(buf.subarray(start, end)).toString("utf8");
10426
- } catch {
10427
- break;
10428
- }
10429
- const batch = [];
10430
- for (const line of text.split("\n")) {
10431
- if (!line.trim()) continue;
10432
- try {
10433
- batch.push(JSON.parse(line));
10434
- } catch {}
10435
- }
10436
- events.unshift(...batch);
10437
- }
10438
- return events.slice(-want);
10439
- }
10440
- /**
10441
- * 判定一组尾部事件是否「以 interrupted 结尾」。
10442
- * 不命中 → null。
10636
+ * > 「常驻的意思就是**自己声明为自己欢迎别人来麻烦自己**,和永不休眠没啥关系,
10637
+ * > **trajectory never ends 悖论的一种实现**」
10443
10638
  *
10444
- * ## 🔴 2026-10-23 判据放宽(owner 实测:「不是挂在工具调用上的,也要能被 interrupted 筛选到并唤醒」)
10639
+ * ⇒ **常驻不是资源状态**。它不是"永不休眠"、不是"常驻内存"、不是"不许销毁我的会话"。
10640
+ * 一条轨迹**永远存在**(没有生命周期),常驻与否都不改变这一点。
10445
10641
  *
10446
- * ### 原判据(v1.87.4 起)为什么漏
10447
- * 原来只认 **末条事件 = `session/end-seed`**。实测(本机 3337 条会话全扫):
10448
- * · 末轮 `turn/end[interrupted]` 的会话 **26** 条;
10449
- * · 其中满足原判据的只有 **24** 条 ⇒ **漏 2 条**,形态逐字为:
10450
- * `… turn/end[interrupted] -> session/end-seed -> agent/inbox/spliced -> agent/inbox/spliced`
10451
- * ⇒ 重启后宿主把这**两条**追加在 end-seed 之后(它们是"恢复时把待办消息重新塞进收件箱"),
10452
- * 于是"末条必须 end-seed"这条**在恢复动作发生之后就恒不成立**。
10453
- * ⚠️ 讽刺的是:**正是那些被恢复过、又再次被打断的会话**最容易长成这样
10454
- * ⇒ 原判据恰好对"反复中断"的会话失明,而它们才是最需要守护的。
10642
+ * ⇒ **它管的是「被找到」,不是「不被销毁」。**
10643
+ * 两件事混为一谈,就会去动调度器的载体生命周期 —— 那正是本轨反复踩的"名字撒谎"。
10455
10644
  *
10456
- * ### 新判据(三条,缺一不可)
10457
- * 1. 日志里**最后一条 `turn/end`** 的 `reason.kind === 'interrupted'`;
10458
- * 2. 该 `turn/end` **之后没有新的 `turn/start`**(否则说明它已被后续轮次接续);
10459
- * 3. ⛔ **不再要求末条是 `end-seed`**。
10645
+ * ## 因此本模块的三条硬边界
10460
10646
  *
10461
- * ### 为什么不改成"末条必须是某个白名单"
10462
- * 那就是原缺陷换个白名单再犯一次(`agent/inbox/spliced` 之外还会有别的)。
10463
- * **判据要问的是"最后一轮的结束原因是什么、之后有没有新轮开始"**,
10464
- * 而不是"日志最后一个 token 长什么样"——后者是**载体形态**,前者是**状态**。
10647
+ * 1. **纯读**:不写任何文件、不碰注册表、不碰 `wake/scheduler.ts`。
10648
+ * 2. **不进系统提示词**(owner 裁决 Q2:说明类信息按需取,不占每轮 token)。
10649
+ * 3. **只报写了 `serves` 的轨迹** —— 只写半个(有 `resident` 没 `serves`)等于
10650
+ * "把自己挂上去却不说能干什么",按手册的判据那比不写更坏,所以**不列出来**,
10651
+ * 但要**单独数出**来,让写的人知道自己的写法没生效。
10465
10652
  *
10466
- * ### 为什么不会误报"曾经中断但已恢复"
10467
- * 恢复一定伴随 `turn/start`(新一轮)或新的 `turn/end`(`completed` 等)⇒ 判据 1/2 各自挡住。
10468
- * 实测该放宽**不新增误报**:原 24 条全含于新判据(`curOnly = 0`),新增 2 条全是真漏。
10653
+ * ## 归属(D23)
10654
+ * 机制在 ACC(怎么找、按什么顺序、列什么字段);内容在 CCC(`serves` 那句话写什么)。
10469
10655
  */
10470
- function detectInterruptedEnd(events) {
10471
- if (events.length === 0) return null;
10472
- let idx = -1;
10473
- for (let i = events.length - 1; i >= 0; i--) if (events[i]?.type === "turn/end") {
10474
- idx = i;
10475
- break;
10476
- }
10477
- if (idx < 0) return null;
10478
- const turnEnd = events[idx];
10479
- if (turnEnd?.data?.reason?.kind !== "interrupted") return null;
10480
- for (let i = idx + 1; i < events.length; i++) if (events[i]?.type === "turn/start") return null;
10481
- const turn = typeof turnEnd.data?.turn === "number" ? turnEnd.data.turn : null;
10482
- return {
10483
- turn,
10484
- pendingTools: findPendingTools(events, turn)
10485
- };
10486
- }
10487
10656
  /**
10488
- * 找出末轮里发起了但没有结果的工具调用名。
10657
+ * 列出全部**可被找到**的轨迹(按编码排序,确定性)。
10489
10658
  *
10490
- * ⚠️ **尽力而为**,非权威:尾部窗口可能截断更早的调用。上层应把它当
10491
- * "**可能**未完成的副作用"清单,而不是"确定未完成"。
10492
- */
10493
- function findPendingTools(events, turn) {
10494
- if (turn === null) return [];
10495
- const called = /* @__PURE__ */ new Map();
10496
- const resolved = /* @__PURE__ */ new Set();
10497
- for (const e of events) {
10498
- const data = e.data;
10499
- if (!data || data.turn !== turn) continue;
10500
- if (e.type === "tool/call" && typeof data.callId === "string" && typeof data.name === "string") called.set(data.callId, data.name);
10501
- else if (e.type === "tool/result" && typeof data.callId === "string") resolved.add(data.callId);
10502
- }
10503
- return [...called].filter(([callId]) => !resolved.has(callId)).map(([, name]) => name);
10504
- }
10505
- /**
10506
- * 扫描一个 **project 目录**(形如 `<sessions-root>/--home-yh-home-home-serenity--/`)。
10659
+ * ⚠️ 读盘失败 / 坏 JSON 一律**跳过该条**(不抛):一份坏的声明不该让整张名单拿不到。
10507
10660
  *
10508
- * @param projectDir DSH 项目目录(其下每个子目录是一个会话)
10509
- * @param options.lookbackMs 回看窗口(缺省 8h)
10510
- * @param options.now 注入"当前时刻"(测试用)
10511
- * @param options.liveIds 需要跳过的会话 id(正在跑的会话不该被判为待续)
10661
+ * @param root CCC 根
10512
10662
  */
10513
- function scanInterruptedSessions(projectDir, options = {}) {
10514
- const t0 = Date.now();
10515
- const lookbackMs = options.lookbackMs ?? 288e5;
10516
- const cutoff = (options.now ?? Date.now()) - lookbackMs;
10517
- const liveIds = options.liveIds ?? /* @__PURE__ */ new Set();
10518
- const result = {
10519
- sessions: [],
10520
- scanned: 0,
10521
- skipped: 0,
10522
- errors: [],
10523
- elapsedMs: 0
10524
- };
10525
- if (!existsSync(projectDir)) {
10526
- result.elapsedMs = Date.now() - t0;
10527
- return result;
10528
- }
10529
- let entries;
10663
+ function collectResident(root) {
10664
+ const entries = [];
10665
+ const incomplete = [];
10666
+ let names = [];
10530
10667
  try {
10531
- entries = readdirSync(projectDir);
10532
- } catch (err) {
10533
- result.errors.push({
10534
- sessionId: "*",
10535
- message: err instanceof Error ? err.message : String(err)
10536
- });
10537
- result.elapsedMs = Date.now() - t0;
10538
- return result;
10668
+ names = readdirSync(sessionsRoot(root), { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith("_") && !e.name.startsWith(".")).map((e) => e.name);
10669
+ } catch {
10670
+ return {
10671
+ entries,
10672
+ incomplete
10673
+ };
10539
10674
  }
10540
- for (const sessionId of entries) {
10541
- if (liveIds.has(sessionId)) continue;
10542
- const dirPath = join(projectDir, sessionId);
10543
- const logPath = findSessionLog(dirPath);
10544
- if (logPath === null) continue;
10545
- let mtimeMs;
10546
- try {
10547
- mtimeMs = statSync(logPath).mtimeMs;
10548
- } catch (err) {
10549
- result.errors.push({
10550
- sessionId,
10551
- message: err instanceof Error ? err.message : String(err)
10552
- });
10553
- continue;
10554
- }
10555
- if (mtimeMs < cutoff) continue;
10556
- result.scanned++;
10675
+ for (const dirName of names) {
10676
+ let profile;
10557
10677
  try {
10558
- const events = readTailEvents(logPath, TAIL_EVENT_TARGET);
10559
- const hit = detectInterruptedEnd(events);
10560
- if (!hit) {
10561
- result.skipped++;
10562
- continue;
10563
- }
10564
- const tailTime = events[events.length - 1]?.time;
10565
- result.sessions.push({
10566
- sessionId,
10567
- logPath,
10568
- turn: hit.turn,
10569
- lastEventAt: typeof tailTime === "number" ? tailTime : null,
10570
- mtimeMs,
10571
- pendingTools: hit.pendingTools
10572
- });
10573
- } catch (err) {
10574
- result.errors.push({
10575
- sessionId,
10576
- message: err instanceof Error ? err.message : String(err)
10577
- });
10678
+ profile = readTrajectoryConstraintAt(join(sessionsRoot(root), dirName), root);
10679
+ } catch {
10680
+ continue;
10681
+ }
10682
+ if (!profile || profile.resident === void 0) continue;
10683
+ const serves = profile.resident.serves;
10684
+ if (serves === void 0 || serves === "") {
10685
+ incomplete.push(profile.code);
10686
+ continue;
10578
10687
  }
10688
+ entries.push({
10689
+ code: profile.code,
10690
+ dirName: profile.dirName,
10691
+ serves,
10692
+ ...profile.model !== void 0 ? { model: profile.model } : {}
10693
+ });
10579
10694
  }
10580
- result.sessions.sort((a, b) => (b.lastEventAt ?? b.mtimeMs) - (a.lastEventAt ?? a.mtimeMs));
10581
- result.elapsedMs = Date.now() - t0;
10582
- return result;
10583
- }
10584
- /**
10585
- * 由 CCC 根推导它的 DSH 项目目录名(`--home-yh-home-home-serenity--`)。
10586
- *
10587
- * 规则取自宿主:把绝对路径的 `/` 换成 `-`,并在前后各加 `--`。
10588
- * ⚠️ 这是**推导**,不是真相源 —— 实际目录名以磁盘为准;调用方应在不存在时
10589
- * 回退到"扫整个 sessions root"或直接报未找到。
10590
- */
10591
- function projectDirNameForCwd(cwd) {
10592
- return `--${cwd.replace(/\/+$/, "").split("/").filter(Boolean).join("-")}--`;
10695
+ entries.sort((a, b) => a.code < b.code ? -1 : a.code > b.code ? 1 : 0);
10696
+ incomplete.sort();
10697
+ return {
10698
+ entries,
10699
+ incomplete
10700
+ };
10593
10701
  }
10594
10702
  /**
10595
- * 续跑正文(**简单、稳定、不含版本号**)。
10596
- *
10597
- * 判据(为什么这么短):本动作的用途是**把被打断的会话推回工作状态**,
10598
- * 不是替它重述任务 —— 被打断那一轮的上下文就在它自己的日志里。
10599
- * ⇒ 正文只做三件事:① 说明发生了什么;② 提示**先核对副作用**;③ 让它接着做。
10703
+ * 渲染成给模型读的文本(`cognitive_trajectory resident` 的返回值)。
10600
10704
  *
10601
- * 🔴 **为什么要写"先核对副作用"**:中断时可能有「有 call 无 result」的工具调用,
10602
- * 它们的副作用**未知**(可能已写文件 / 已发消息)。若模型把那一轮整段重放,
10603
- * 会**重复执行**。这句话是给模型的护栏,不是客套。
10705
+ * 🔴 **空名单也是有效结果**,且必须说清"这意味着什么" —— 否则读者会以为工具坏了。
10604
10706
  */
10605
- function buildContinueText(sessionId, turn, pendingTools) {
10606
- const lines = [];
10607
- lines.push("[中断续跑] 上一条进程在本轮完成前退出,该轮已被中止。现在继续。");
10608
- lines.push("");
10609
- lines.push(`会话: ${sessionId}${turn === null ? "" : ` / 被中断的轮次: turn ${turn}`}`);
10610
- if (pendingTools.length > 0) {
10707
+ function listResident(root) {
10708
+ const { entries, incomplete } = collectResident(root);
10709
+ const lines = ["═══ 常驻轨迹(自己声明「欢迎别人来麻烦我」)═══", ""];
10710
+ if (entries.length === 0) {
10711
+ lines.push(" (本 CCC 暂无轨迹声明常驻)");
10712
+ lines.push(" ⇒ 这不是错误:常驻是**自愿的自我声明**,没写就是\"不参与被找\"。");
10713
+ } else for (const e of entries) {
10714
+ const model = e.model !== void 0 ? ` [model: ${e.model}]` : "";
10715
+ lines.push(` · ${e.code}${model}`);
10716
+ lines.push(` ${e.serves}`);
10717
+ }
10718
+ if (incomplete.length > 0) {
10611
10719
  lines.push("");
10612
- lines.push(`⚠️ 中断时这些工具「有调用、无结果」(副作用未知,**可能已经执行**): ${pendingTools.join(", ")}`);
10613
- lines.push(" ⇒ 先核对它们是否已生效(文件是否已写、消息是否已发),再决定要不要重做。");
10614
- lines.push(" ⛔ 不要把被中断的那一轮整段重放。");
10720
+ lines.push(` ⚠️ 写了 resident 但**没写 serves**(不会被列出):${incomplete.join("、")}`);
10721
+ lines.push(" 只写半个 = 把自己挂上去却不说能干什么 —— 别人会来问,然后发现问错了人。");
10615
10722
  }
10616
10723
  lines.push("");
10617
- lines.push("请接着完成该轮未完成的工作;完成后把进展写入本轨迹的 SESSION.md。");
10724
+ lines.push("── 怎么让自己出现在这里 ──");
10725
+ lines.push(" 在你这条轨迹目录的 trajectory.json 里写:");
10726
+ lines.push(" { \"resident\": { \"serves\": \"一句话说清我擅长什么、能帮上什么忙\" } }");
10727
+ lines.push(" ⚠️ 只写这一个字段**不影响任何默认行为**(D183);其余 key 全部可选。");
10618
10728
  return lines.join("\n");
10619
10729
  }
10730
+ //#endregion
10731
+ //#region src/trajectory/live.ts
10620
10732
  /**
10621
- * 把一个 agent 唤醒(注入一条续跑消息)。
10733
+ * trajectory/live.ts — 进程内 live 会话取数 + 精简诊断
10622
10734
  *
10623
- * 两条路径(与 `wake/scheduler.ts` 的 `sendToCognitiveTrajectory` 同源判据):
10624
- * - **live**:`agents.get(id)` 拿得到 ⇒ 目标在跑(或被加载)⇒ `steer` 当场注入当前轮;
10625
- * `steer` 不可用则退回 `followup`(宁可排队,不可静默丢)。
10626
- * - **cold-resume**:`sessionController.resolveAgent(id)` 冷载入后同样注入。
10735
+ * ── 为什么单独成模块(R↓,2026-09-15 S142「ACC 侧 autopilot 退场」S1 搬迁)──
10736
+ * 这两个函数原先住在 `autopilot-trajectory.ts`,但它们的**主语不是 autopilot**:
10737
+ * `listLiveSessions` 服务 `acc-diag` ① 段的"**哪个会话属于哪个 CCC**"(会话维度,
10738
+ * 不是 CCC 维度),而"**独立进程看不到运行时**"正是 `acc-diag` 存在的理由。
10739
+ * autopilot 退场后它们必须活下去 ⇒ 独立成模块,不再挂着已死机制的名字。
10627
10740
  *
10628
- * ⚠️ 冷会话载入**会真起一个 agent**(消耗真实算力)⇒ 调用方必须自己决定要不要,
10629
- * 本函数不做数量限制(那是策略,归调用者)。
10741
+ * ── 迁移边界(不搬什么,以及为什么)──────────────────────────────────────
10742
+ * 原 `diagLive` 还含两段,**均不搬**(其主语是 autopilot,随机制退场):
10743
+ * · `autopilotCccs`(逐 CCC 目标命中 / agent 定位诊断)—— 依赖 `readAutopilotSettings` /
10744
+ * `getAutopilotStatus` / `resolveTargetAgent`
10745
+ * · `panelResolved`(无参面板请求落到哪个 CCC)—— 面板区块随机制退场
10746
+ * 留下的 = **进程 cwd/CCC + live 会话清单**(`acc-diag` ① 段仍需要)。
10747
+ *
10748
+ * 判据纪律:本模块**不得**反向依赖 autopilot 判据层(那会让退场半途而废)。
10749
+ *
10750
+ * ── 🔴 2026-09-25 I2 收敛(约束文档 §2.7 违反清单,L1 违反之一)────────────────
10751
+ * **本模块的 `ctx` 参数类型从 `Context` 改为 `unknown`** —— 消除本文件唯一的 `W2` 接触
10752
+ * (直接 import 宿主 `cordis` 的类型)。
10753
+ *
10754
+ * **为什么这是"真解耦"而不是"把类型藏起来"(判据):** `ctx` 在本模块**从不被解引用** ——
10755
+ * 它只被**原样透传**给 `hostSessions(ctx)`,而后者(`host/access.ts`,**L0**)的参数
10756
+ * 本就声明为 `ctx: unknown` ⇒ `Context` 这个 import **在语义上是多余的**,
10757
+ * 它没有参与任何类型检查,只贡献了一处 L1→宿主 的接触点。
10758
+ * 🔵 **证据**:grep 本文件 `ctx` 全部 5 处命中 —— 3 处在参数/调用位、2 处在注释,
10759
+ * **零处**属性访问(`ctx.xxx`)。
10630
10760
  */
10631
- function createResumeOne(deps) {
10632
- return async (hit) => {
10633
- const { sessionId } = hit;
10634
- const text = buildContinueText(sessionId, hit.turn, hit.pendingTools);
10635
- let agent;
10636
- let how;
10637
- const live = deps.agents?.get?.(sessionId);
10638
- if (live) {
10639
- agent = live;
10640
- how = "live";
10641
- } else {
10642
- const sc = deps.sessionController;
10643
- const resolve = sc?.resolveAgent;
10644
- if (typeof resolve !== "function" || sc === void 0) return {
10645
- sessionId,
10646
- ok: false,
10647
- error: "宿主 sessionController 不可用(重启启动窗口?)—— 稍后重试"
10648
- };
10649
- try {
10650
- const out = await resolve.call(sc, sessionId);
10651
- if (!out?.agent) return {
10652
- sessionId,
10653
- ok: false,
10654
- error: `冷载入未返回 agent: ${String(out?.error ?? "未知原因")}`
10655
- };
10656
- agent = out.agent;
10657
- how = "cold-resume";
10658
- } catch (err) {
10659
- return {
10660
- sessionId,
10661
- ok: false,
10662
- error: `冷载入失败: ${err instanceof Error ? err.message : String(err)}`
10663
- };
10664
- }
10761
+ /**
10762
+ * 从 dsh 会话 log 读取标题(latest-wins `session/title` 事件)——
10763
+ * **标题不在 `sessions.list()` 条目上**(wire/对象均无 title 字段;F3 命名经
10764
+ * `sessionTitle.rename` 写进 session log),必须从 events 提取(rebuild 同款读取模式)。
10765
+ */
10766
+ function readSessionTitle(session) {
10767
+ try {
10768
+ const events = sessionEvents(session);
10769
+ for (let i = events.length - 1; i >= 0; i--) {
10770
+ const e = events[i];
10771
+ if (e?.type === "session/title" && typeof e.data?.title === "string" && e.data.title.trim() !== "") return e.data.title.trim();
10665
10772
  }
10666
- const a = agent;
10667
- try {
10668
- if (typeof a.steer === "function" && a.status === "running") {
10669
- a.steer(deps.makeMessage(text));
10670
- return {
10671
- sessionId,
10672
- ok: true,
10673
- how: `${how}+steer`
10674
- };
10675
- }
10676
- if (typeof a.followup === "function") {
10677
- a.followup(deps.makeMessage(text));
10678
- return {
10679
- sessionId,
10680
- ok: true,
10681
- how: `${how}+followup`
10682
- };
10683
- }
10684
- if (typeof a.steer === "function") {
10685
- a.steer(deps.makeMessage(text));
10686
- return {
10687
- sessionId,
10688
- ok: true,
10689
- how: `${how}+steer`
10690
- };
10691
- }
10692
- return {
10693
- sessionId,
10694
- ok: false,
10695
- how,
10696
- error: "agent 既无 steer 也无 followup(宿主契约漂移)"
10697
- };
10698
- } catch (err) {
10699
- if (typeof a.followup === "function") try {
10700
- a.followup(deps.makeMessage(text));
10701
- return {
10702
- sessionId,
10703
- ok: true,
10704
- how: `${how}+followup(steer 失败后退回)`
10705
- };
10706
- } catch (err2) {
10707
- return {
10708
- sessionId,
10709
- ok: false,
10710
- error: `steer 与 followup 均失败: ${String(err2?.message ?? err2)}`
10711
- };
10712
- }
10713
- return {
10714
- sessionId,
10715
- ok: false,
10716
- error: `注入失败: ${err instanceof Error ? err.message : String(err)}`
10717
- };
10773
+ } catch {}
10774
+ return null;
10775
+ }
10776
+ /**
10777
+ * 逐条 live 会话(`cccRoot` = 该会话 cwd 的 CCC 归属,无 → null)。
10778
+ *
10779
+ * CCC 枚举("本机有哪些 CCC")**不在这里**,归 `ccc-roots.listCccs`(并集语义);
10780
+ * 本函数保留的是它**独有**的那部分:会话维度 + cwd 归属 + 标题 + id。
10781
+ *
10782
+ * @param ctx 插件上下文(**只透传**给 L0 取数口,本模块不解引用 ⇒ 类型为 `unknown`)
10783
+ * @returns 逐条 live 会话
10784
+ */
10785
+ function listLiveSessions(ctx) {
10786
+ const out = [];
10787
+ try {
10788
+ const sessions = hostSessions(ctx);
10789
+ for (const s of sessions?.list?.() ?? []) {
10790
+ const cwd = s?.header?.cwd ?? null;
10791
+ out.push({
10792
+ id: s?.id ?? "",
10793
+ cwd,
10794
+ cccRoot: cwd ? cccRootForCwd(cwd) : null,
10795
+ title: readSessionTitle(s)
10796
+ });
10718
10797
  }
10798
+ } catch {}
10799
+ return out;
10800
+ }
10801
+ function diagLive(ctx) {
10802
+ const processCwd = process.cwd();
10803
+ return {
10804
+ processCwd,
10805
+ processCcc: cccRootForCwd(processCwd),
10806
+ liveSessions: listLiveSessions(ctx)
10719
10807
  };
10720
10808
  }
10721
10809
  //#endregion
@@ -12391,6 +12479,61 @@ function hasEnabledImChannel(root) {
12391
12479
  return (settings.accounts ?? []).some((a) => a.enabled !== false);
12392
12480
  }
12393
12481
  /**
12482
+ * 可见性判据(**轨迹级**,2026-10-23 owner 令):
12483
+ * 这个 dsh 会话所属的认知轨迹,是否**被 IM 路由表点名**(= 它才是那个"接线人")。
12484
+ *
12485
+ * ## 它解决的问题(owner 原话的**去标识改写**,原文含私有角色名,按 D145 不入仓库)
12486
+ *
12487
+ * > 「很多轨迹发消息给我,但是我没空回,它们就会等待我,其实并不是,我希望他们继续工作;
12488
+ * > 所以只有 IM 桥绑定了任意账户的认知轨迹才拥有 `im-bridge` 这个 tool,
12489
+ * > 这样可以确保只有一个接线角色和我联系,其它轨迹和它联系即可,它会自行回复它们的」
12490
+ *
12491
+ * ⇒ 旧判据是 **CCC 级**({@link hasEnabledImChannel}:本 CCC 有任一启用账号 ⇒ 全体可见)。
12492
+ * 它的后果不是"少一个工具",而是**一条错误的工作流**:每条轨迹都以为自己该直接对 owner 说话,
12493
+ * 于是发完就**停下来等人回**——而 owner 根本没空逐条回。**工具在,就把等待也带上了。**
12494
+ *
12495
+ * ## 判据(两条,缺一不可)
12496
+ *
12497
+ * 1. 本 CCC 有可用账号({@link hasEnabledImChannel})—— 没有账号时路由表也没有意义;
12498
+ * 2. 本会话**绑定的轨迹编码**出现在 `routes[].trajectory` 里。
12499
+ *
12500
+ * ⚠️ **为什么不看 `routes[].user`**:那是"对端是谁"(owner 的通道 id),
12501
+ * 判"这条轨迹能不能说话"只跟**轨迹**有关,与哪个对端无关。
12502
+ *
12503
+ * ⚠️ **`*` 通配也算点名**:`{ user:"*", trajectory:"<某轨迹>" }` 这条路由的含义就是
12504
+ * "**没人认领的消息都归那条轨迹**" ⇒ 它确实是一条被授权的接线轨迹,必须算可见。
12505
+ * 这与 `matchWeixinRoute` 的通配兜底语义**同源**,不另立规则。
12506
+ *
12507
+ * ## 拿不到轨迹编码时怎么办(fail-closed,与 `exclusive` 同向)
12508
+ *
12509
+ * 会话 → 轨迹编码要读绑定。**取不到就判不可见**:
12510
+ * - 与 `exclusive`(未声明 ⇒ 隐藏)同向;
12511
+ * - 反向(取不到就放行)会让"绑定坏了"表现成"工具还在",
12512
+ * 而那正是本判据要防的形态(D179:配置说它有、行为说它没有,且零报错)。
12513
+ * ⚠️ 代价明示:绑定读取若短暂失败,该轮会少一个工具;下一轮绑定恢复即回。
12514
+ * 这比"误给一条轨迹发消息的权限"便宜 —— 后者会让它**停下来等 owner**。
12515
+ *
12516
+ * @param root CCC 根
12517
+ * @param trajectoryCode 本会话所属认知轨迹的编码(**由调用方解析**,见 `trajectory/policy/profile.ts`)
12518
+ * @returns true = 该轨迹接线,`im-bridge` 可见
12519
+ */
12520
+ function isImWiredTrajectory(root, trajectoryCode) {
12521
+ if (typeof trajectoryCode !== "string" || trajectoryCode.trim() === "") return false;
12522
+ if (!hasEnabledImChannel(root)) return false;
12523
+ let settings;
12524
+ try {
12525
+ settings = readImSettings(root);
12526
+ } catch {
12527
+ return false;
12528
+ }
12529
+ const wanted = trajectoryCode.trim();
12530
+ for (const route of settings.routes ?? []) {
12531
+ const target = typeof route.trajectory === "string" ? route.trajectory.trim() : "";
12532
+ if (target !== "" && target === wanted) return true;
12533
+ }
12534
+ return false;
12535
+ }
12536
+ /**
12394
12537
  * 错误码 → 可行动提示(模型视角:说清怎么改)。
12395
12538
  *
12396
12539
  * 🔴 `CHANNEL_NOT_CONFIGURED` 的提示**不能写死某一个渠道的字段名**(2026-10-20 Q4 真机验收踩到):
@@ -12920,7 +13063,7 @@ function computeVisibilitySources(sessionId, root) {
12920
13063
  }
12921
13064
  return { deny: [.../* @__PURE__ */ new Set([...SAFE_MODE_DENY_TOOLS, ...declared])] };
12922
13065
  }));
12923
- out.push(sourceOf("imBridge", () => hasEnabledImChannel(root) ? { deny: [] } : { deny: [IM_BRIDGE_TOOL_NAME] }));
13066
+ out.push(sourceOf("imBridge", () => isImWiredTrajectory(root, trajectoryCodeForSession(root, sessionId)) ? { deny: [] } : { deny: [IM_BRIDGE_TOOL_NAME] }));
12924
13067
  out.push(sourceOf("exclusive", () => {
12925
13068
  const declared = readExclusiveTools(root);
12926
13069
  return { deny: EXCLUSIVE_TOOL_NAMES.filter((t) => !declared.includes(t)) };
@@ -15113,7 +15256,7 @@ function registerStatusApi(ctx, opts = {}) {
15113
15256
  });
15114
15257
  }
15115
15258
  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);
15259
+ const { findDirNameByCode } = await import("./profile-zxakCNXy.js").then((n) => n.a);
15117
15260
  for (const r of routes) if (findDirNameByCode(root, r.trajectory) === null) {
15118
15261
  sendJson$2(res, 400, { error: `unknown trajectory: ${r.trajectory} (no such trajectory code in ${root})` });
15119
15262
  return;
@@ -20156,7 +20299,7 @@ const MEDIA_MAX_BYTES = 20971520;
20156
20299
  */
20157
20300
  async function resolveCccRootByAlias(ctx, alias) {
20158
20301
  try {
20159
- const { listCccs } = await import("./ccc-roots-CvgsUnFW.js").then((n) => n.a);
20302
+ const { listCccs } = await import("./ccc-roots-WfktvbN8.js").then((n) => n.a);
20160
20303
  const entries = await listCccs(ctx);
20161
20304
  const byName = entries.find((e) => e.name === alias);
20162
20305
  if (byName) return byName.root;
@@ -20224,7 +20367,7 @@ async function resolveInboundTarget(ctx, input) {
20224
20367
  });
20225
20368
  return null;
20226
20369
  }
20227
- const { findDirNameByCode } = await import("./profile-DGt_e-Nx.js").then((n) => n.a);
20370
+ const { findDirNameByCode } = await import("./profile-zxakCNXy.js").then((n) => n.a);
20228
20371
  if (findDirNameByCode(root, target.role) === null) {
20229
20372
  const detail = `目标 CCC "${target.ccc}" 内没有编码为 "${target.role}" 的认知轨迹`;
20230
20373
  console.error(`[serenity-hooks] ✗ human-channel: ${detail}(account=${input.account} user=${input.user})—— 该消息**未投递**,请改订阅表里的目标编码`);
@@ -21178,7 +21321,7 @@ function syncHumanChannel(ctx) {
21178
21321
  (async () => {
21179
21322
  try {
21180
21323
  stopMachineLoops();
21181
- const { listCccs } = await import("./ccc-roots-CvgsUnFW.js").then((n) => n.a);
21324
+ const { listCccs } = await import("./ccc-roots-WfktvbN8.js").then((n) => n.a);
21182
21325
  for (const entry of await listCccs(ctx)) syncCccBridge(ctx, entry.root);
21183
21326
  } catch {}
21184
21327
  })();
@@ -21471,7 +21614,7 @@ function matchCcc(input, candidates) {
21471
21614
  }
21472
21615
  /** 组装候选列表(`ccc-roots.listCccs` 投影 + `.serenity` 名;动态 import 保持本模块静态依赖轻量) */
21473
21616
  async function collectCandidates(ctx) {
21474
- const { listCccs } = await import("./ccc-roots-CvgsUnFW.js").then((n) => n.a);
21617
+ const { listCccs } = await import("./ccc-roots-WfktvbN8.js").then((n) => n.a);
21475
21618
  const entries = await listCccs(ctx, { defaultRoot: process.cwd() });
21476
21619
  const seen = /* @__PURE__ */ new Set();
21477
21620
  const out = [];