@shgroup/dsh-serenity-hooks 1.95.0 → 1.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-serenity-hooks",
3
- "version": "1.95.0",
3
+ "version": "1.97.0",
4
4
  "main": "lib/index.js",
5
5
  "description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——14 个工具(container_fs/cognitive_trajectory/dashboard/container_git/msm/praxis/ontology/serenity-skill-creator/cheap-worker/localstore/container_admin;另三个 im-bridge/acc-diag/diagram 按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 认知轨迹日志与原地重建 + 网页登录入口/IM 桥/认知轨迹唤醒注册表。轨迹声明 = 轨迹目录下的 trajectory.json(约束面 + 旧标识 uid + CRO 机器绑定 cro.host;旧名 trajectory-constraint.json / skiff.json 仍在读取族里);轨迹提示词 = 轨迹目录下的 PROMPT.md(一条轨迹「是谁、按什么章程工作」,所有轨迹通用,追加在容器层与质量层之后,不替换它们)。适配 DSH 0.1.7-rc.1 / 0.2.0-rc.1 / 0.2.1-rc.1(三档,deepseek-ai/deepseek-harness)。",
6
6
  "engines": {
@@ -12,11 +12,13 @@
12
12
  *
13
13
  * | 事件 | 动作 | 出处 |
14
14
  * |---|---|---|
15
- * | `agent/status` = `running` | 标记「在跑」;`idle` ⇒ 清除 | 宿主 `AgentLoop.setPhase`:`emit("agent/status", { status })`,**只在状态真的变化时发** |
16
- * | `agent/turn-stopping` | 清除标记 | `host/contract.ts`(dsp 已在 `trajectory/rebuild.ts` 等 4 处订阅) |
15
+ * | `agent/status` = `running` | **置位**「在跑」 | 宿主 `AgentLoop.setPhase`:`emit("agent/status", { status })`,**只在状态真的变化时发** |
16
+ * | `agent/status` = `idle` | **清位** | 同上;`kick()` 的 `finally` 一定 `setPhase({kind:'idle'})` |
17
17
  * | `agent/disposed` | **清该载体的项** | `host/contract.ts` |
18
18
  * | `session/disposed` | **清该载体的项** | `host/contract.ts` |
19
19
  *
20
+ * 🔴 **`agent/turn-stopping` 刻意不订阅** —— 它是「轮末暂停点」不是「轮结束」,见下方 D244 段。
21
+ *
20
22
  * ## 🔴🔴 D243(2026-10-24):**⛔ 不许用 `agent/created` 标记「在跑」**
21
23
  *
22
24
  * **修前**:`agent/created` 也 `markTurnRunning(...)`。**为什么这是错的** —— 该事件是
@@ -37,18 +39,46 @@
37
39
  * **代价与兜底**:删掉 `agent/created` 后,本表在"宿主重启后、某会话真正开跑之前"为空
38
40
  * ⇒ 落到文件尾那条**便宜**的误判方向(报"没跑"),不会被拿来藏会话。
39
41
  *
42
+ * ## 🔴🔴 D244(2026-10-24):**⛔ `agent/turn-stopping` 不得清表**
43
+ *
44
+ * **它看起来最像"收轮",其实不是。** 宿主 `AgentLoop.turn()` 原文:
45
+ *
46
+ * ```js
47
+ * if (turnEnds && this.inbox.nextStep.length === 0) {
48
+ * await this.dispatch.serial("agent/turn-stopping", { turn, signal }) // 暂停点
49
+ * signal.throwIfAborted()
50
+ * }
51
+ * if (turnEnds && this.inbox.nextStep.length === 0) break // 只有 nextStep 空才真结束
52
+ * target = "next-step" // 否则同轮继续
53
+ * ```
54
+ *
55
+ * ⇒ 任何监听者在 `turn-stopping` 回调里 `steer`(往 `next-step` 塞消息)⇒ **同一轮继续跑**,
56
+ * 而 `turn/start` **不重发**、`agent/status` 也**不重发**(状态没变,还是 running)。
57
+ *
58
+ * 🔴 **实测代价**(真机 2026-10-24 18:1x):`trajectory/rebuild.ts` 的 auto-continue 正是
59
+ * 在 `turn-stopping` 里 `agent.steer(...)` ⇒ 一条轮**连跑 9 分钟**
60
+ * (`turn/start(32)` 18:12:00 → `turn/end(32)` 18:21:27),而本表**整段时间都是空的**
61
+ * ⇒ `interrupted` 的排除失效 ⇒ `steer` 被塞进**正在跑的那一轮**(对它是误导)。
62
+ * ⚠️ D243 之前这一格被 `agent/created` 的假置位**掩盖**;D243 删掉它 ⇒ 洞才暴露。
63
+ * ⇒ **D243 的方向是对的,它只是揭开了一个更老的语义误解**。
64
+ *
65
+ * ⇒ **清理只认 `agent/status = idle`**(`kick()` 的 `finally` 一定 `setPhase({kind:'idle'})`)。
66
+ * ⚠️ **已知窄代价**:`runMaintenance` 期间 `status` 也是 `idle` ⇒ 压缩等内部工作时不置位。
67
+ * 判可接受:那时没有 CRO 该救的轮;方向落到那条**便宜**的误判(报"没跑")。
68
+ * ⛔ **不要把 `clearTurnRunning` 加回 `turn-stopping`** —— 等于重新打开那个 9 分钟的洞。
69
+ *
40
70
  * ## 🔴 清理设计(**这是本模块最容易被做坏的地方**)
41
71
  *
42
72
  * ⚠️ 本容器栽过 **5 次**「只增不清」的病(wake-registry / keeper 三表 / pendingRebuilds …),
43
73
  * 而 `agent/disposed` 在 `HOST_EVENTS` 里的 impact 原文**恰好就是**
44
74
  * 「*per-会话内存态不清理(**长跑泄漏**)*」⇒ **本模块属于该已登记病族,必须在设计期就带清理**。
45
75
  *
46
- * **三层清理**:
47
- * 1. **正常路径**:`turn-stopping` ⇒ 清除该项;
76
+ * **三层清理**(D244 起第 1 层已从 `turn-stopping` 换成 `status=idle`):
77
+ * 1. **正常路径**:`agent/status = idle` ⇒ 清除该项;
48
78
  * 2. **载体销毁**:`agent/disposed` / `session/disposed` ⇒ 清除该项;
49
79
  * 3. 🔴 **异常路径(前两层都漏掉的兜底)**:读取时按 **TTL** 过滤——
50
- * 超过 `TURN_TTL_MS` 未收到 `turn-stopping` 的项**视为不在跑**(并顺手清除)。
51
- * 触发场景:进程被 kill / 卡死 / 事件丢失。
80
+ * 超过 `TURN_TTL_MS` 未刷新的项**视为不在跑**(并顺手清除)。
81
+ * 触发场景:进程被 kill / 卡死 / 事件丢失(这些情况下 `idle` 永远不会来)。
52
82
  *
53
83
  * ## TTL 取值的**方向性判据**(R↓,别用"感觉合适")
54
84
  *
@@ -58,7 +88,7 @@
58
88
  * 「漏排是硬故障、多排是常态成本」)。
59
89
  *
60
90
  * ⇒ **不确定时宁可报"没跑"**(选便宜的那种错)⇒ **TTL 不宜过长**。
61
- * 取 **30 分钟**:单轮静默超过 30 分钟仍无 `turn-stopping` 的情形罕见;
91
+ * 取 **30 分钟**:单轮静默超过 30 分钟仍无 `idle` 的情形罕见;
62
92
  * 而真发生了,代价只是一次可能多余的唤起(5min tick 限制频率,且 CRO 程序自己还能看
63
93
  * `pendingWakes` / `lastWakeAt` 二次判)。
64
94
  *
package/lib/index.js CHANGED
@@ -8442,11 +8442,13 @@ function registerDisposer(ctx, label, cleanup) {
8442
8442
  *
8443
8443
  * | 事件 | 动作 | 出处 |
8444
8444
  * |---|---|---|
8445
- * | `agent/status` = `running` | 标记「在跑」;`idle` ⇒ 清除 | 宿主 `AgentLoop.setPhase`:`emit("agent/status", { status })`,**只在状态真的变化时发** |
8446
- * | `agent/turn-stopping` | 清除标记 | `host/contract.ts`(dsp 已在 `trajectory/rebuild.ts` 等 4 处订阅) |
8445
+ * | `agent/status` = `running` | **置位**「在跑」 | 宿主 `AgentLoop.setPhase`:`emit("agent/status", { status })`,**只在状态真的变化时发** |
8446
+ * | `agent/status` = `idle` | **清位** | 同上;`kick()` 的 `finally` 一定 `setPhase({kind:'idle'})` |
8447
8447
  * | `agent/disposed` | **清该载体的项** | `host/contract.ts` |
8448
8448
  * | `session/disposed` | **清该载体的项** | `host/contract.ts` |
8449
8449
  *
8450
+ * 🔴 **`agent/turn-stopping` 刻意不订阅** —— 它是「轮末暂停点」不是「轮结束」,见下方 D244 段。
8451
+ *
8450
8452
  * ## 🔴🔴 D243(2026-10-24):**⛔ 不许用 `agent/created` 标记「在跑」**
8451
8453
  *
8452
8454
  * **修前**:`agent/created` 也 `markTurnRunning(...)`。**为什么这是错的** —— 该事件是
@@ -8467,18 +8469,46 @@ function registerDisposer(ctx, label, cleanup) {
8467
8469
  * **代价与兜底**:删掉 `agent/created` 后,本表在"宿主重启后、某会话真正开跑之前"为空
8468
8470
  * ⇒ 落到文件尾那条**便宜**的误判方向(报"没跑"),不会被拿来藏会话。
8469
8471
  *
8472
+ * ## 🔴🔴 D244(2026-10-24):**⛔ `agent/turn-stopping` 不得清表**
8473
+ *
8474
+ * **它看起来最像"收轮",其实不是。** 宿主 `AgentLoop.turn()` 原文:
8475
+ *
8476
+ * ```js
8477
+ * if (turnEnds && this.inbox.nextStep.length === 0) {
8478
+ * await this.dispatch.serial("agent/turn-stopping", { turn, signal }) // 暂停点
8479
+ * signal.throwIfAborted()
8480
+ * }
8481
+ * if (turnEnds && this.inbox.nextStep.length === 0) break // 只有 nextStep 空才真结束
8482
+ * target = "next-step" // 否则同轮继续
8483
+ * ```
8484
+ *
8485
+ * ⇒ 任何监听者在 `turn-stopping` 回调里 `steer`(往 `next-step` 塞消息)⇒ **同一轮继续跑**,
8486
+ * 而 `turn/start` **不重发**、`agent/status` 也**不重发**(状态没变,还是 running)。
8487
+ *
8488
+ * 🔴 **实测代价**(真机 2026-10-24 18:1x):`trajectory/rebuild.ts` 的 auto-continue 正是
8489
+ * 在 `turn-stopping` 里 `agent.steer(...)` ⇒ 一条轮**连跑 9 分钟**
8490
+ * (`turn/start(32)` 18:12:00 → `turn/end(32)` 18:21:27),而本表**整段时间都是空的**
8491
+ * ⇒ `interrupted` 的排除失效 ⇒ `steer` 被塞进**正在跑的那一轮**(对它是误导)。
8492
+ * ⚠️ D243 之前这一格被 `agent/created` 的假置位**掩盖**;D243 删掉它 ⇒ 洞才暴露。
8493
+ * ⇒ **D243 的方向是对的,它只是揭开了一个更老的语义误解**。
8494
+ *
8495
+ * ⇒ **清理只认 `agent/status = idle`**(`kick()` 的 `finally` 一定 `setPhase({kind:'idle'})`)。
8496
+ * ⚠️ **已知窄代价**:`runMaintenance` 期间 `status` 也是 `idle` ⇒ 压缩等内部工作时不置位。
8497
+ * 判可接受:那时没有 CRO 该救的轮;方向落到那条**便宜**的误判(报"没跑")。
8498
+ * ⛔ **不要把 `clearTurnRunning` 加回 `turn-stopping`** —— 等于重新打开那个 9 分钟的洞。
8499
+ *
8470
8500
  * ## 🔴 清理设计(**这是本模块最容易被做坏的地方**)
8471
8501
  *
8472
8502
  * ⚠️ 本容器栽过 **5 次**「只增不清」的病(wake-registry / keeper 三表 / pendingRebuilds …),
8473
8503
  * 而 `agent/disposed` 在 `HOST_EVENTS` 里的 impact 原文**恰好就是**
8474
8504
  * 「*per-会话内存态不清理(**长跑泄漏**)*」⇒ **本模块属于该已登记病族,必须在设计期就带清理**。
8475
8505
  *
8476
- * **三层清理**:
8477
- * 1. **正常路径**:`turn-stopping` ⇒ 清除该项;
8506
+ * **三层清理**(D244 起第 1 层已从 `turn-stopping` 换成 `status=idle`):
8507
+ * 1. **正常路径**:`agent/status = idle` ⇒ 清除该项;
8478
8508
  * 2. **载体销毁**:`agent/disposed` / `session/disposed` ⇒ 清除该项;
8479
8509
  * 3. 🔴 **异常路径(前两层都漏掉的兜底)**:读取时按 **TTL** 过滤——
8480
- * 超过 `TURN_TTL_MS` 未收到 `turn-stopping` 的项**视为不在跑**(并顺手清除)。
8481
- * 触发场景:进程被 kill / 卡死 / 事件丢失。
8510
+ * 超过 `TURN_TTL_MS` 未刷新的项**视为不在跑**(并顺手清除)。
8511
+ * 触发场景:进程被 kill / 卡死 / 事件丢失(这些情况下 `idle` 永远不会来)。
8482
8512
  *
8483
8513
  * ## TTL 取值的**方向性判据**(R↓,别用"感觉合适")
8484
8514
  *
@@ -8488,7 +8518,7 @@ function registerDisposer(ctx, label, cleanup) {
8488
8518
  * 「漏排是硬故障、多排是常态成本」)。
8489
8519
  *
8490
8520
  * ⇒ **不确定时宁可报"没跑"**(选便宜的那种错)⇒ **TTL 不宜过长**。
8491
- * 取 **30 分钟**:单轮静默超过 30 分钟仍无 `turn-stopping` 的情形罕见;
8521
+ * 取 **30 分钟**:单轮静默超过 30 分钟仍无 `idle` 的情形罕见;
8492
8522
  * 而真发生了,代价只是一次可能多余的唤起(5min tick 限制频率,且 CRO 程序自己还能看
8493
8523
  * `pendingWakes` / `lastWakeAt` 二次判)。
8494
8524
  *
@@ -8604,10 +8634,6 @@ function registerCroTurnTracking(ctx) {
8604
8634
  if (p.status === "running") markTurnRunning(id, Date.now());
8605
8635
  else if (p.status === "idle") clearTurnRunning(id);
8606
8636
  });
8607
- on("agent/turn-stopping", (payload) => {
8608
- const id = sessionIdOf$2(payload?.agent);
8609
- if (id) clearTurnRunning(id);
8610
- });
8611
8637
  on("agent/disposed", (payload) => {
8612
8638
  const id = sessionIdOf$2(payload?.agent);
8613
8639
  if (id) clearTurnRunning(id);
@@ -9143,6 +9169,33 @@ function readTailEvents(logPath, want) {
9143
9169
  */
9144
9170
  const UNFINISHED_KINDS = /* @__PURE__ */ new Set(["interrupted", "aborted"]);
9145
9171
  /**
9172
+ * 尾部窗口里**最后一条 `turn/start` 的轮号**(D244 输出面,给调用方做 A108 的现场判断)。
9173
+ *
9174
+ * ⚠️ 与 {@link detectUnclosedTurn} 不同:**它不判"是否未收尾"**,只**如实报告窗口里有什么**。
9175
+ * 分开的理由:前者是判据(会 deciding 命中),后者是读数(零判据风险)——
9176
+ * 把读数塞进判据函数,调用方就没法在"判据因为窗口截断而沉默"时看出**为什么**沉默。
9177
+ *
9178
+ * @returns 轮号;窗口内没有 `turn/start` 或轮号缺失 ⇒ `null`
9179
+ */
9180
+ function lastTurnStartTurnOf(events) {
9181
+ for (let i = events.length - 1; i >= 0; i--) if (events[i]?.type === "turn/start") {
9182
+ const t = events[i]?.data?.turn;
9183
+ return typeof t === "number" ? t : null;
9184
+ }
9185
+ return null;
9186
+ }
9187
+ /**
9188
+ * 尾部窗口里**最后一条 `turn/end` 的 `reason.kind`**(同上:读数,不是判据)。
9189
+ * @returns kind;窗口内没有 `turn/end`(形态 B 的典型)或 kind 缺失 ⇒ `null`
9190
+ */
9191
+ function lastTurnEndKindOf(events) {
9192
+ for (let i = events.length - 1; i >= 0; i--) if (events[i]?.type === "turn/end") {
9193
+ const k = events[i]?.data?.reason?.kind;
9194
+ return typeof k === "string" ? k : null;
9195
+ }
9196
+ return null;
9197
+ }
9198
+ /**
9146
9199
  * 命中判定(**形态 A:靠 `turn/end.reason.kind`**)。
9147
9200
  *
9148
9201
  * ⚠️ **本函数刻意不判「开了没收」** —— 那种形态与「此刻正在跑」在日志读数上**不可分**,
@@ -9261,7 +9314,8 @@ function scanInterruptedSessions(projectDir, options = {}) {
9261
9314
  scanned: 0,
9262
9315
  skipped: 0,
9263
9316
  errors: [],
9264
- elapsedMs: 0
9317
+ elapsedMs: 0,
9318
+ excluded: []
9265
9319
  };
9266
9320
  if (!existsSync(projectDir)) {
9267
9321
  result.elapsedMs = Date.now() - t0;
@@ -9279,7 +9333,13 @@ function scanInterruptedSessions(projectDir, options = {}) {
9279
9333
  return result;
9280
9334
  }
9281
9335
  for (const sessionId of entries) {
9282
- if (liveIds.has(sessionId)) continue;
9336
+ if (liveIds.has(sessionId)) {
9337
+ const lp = findSessionLog(join(projectDir, sessionId));
9338
+ if (lp !== null) try {
9339
+ if (statSync(lp).mtimeMs >= cutoff) result.excluded.push(sessionId);
9340
+ } catch {}
9341
+ continue;
9342
+ }
9283
9343
  const dirPath = join(projectDir, sessionId);
9284
9344
  const logPath = findSessionLog(dirPath);
9285
9345
  if (logPath === null) continue;
@@ -9312,7 +9372,9 @@ function scanInterruptedSessions(projectDir, options = {}) {
9312
9372
  * 实测(本机 10 个窗口内会话):**4 条**只能由形态 B 抓到(末条 `tool/call` / `step/start`),
9313
9373
  * 修前全落到 `skipped`。
9314
9374
  */
9315
- const hit = detectInterruptedEnd(events) ?? detectUnclosedTurn(events);
9375
+ const formA = detectInterruptedEnd(events);
9376
+ const formB = formA === null ? detectUnclosedTurn(events) : null;
9377
+ const hit = formA ?? formB;
9316
9378
  if (!hit) {
9317
9379
  result.skipped++;
9318
9380
  continue;
@@ -9324,7 +9386,10 @@ function scanInterruptedSessions(projectDir, options = {}) {
9324
9386
  turn: hit.turn,
9325
9387
  lastEventAt: typeof tailTime === "number" ? tailTime : null,
9326
9388
  mtimeMs,
9327
- pendingTools: hit.pendingTools
9389
+ pendingTools: hit.pendingTools,
9390
+ form: formA !== null ? "interrupted-end" : "unclosed",
9391
+ lastTurnEndKind: lastTurnEndKindOf(events),
9392
+ lastTurnStartTurn: lastTurnStartTurnOf(events)
9328
9393
  });
9329
9394
  } catch (err) {
9330
9395
  result.errors.push({
@@ -11076,25 +11141,39 @@ function collectRunningSessionIds(nowMs) {
11076
11141
  function renderInterruptedScan(input) {
11077
11142
  const { projectDir, lookbackHours, scan } = input;
11078
11143
  const lines = [];
11144
+ const excludedCount = scan.excluded.length;
11079
11145
  lines.push(`═══ 末轮未跑完的 dsh 会话(回看 ${lookbackHours}h)═══`);
11080
11146
  lines.push(` 扫描域: ${projectDir}`);
11081
- lines.push(` 检查 ${scan.scanned} 个 · 命中 ${scan.sessions.length} · 跳过 ${scan.skipped} · 耗时 ${scan.elapsedMs}ms${scan.errors.length > 0 ? ` · 读取失败 ${scan.errors.length}` : ""}`);
11147
+ lines.push(` 检查 ${scan.scanned} 个 · 命中 ${scan.sessions.length} · 跳过 ${scan.skipped}` + (excludedCount > 0 ? ` · 排除在跑 ${excludedCount} 个` : "") + ` · 耗时 ${scan.elapsedMs}ms${scan.errors.length > 0 ? ` · 读取失败 ${scan.errors.length}` : ""}`);
11148
+ if (excludedCount > 0) {
11149
+ lines.push(` ⓘ 窗口内共 ${scan.scanned + excludedCount} 个会话,其中 ${excludedCount} 个**此刻有轮次在跑**,按「正在跑的不该被判为待续」的纪律被排除:`);
11150
+ for (const id of scan.excluded) lines.push(` - ${id}`);
11151
+ lines.push(" ⚠️ 这些**没有**被检查过。若你认为其中某个其实已死(进程重启后残留的\"在跑\"标记),");
11152
+ lines.push(" 请告知 ACC 维护方 —— 那是「在跑」表的判据问题,不是扫描的问题。");
11153
+ }
11082
11154
  if (scan.sessions.length === 0) {
11083
11155
  lines.push("");
11084
- lines.push("✓ 没有末轮未跑完的会话 —— 无需处理。");
11156
+ lines.push(excludedCount > 0 ? `✓ 被检查的 ${scan.scanned} 个里没有末轮未跑完的 —— 另有 ${excludedCount} 个在跑,未检查(见上)。` : "✓ 没有末轮未跑完的会话 —— 无需处理。");
11085
11157
  return lines.join("\n");
11086
11158
  }
11087
11159
  lines.push("");
11088
11160
  for (const s of scan.sessions) {
11089
11161
  const at = s.lastEventAt !== null ? new Date(s.lastEventAt).toISOString() : "(时间未知)";
11090
11162
  lines.push(` · ${s.sessionId}`);
11163
+ const formText = s.form === "unclosed" ? "未收尾(窗口内无 turn/end)" : "有收尾(reason.kind 命中)";
11091
11164
  lines.push(` 轮次 turn=${s.turn ?? "?"} · 末事件 ${at}`);
11165
+ lines.push(` 形态 ${s.form} = ${formText} · 末条 turn/end.kind=${s.lastTurnEndKind ?? "null"} · 窗口内最后 turn/start=${s.lastTurnStartTurn ?? "null"}`);
11092
11166
  if (s.pendingTools.length > 0) lines.push(` ⚠️ 有调用无结果的工具(副作用未知): ${s.pendingTools.join(", ")}`);
11093
11167
  }
11094
11168
  lines.push("");
11095
- lines.push("判据: 日志末条 `turn/end` 的 `reason.kind` ∈ {interrupted, aborted},且其后没有新的 turn/start。");
11169
+ lines.push("判据(两种形态,任一命中即收):");
11170
+ lines.push(" A `interrupted-end`: 末条 `turn/end.reason.kind` ∈ {interrupted, aborted},且其后没有新的 turn/start。");
11096
11171
  lines.push(" interrupted = 进程崩溃后被 resume 补收尾 / aborted = 被正常终止(重启、销毁、用户中断)。");
11097
- lines.push(" 两者都表示「这一轮没跑完」,都会被收进来。");
11172
+ lines.push(" B `unclosed`: 窗口内最后一条 `turn/start` 之后没有 `turn/end`(进程在轮内任意位置死掉,宿主来不及写收尾)。");
11173
+ lines.push("🔴 **怎么用「形态」判该不该续**(调用方口径,ACC 不代判):");
11174
+ lines.push(" · 投递方式 `cold-resume` ⇒ 该会话**未被载入本进程**(死着)⇒ 真需要接走;");
11175
+ lines.push(" · 投递方式 `live+steer` ⇒ 它在进程里 ⇒ ⚠️ **「载入」≠「空闲」**:");
11176
+ lines.push(" `form=unclosed` 且 `窗口内最后 turn/start` == 命中 turn ⇒ 可能**那一轮正在跑** ⇒ 先核再注入。");
11098
11177
  lines.push("🔴 本动作**只读** —— 是否续跑、续哪条、几点续,由调用方(你的认知轨迹)决定。");
11099
11178
  lines.push("⚠️ 未完成的工具调用「副作用未知」≠「没执行」:它们可能已写文件 / 已发消息。");
11100
11179
  lines.push(" 续跑前应先核对副作用,而不是把那一轮整段重放。");
@@ -11114,13 +11193,15 @@ function renderInterruptedScan(input) {
11114
11193
  function renderContinueResult(input) {
11115
11194
  const { projectDir, lookbackHours, scan, outcomes, dryRun } = input;
11116
11195
  const lines = [];
11196
+ const excludedCount = scan.excluded.length;
11117
11197
  const head = dryRun ? "续跑预告(dryRun,未注入任何消息)" : "中断续跑结果";
11118
11198
  lines.push(`═══ ${head}(回看 ${lookbackHours}h)═══`);
11119
11199
  lines.push(` 扫描域: ${projectDir}`);
11120
- lines.push(` 检查 ${scan.scanned} 个 · 命中 ${scan.sessions.length} · 跳过 ${scan.skipped} · 耗时 ${scan.elapsedMs}ms`);
11200
+ lines.push(` 检查 ${scan.scanned} 个 · 命中 ${scan.sessions.length} · 跳过 ${scan.skipped}` + (excludedCount > 0 ? ` · 排除在跑 ${excludedCount} 个` : "") + ` · 耗时 ${scan.elapsedMs}ms`);
11201
+ if (excludedCount > 0) lines.push(` ⓘ 另有 ${excludedCount} 个会话**此刻有轮次在跑**,未检查:${scan.excluded.join("、")}`);
11121
11202
  if (scan.sessions.length === 0) {
11122
11203
  lines.push("");
11123
- lines.push("✓ 没有末轮未跑完的会话 —— 无需续跑。");
11204
+ lines.push(excludedCount > 0 ? `✓ 被检查的 ${scan.scanned} 个里没有末轮未跑完的 —— 另有 ${excludedCount} 个在跑,未续(见上)。` : "✓ 没有末轮未跑完的会话 —— 无需续跑。");
11124
11205
  return lines.join("\n");
11125
11206
  }
11126
11207
  lines.push("");
@@ -82,6 +82,30 @@ export interface InterruptedSession {
82
82
  mtimeMs: number;
83
83
  /** 该轮里"有 call 无 result"的工具名(**尽力而为**,可能因尾部窗口截断而不全) */
84
84
  pendingTools: string[];
85
+ /**
86
+ * 🔴 **命中形态**(D244 输出面,guard 2026-10-24 要的判别式 Y 的一半)。
87
+ *
88
+ * | 值 | 含义 | 末条 `turn/end` |
89
+ * |---|---|---|
90
+ * | `interrupted-end` | 形态 A:日志**有** `turn/end`,其 `reason.kind ∈ {interrupted, aborted}` | 存在,见 {@link lastTurnEndKind} |
91
+ * | `unclosed` | 形态 B:**开了没收** —— 窗口内最后一条 `turn/start` 之后没有 `turn/end` | **可能不存在** |
92
+ *
93
+ * 🔴 **为什么必须暴露它**:调用方(如 guard 的守护程序)要能自己判「该不该注入」,
94
+ * 而这两种形态的**可用判据完全不同** —— 形态 B 连"末条 turn/end 的 kind"都不存在。
95
+ * 把决定权留在 ACC 里 = 让调用方没法自己躲开误注入(D237 同族:读数要能改变下一步)。
96
+ */
97
+ form: 'interrupted-end' | 'unclosed';
98
+ /**
99
+ * 形态 A:末条 `turn/end` 的 `reason.kind`;形态 B:**恒为 `null`**(那时没有末条 turn/end)。
100
+ * ⚠️ `null` 在这里**承载语义**("该格不存在"),不是"没读到"(D233 同族)——
101
+ * 两种成因在读数上不同:形态 B 的 `form` 会是 `unclosed`。
102
+ */
103
+ lastTurnEndKind: string | null;
104
+ /**
105
+ * 日志尾部窗口里**最后一条 `turn/start` 的轮号**;窗口内没有则 `null`。
106
+ * 与 {@link turn} 对照可看出「窗口是否把这一轮的起点挤出去了」(A108 的现场判据)。
107
+ */
108
+ lastTurnStartTurn: number | null;
85
109
  }
86
110
  /** 扫描结果 */
87
111
  export interface ScanResult {
@@ -98,7 +122,38 @@ export interface ScanResult {
98
122
  }>;
99
123
  /** 扫描耗时(ms) */
100
124
  elapsedMs: number;
125
+ /**
126
+ * 🔴🔴 **D245(2026-10-24):被"在跑"排除掉的会话 id** —— 必须报告,不能静默跳过。
127
+ *
128
+ * **为什么这个字段是必需的**(真机实测,owner 报障「唤起了没有成功恢复」):
129
+ * 修前 `if (liveIds.has(sessionId)) continue` 是**静默**跳过 —— 既不计数也不报告。
130
+ * 于是同一次扫描出现两个读数:
131
+ * - 生产 tick 日志:`扫 11 个 · 排除在跑 4 个`(**知道**有 4 个被藏起来)
132
+ * - 工具输出(guard 查的那个):`检查 7 个 · 命中 0 · 跳过 7`(**完全看不到**那 4 个)
133
+ * ⇒ guard 判「没事」⇒ 该救的没人救。
134
+ *
135
+ * ⇒ **判据(D237 同族)**:`scanned + excluded.size` 才是**窗口内总数**;
136
+ * 只报 `scanned` 会让读者以为"就这么点会话"。
137
+ * ⚠️ 这不是"多给一个诊断字段"——**它是"排除"这个动作的可见性**:
138
+ * 任何"我替你把某些东西藏起来"的实现,都必须报告藏了几个、藏了谁。
139
+ */
140
+ excluded: string[];
101
141
  }
142
+ /**
143
+ * 尾部窗口里**最后一条 `turn/start` 的轮号**(D244 输出面,给调用方做 A108 的现场判断)。
144
+ *
145
+ * ⚠️ 与 {@link detectUnclosedTurn} 不同:**它不判"是否未收尾"**,只**如实报告窗口里有什么**。
146
+ * 分开的理由:前者是判据(会 deciding 命中),后者是读数(零判据风险)——
147
+ * 把读数塞进判据函数,调用方就没法在"判据因为窗口截断而沉默"时看出**为什么**沉默。
148
+ *
149
+ * @returns 轮号;窗口内没有 `turn/start` 或轮号缺失 ⇒ `null`
150
+ */
151
+ export declare function lastTurnStartTurnOf(events: SessionEvent[]): number | null;
152
+ /**
153
+ * 尾部窗口里**最后一条 `turn/end` 的 `reason.kind`**(同上:读数,不是判据)。
154
+ * @returns kind;窗口内没有 `turn/end`(形态 B 的典型)或 kind 缺失 ⇒ `null`
155
+ */
156
+ export declare function lastTurnEndKindOf(events: SessionEvent[]): string | null;
102
157
  /**
103
158
  * 命中判定(**形态 A:靠 `turn/end.reason.kind`**)。
104
159
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.95.0",
3
+ "version": "1.97.0",
4
4
  "description": "宁静号 ACC harness(Native Cordis 插件)——给 DeepSeek Harness 装一个「AI 工作区」:14 个工具(container_fs/cognitive_trajectory/dashboard/container_git/msm/praxis/ontology/serenity-skill-creator/cheap-worker/localstore/container_admin;另三个 im-bridge/acc-diag/diagram 按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 认知轨迹日志与原地重建 + 网页登录入口/IM 桥/认知轨迹唤醒注册表。轨迹声明 = 轨迹目录下的 trajectory.json(约束面 + 旧标识 uid + CRO 机器绑定 cro.host;旧名 trajectory-constraint.json / skiff.json 仍在读取族里);轨迹提示词 = 轨迹目录下的 PROMPT.md(一条轨迹「是谁、按什么章程工作」,所有轨迹通用,追加在容器层与质量层之后,不替换它们)。适配 DSH 0.1.7-rc.1 / 0.2.0-rc.1 / 0.2.1-rc.1(三档)。",
5
5
  "license": "MIT",
6
6
  "repository": {