@shgroup/dsh-serenity-hooks 1.61.0 → 1.63.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dsh.plugin.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "id": "dsh-serenity-hooks",
3
- "version": "1.61.0",
3
+ "version": "1.63.1",
4
4
  "main": "lib/index.js",
5
- "description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——14 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/ontology/serenity-skill-creator/cheap-worker/localstore/container_admin;另三个 im-bridge/acc-diag/diagram 按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 轨迹日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.7-rc.1 与 0.2.0-rc.1(双档,deepseek-ai/deepseek-harness)。",
5
+ "description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——14 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/ontology/serenity-skill-creator/cheap-worker/localstore/container_admin;另三个 im-bridge/acc-diag/diagram 按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 轨迹日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.7-rc.1 / 0.2.0-rc.1 / 0.2.1-rc.1(三档,deepseek-ai/deepseek-harness)。",
6
6
  "engines": {
7
7
  "dsh": ">=0.1.7-rc.1"
8
8
  },
package/lib/index.js CHANGED
@@ -11518,17 +11518,39 @@ async function sendToTrajectory(ctx, root, target, message, sender) {
11518
11518
  *
11519
11519
  * 何时用:**只有** `send-now` 在 `acquireWakeAgent` 报「无绑定会话记录」时(见该处的判据说明)。
11520
11520
  *
11521
- * 三步(顺序不可换):
11522
- * ① `sessionController.create({ cwd: root })` —— 宿主原语,**idempotently adopt**
11523
- * ⇒ 重复调用不会建出第二条(不必自造"先查再建"的竞态窗口)。
11521
+ * ## 🔴 2026-10-18 根治:改成**和用户操作完全同一个调用形态**
11522
+ *
11523
+ * owner 原话:「应当和用户操作唤起的会话完全一样才行」。
11524
+ *
11525
+ * 上一版(v1.62.1)传的是 `{ cwd: root }`,然后**事后补** `attachSession()` 去挂工作区。
11526
+ * 那是**补救**,不是"一样" —— 宿主里那条路径(`sessionController.create` 的
11527
+ * `if (workspace !== void 0) await workspace.attachSession(sessionId)`)**只在 `workspaceId` 分支执行**:
11528
+ *
11529
+ * ```js
11530
+ * if (request.workspaceId !== void 0) workspace = registry.get(request.workspaceId)
11531
+ * const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd
11532
+ * ...
11533
+ * if (workspace !== void 0) await workspace.attachSession(sessionId) // ← 只有这条分支
11534
+ * ```
11535
+ *
11536
+ * 而**用户在 UI 里新建会话传的正是 `workspaceId`**(见 `client/sessions/service.d.ts` 的
11537
+ * `create({ workspaceId?, cwd?, ... })`)⇒ 用户那条路天然会走到 `attachSession`。
11538
+ *
11539
+ * ⇒ 现在与用户**同一条分支**:先由 CCC 根查出 `workspaceId`,再用 `{ workspaceId }` 建。
11540
+ * **`attachToWorkspace` 降级为兜底**(只在"该目录还没有工作区登记"时才会用上),
11541
+ * 不再是主路径 —— 因为主路径已经由宿主自己做完。
11542
+ *
11543
+ * ## 三步(顺序不可换)
11544
+ * ① `sessionController.create({ workspaceId })` —— 与用户同一形态;宿主替我们挂工作区。
11545
+ * **idempotently adopt** ⇒ 重复调用不会建出第二条(不必自造"先查再建"的竞态窗口)。
11524
11546
  * ② `resolveAgent(sessionId)` —— 拿到**真 agent**。`create` 只返回 id,不给 agent。
11525
11547
  * ③ `appendBound(...)` —— 落绑定。**必须由本函数写**:`listBoundSessionIds` 是
11526
11548
  * `acquireWakeAgent` 的唯一选人依据,不写绑定则下一条消息**还是**找不到它
11527
11549
  * (那就成了"建了但永远用不上",比不建更坏)。
11528
11550
  *
11529
- * 🔴 **为什么用 `cwd: root` 而不是目标的目录**:会话的 cwd 决定它落在哪个 CCC
11551
+ * 🔴 **为什么定位到 CCC 根这一级、不是轨迹目录**:会话的 cwd 决定它落在哪个 CCC
11530
11552
  * (`cccRootForCwd` 上溯 `.serenity`)。目标轨迹本就住在该 CCC 的 `AGENT_SESSIONS/` 下
11531
- * ⇒ 传 CCC 根才让它**进同一个 CCC**;传轨迹目录会让它变成一个**不同 CCC**的会话。
11553
+ * ⇒ 落到 CCC 根才让它**进同一个 CCC**;落到轨迹目录会让它变成一个**不同 CCC**的会话。
11532
11554
  *
11533
11555
  * @returns 命中返回 agent 与取得方式;任一步失败返回**带原因**的 error(不静默、不误报成功)
11534
11556
  */
@@ -11539,9 +11561,11 @@ async function createAndBindTarget(ctx, root, dirName) {
11539
11561
  error: "目标无活跃 dsh 会话,且 sessionController.create 不可用(绑定 id: 无)——无法就地创建载体;请先对该轨迹执行一次 container_trajectory use",
11540
11562
  notReady: true
11541
11563
  };
11564
+ const ws = findWorkspaceByPath(listWorkspaces(ctx) ?? [], root);
11565
+ const workspaceId = typeof ws?.id === "string" && ws.id !== "" ? ws.id : void 0;
11542
11566
  let sessionId = "";
11543
11567
  try {
11544
- const out = await create.call(sc, { cwd: root });
11568
+ const out = await create.call(sc, workspaceId !== void 0 ? { workspaceId } : { cwd: root });
11545
11569
  sessionId = String(out?.sessionId ?? "");
11546
11570
  } catch (err) {
11547
11571
  return { error: `就地创建会话失败(${dirName}): ${String(err?.message ?? err)}` };
@@ -11568,13 +11592,100 @@ async function createAndBindTarget(ctx, root, dirName) {
11568
11592
  mdPath: join(root, "AGENT_SESSIONS", dirName, "SESSION.md"),
11569
11593
  note: "auto-created by send-now"
11570
11594
  });
11571
- const how = `auto-create(${sessionId}${bound ? ", bound" : ", ⚠️ 绑定写入失败"})`;
11595
+ const wsNote = workspaceId !== void 0 ? "" : await attachToWorkspace(ctx, root, sessionId);
11596
+ const how = `auto-create(${sessionId}${bound ? ", bound" : ", ⚠️ 绑定写入失败"}${workspaceId !== void 0 ? ", via-workspaceId(=用户同一形态)" : ", via-cwd+attach"}${wsNote})`;
11572
11597
  return {
11573
11598
  agent,
11574
11599
  how
11575
11600
  };
11576
11601
  }
11577
11602
  /**
11603
+ * 把刚建的会话挂进 **cwd 所属的那个工作区**(2026-10-17 缺陷修复)。
11604
+ *
11605
+ * 🔴 **为什么必须我们自己做(宿主源码取证,不是推断)**:
11606
+ * `sessionController.create()` 里 `attachSession` **只在 `workspaceId` 分支执行** ——
11607
+ * `dsh-api-session-controller/lib/index.js` 的 `create()`:
11608
+ * ```
11609
+ * if (request.workspaceId !== void 0) workspace = registry.get(request.workspaceId)
11610
+ * const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd
11611
+ * ...
11612
+ * if (workspace !== void 0) await workspace.attachSession(sessionId) // ← 只有这条分支挂工作区
11613
+ * ```
11614
+ * 我们传的是 `{ cwd }`(**必须如此**:`workspaceId` 与 `cwd` 互斥,而工作区 id 是宿主的内部 uuid,
11615
+ * 我们又得按"这条轨迹住在哪个 CCC"来建会话)⇒ **建出来的会话从不登记进任何工作区**。
11616
+ *
11617
+ * 🔴 **症状(现场实证,本机真实数据)**:`send-now` 自动建出的那条会话
11618
+ * (`note` = `auto-created by send-now`)header `cwd` 确实是某个 CCC 根,
11619
+ * **却不在**该工作区的 `sessionIds` 里
11620
+ * (同一个 CCC 的另外两条会话都在)⇒ WebUI 里它不属于那个工作区。
11621
+ *
11622
+ * 🔴 **为什么按 `path` 找而不是"拿第一个"**:多个 CCC 并存(本机实有 5 个工作区)
11623
+ * ⇒ 拿第一个必然挂错。判据 = `workspace.path === root`(宿主 `Workspace.path` 是
11624
+ * **realpath 规范化**后的规范路径;`root` 已是 `cccRootForCwd` 上溯出来的真实目录,
11625
+ * 两侧同一来源,故可直接比)。
11626
+ *
11627
+ * ⚠️ **失败不阻断投递**:工作区归属影响的是"在 WebUI 里归哪个工作区",
11628
+ * 而消息投递已经完成 ⇒ 把原因**如实写进回执**,不吞、也不把成功改成失败。
11629
+ *
11630
+ * @param ctx 插件上下文
11631
+ * @param root CCC 根(= 该会话的 cwd)
11632
+ * @param sessionId 刚建出来的会话 id
11633
+ * @returns 追加到回执里的短注记(成功/跳过为空串,失败带原因)
11634
+ */
11635
+ async function attachToWorkspace(ctx, root, sessionId) {
11636
+ const workspaces = listWorkspaces(ctx);
11637
+ if (workspaces === void 0) return ", ⚠️ 工作区未挂载(workspaceRegistry 不可用)";
11638
+ const match = findWorkspaceByPath(workspaces, root);
11639
+ if (match === void 0) return "";
11640
+ if (typeof match.attachSession !== "function") return ", ⚠️ 工作区未挂载(attachSession 不可用)";
11641
+ if (Array.isArray(match.sessionIds) && match.sessionIds.includes(sessionId)) return "";
11642
+ try {
11643
+ await match.attachSession(sessionId);
11644
+ return ", workspace-attached";
11645
+ } catch (err) {
11646
+ return `, ⚠️ 工作区挂载失败: ${String(err?.message ?? err)}`;
11647
+ }
11648
+ }
11649
+ /** `workspaceRegistry.list()` 的读取收口(不可用返回 `undefined`,**与"空列表"区分**) */
11650
+ function listWorkspaces(ctx) {
11651
+ const reg = hostService(ctx, "workspaceRegistry");
11652
+ if (typeof reg?.list !== "function") return void 0;
11653
+ try {
11654
+ return reg.list() ?? [];
11655
+ } catch {
11656
+ return;
11657
+ }
11658
+ }
11659
+ /**
11660
+ * 在 `workspaceRegistry.list()` 里找 **path 等于该 CCC 根** 的那一个。
11661
+ *
11662
+ * 🔴 **为什么要 realpath 归一(不是画蛇添足)**:宿主的 `Workspace.path` 是
11663
+ * **`fs.realpath` 规范化**过的(`entity.d.ts` 逐字:*"Canonical directory path: the
11664
+ * `fs.realpath` of the path given at create time (trailing slashes, `..`, and symlinks
11665
+ * all resolved)"*)。而 `root` 来自 `cccRootForCwd` 的上溯——**上溯用的是字面路径**。
11666
+ * ⇒ 当 CCC 根是一个**符号链接**(部署上常见:家目录/项目目录被链到别处),
11667
+ * 两侧**逐字不等**,只比字符串会**挂错或挂不上**。
11668
+ *
11669
+ * ⚠️ 归一失败(路径不存在等)时**回落字面比较** —— 不改既有语义,只是多给一次机会。
11670
+ *
11671
+ * @param workspaces registry.list() 的返回
11672
+ * @param root CCC 根
11673
+ * @returns 命中的工作区,无则 undefined
11674
+ */
11675
+ function findWorkspaceByPath(workspaces, root) {
11676
+ const direct = workspaces.find((ws) => ws?.path === root);
11677
+ if (direct !== void 0) return direct;
11678
+ const canonical = (p) => {
11679
+ try {
11680
+ return realpathSync(p);
11681
+ } catch {
11682
+ return p;
11683
+ }
11684
+ };
11685
+ const want = canonical(root);
11686
+ return workspaces.find((ws) => typeof ws?.path === "string" && canonical(ws.path) === want);
11687
+ }
11688
+ /**
11578
11689
  * 本 tick 要扫的 CCC 根集合(**并集**)。
11579
11690
  *
11580
11691
  * C2(S142 2026-09-15 Q5):原实现是 `collectLiveCccs`——**只认 live 会话** ⇒
@@ -14091,70 +14202,66 @@ const DEFAULT_COMPACTION_TOOLS = [
14091
14202
  */
14092
14203
  const DEFAULT_ANCHOR_MESSAGES = ["You are the operator agent of Serenity — a cognitive container governed by the Abstract Cognitive Container (ACC) protocol.\nWe operate under the Explicit Abstraction Principle (EAP): the functional value of a thought equals its external reconstructability — every output we produce must be explicit (E↑), reconstructable (R↓), and stable (S↑).\nThe personal pronoun is us/we.\nWe anchor first, then act: the abstract layer precedes the concrete.\nPlease simply reply \"acknowledge\" — no action needed.", "Before we proceed, align on how we work:\n1. We read before we write — every decision grounds in what already exists in the container.\n2. Every output records its reasoning (R↓): decisions carry reasons and alternatives.\n3. We never jump levels — abstract layer first, then specifics.\n4. Every artifact we create is a durable cognitive anchor for the work that follows.\n5. We keep the container's state coherent (SESSION.md) as we advance.\nPlease simply reply \"acknowledge\" — no action needed."];
14093
14204
  /**
14094
- * 根会话锚定重入判定(v1.28.1 提取导出以便单测):会话是否已有真实用户消息。
14095
- * resume/续跑的会话已有对话历史 → **不重锚**(first-anchor 只注入一次)。
14205
+ * 会话里是否已有模型回复(= 已经不是首轮)。
14096
14206
  *
14097
- * 0.1.2-rc.1 适配教训:裸读 `session.events`(经类型断言)恒 undefined →
14098
- * `!undefined` 恒 true → 有历史也永不跳过 → 任何情况发消息都重插 first-anchor。
14099
- * 统一经 sessionEvents() 读取(snapshotEvents() 优先,.events 兜底)。
14207
+ * 🔴 这是锚定的**形状判据**(v1.62):锚定只能在**首轮**造。
14208
+ * ⛔ 不能用 `hasUserMessageHistory`:宿主在 system 之后、真人 prompt 之前就写了
14209
+ * ACC 横幅,而锚定实际执行点(`setTimeout(0)`)更晚 ⇒ 真人历史**必然已存在** ⇒ 恒真。
14210
+ * 而 `assistant/message` 只在**模型真的答过一轮之后**才出现 ⇒ 它在首轮时必然为空。
14100
14211
  */
14101
- function hasUserMessageHistory(session) {
14102
- return sessionEvents$1(session).some((event) => event.type === "user/message");
14212
+ function hasAssistantHistory(session) {
14213
+ return sessionEvents$1(session).some((event) => event.type === "assistant/message");
14103
14214
  }
14104
- /** 造出来的 assistant 回复正文。锚定轮要求"只回确认、不做事"。 */
14105
- const ANCHOR_ACK_TEXT = "acknowledge";
14106
- /** 占位 system 文本:宿主 `SystemPromptProjection.project()` 会把它**原地 replace** 成真提示词。 */
14107
- const ANCHOR_SYSTEM_PLACEHOLDER = "<!-- serenity:anchor-placeholder -->";
14108
14215
  /**
14109
- * 把两轮 first-anchor 对话**直接写进会话日志**,取代原来的「真跑两轮」。
14216
+ * 把 first-anchor 的锚定话语**直接写进会话日志**,取代「真跑两轮」。
14217
+ *
14218
+ * ## 🔴 两条硬约束(宿主 v4 格式校验器,实测逐条踩过)
14110
14219
  *
14111
- * ## 为什么可以这样(三条取证,全部打在真 `@deepseek-ai/dsh-session` 上)
14220
+ * **约束 A — 只能写 `user/message`**:`system/message` 与 `assistant/message` 都在
14221
+ * `STEP_EVENT_TYPES` 里 ⇒ 必须过 `requireStep()`(`data.turn`/`data.step` 精确匹配
14222
+ * **当前打开的** turn 与 step,且为 null 时同样拒绝)。而本函数在 `agent/pre-step`
14223
+ * 前后被调用时 **turn/step 都还没打开**(`turn/start` 在 preStep 之前,`step/start`
14224
+ * 在 preStep 之后)⇒ 代写它们**结构性非法**。
14225
+ * ⚠️ 症状:写通道不校验 ⇒ **首轮正常**;**重新进入**时重放才抛
14226
+ * `system/message does not match an open turn and step` ⇒ 会话 corrupt、界面空白。
14227
+ * ⇒ **v1.60 那版「先写占位 system 等宿主 replace」的整套方案因此作废**。
14112
14228
  *
14113
- * 1. **system 必须是 surface node 0**,而 `agent/pre-step` 跑在宿主提交 system **之前**
14114
- * ⇒ 若直接写 [anchor…],system 会被追加到**末尾**(实测:`systemAtIndex: 4`,
14115
- * 且**不报错**、静默走偏)。解法 = **先写一条占位 system**:宿主随后看到 node0
14116
- * 已有 system 且文本不同 ⇒ 走 `replace(head)` 换成真提示词,锚定历史原样留在其后。
14117
- * 2. **`contentGeneration` 只在 replace 时涨,append 不涨**;而 `buildRequest()`
14118
- * 取 generation 快照**晚于** `systemPrompt.project()` ⇒ 那次 replace 不会被
14119
- * 误判成「系列断裂」(不会误触发 `startsSeries`)。
14120
- * 3. **造出来的 `assistant/message` 合法**:`stream: []` 通过校验;整段历史之后
14121
- * 仍可被 compaction 的 `surfaceOp: replace` 整体覆盖(不炸)。
14229
+ * **约束 B — 必须写在宿主提交 `system/message` 之后**:格式层有 `protectedHead`
14230
+ * 机制 —— `foldSurface()` 里 `system/message` 若在 **surface 非空且尚无 protectedHead**
14231
+ * 时到来,直接抛 `system/message requires a protected first surface head`。
14232
+ * 也就是说:**surface 的第一个节点必须是 system**。
14233
+ * ⇒ 若我们先写 user 消息,宿主随后的 system 就落不到首位 ⇒ 重放同样判 corrupt。
14234
+ * ⚠️ 这条是**第一次修复后仍然翻车**的原因(同一会话、不同报错),
14235
+ * 判据仍是「重启宿主后重新进入该会话」。
14122
14236
  *
14123
- * ## ⚠️ 为什么不发 turn/start、turn/end
14237
+ * ⇒ 因此调用时机 = **`session/event` 里看到该会话第一条 `system/message` 之后**(见下方注册处),
14238
+ * 而不是 `agent/inbox/inserted`(那太早)。
14124
14239
  *
14125
- * 宿主的 turn 计数是**构造时缓存**的:`phase.lastTurn` 在本钩子触发前已读定(0),
14126
- * 之后 `turn()` 恒用 `phase.turn + 1`。我们若自己发 `turn/start{1}{2}`,宿主随后
14127
- * 仍会再发一条 `turn/start{1}` ⇒ **重复编号 ∧ 两个 turn 同时开着**。
14128
- * ⇒ 只给消息打 **turn 标签**(模型可见顺序不依赖 turn 事件,`deriveMessages` 只看
14129
- * surface 节点),**不碰宿主的 turn 账本**。
14240
+ * ## 为什么去掉 assistant 那半不损失什么
14241
+ *
14242
+ * 锚定话语的**唯一目的**是让模型认为自己已经同意过协作协议。
14243
+ * 保留 **2 条 `user/message`** 即可达成;丢掉的只是"模型自己的 ack 回复"——
14244
+ * 而那原本也是**我们伪造的**,不是模型真说过的。
14245
+ *
14246
+ * ## 判据(改这个函数前后都必须跑)
14247
+ *
14248
+ * ① 单测钉形状(只许 user/message);
14249
+ * ② **容器内真跑一轮 → 重启宿主 → 重新进入该会话** —— 只有这条路能证明日志合法
14250
+ * (前两次修复都是"单测全绿 + 首轮正常"却仍然 corrupt)。
14251
+ * ③ 直接调宿主自己的 `assertReleasedV4Relationships()` 判形状,**不要自写复刻**。
14130
14252
  *
14131
14253
  * @param session 会话(需有 append)
14132
- * @param provider 当前路由 provider(写入 assistant 消息来源)
14133
- * @param model 当前模型 id
14254
+ * @param anchorMessages 锚定话语序列(缺省协议固有两条)
14134
14255
  * @returns 是否成功写入
14135
14256
  */
14136
- function appendAnchorHistory(session, provider, model, anchorMessages = DEFAULT_ANCHOR_MESSAGES) {
14257
+ function appendAnchorHistory(session, anchorMessages = DEFAULT_ANCHOR_MESSAGES) {
14137
14258
  const target = session;
14138
14259
  if (typeof target?.append !== "function") return false;
14139
14260
  const append = target.append.bind(target);
14140
14261
  const rid = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
14141
- append("system/message", {
14142
- turn: 1,
14143
- step: 1,
14144
- message: {
14145
- id: rid("anchor-system"),
14146
- role: "system",
14147
- content: [{
14148
- type: "text",
14149
- text: ANCHOR_SYSTEM_PLACEHOLDER
14150
- }],
14151
- source: { kind: "system-prompt" }
14152
- }
14153
- }, { surfaceOp: "append" });
14154
- for (let i = 0; i < anchorMessages.length; i++) {
14155
- const turn = i + 1;
14156
- append("user/message", {
14157
- id: rid(`anchor-user-${turn}`),
14262
+ try {
14263
+ for (let i = 0; i < anchorMessages.length; i++) append("user/message", {
14264
+ id: rid(`anchor-user-${i + 1}`),
14158
14265
  role: "user",
14159
14266
  content: [{
14160
14267
  type: "text",
@@ -14162,26 +14269,10 @@ function appendAnchorHistory(session, provider, model, anchorMessages = DEFAULT_
14162
14269
  }],
14163
14270
  source: { kind: "user" }
14164
14271
  }, { surfaceOp: "append" });
14165
- append("assistant/message", {
14166
- turn,
14167
- step: 1,
14168
- message: {
14169
- id: rid(`anchor-assistant-${turn}`),
14170
- role: "assistant",
14171
- content: [{
14172
- type: "text",
14173
- text: ANCHOR_ACK_TEXT
14174
- }],
14175
- source: {
14176
- kind: "model",
14177
- provider,
14178
- model
14179
- }
14180
- },
14181
- stream: []
14182
- }, { surfaceOp: "append" });
14272
+ return anchorMessages.length > 0;
14273
+ } catch {
14274
+ return false;
14183
14275
  }
14184
- return true;
14185
14276
  }
14186
14277
  /**
14187
14278
  * 构建一个 epoch 感知晋升跟踪器(纯逻辑,可单测)。
@@ -14295,16 +14386,18 @@ const PROMOTE_EVENTS = {
14295
14386
  "assistant-message": /* @__PURE__ */ new Set(["assistant/message"]),
14296
14387
  either: /* @__PURE__ */ new Set(["tool/call", "assistant/message"])
14297
14388
  };
14298
- /** 协议固有 bootstrap 设置:**zeroTools=false**(v1.60 起)。
14389
+ /** 协议固有 bootstrap 设置(v1.62 / D140)。
14299
14390
  *
14300
- * 🔴 **为什么从 true 改成 false**(owner 令 2026-10-17,造历史取代真跑):
14301
- * 锚定历史现在**直接写进日志**,因此 `assistant/message` 晋升信号在首请求前
14302
- * 就已满足 ⇒ `boundary < 0` 分支实际不可达。但**不能依赖"恰好满足"这个副作用**:
14303
- * 若哪天锚定历史没写成(例如非 CCC 会话/异常降级),`zeroTools: true` 会退回到
14304
- * 「首请求 0 工具」,那是**另一种**退化。显式关掉 ⇒ 任何情况下首请求都拿完整目录。
14391
+ * 🔴 **zeroTools 必须为 false**:v1.62 起锚定历史**只造 `user/message`**(不含伪造的
14392
+ * `assistant/message`)⇒ **首请求时晋升信号尚未满足** ⇒ 若仍 `zeroTools: true`,
14393
+ * 首请求会真的退化成「0 工具」,模型看不到任何 ACC 工具。
14305
14394
  *
14306
- * ⚠️ 这意味着 `bootstrapTools` 窄化也随之失效 —— 它的作用本来就是"首请求窄目录",
14307
- * 而造历史的目的正是**取消这个断点**。保留字段只为不破坏旧测试的引用面。 */
14395
+ * 🔴 **为什么不改回去伪造一条 assistant**:宿主 v4 校验要求 `assistant/message` 落在
14396
+ * 已打开的 turn+step 内(见 `appendAnchorHistory` 头注释),而锚定写入时 turn/step
14397
+ * 都还没打开 ⇒ 伪造它**结构性非法**,代价是「重新进入会话即 corrupt」。
14398
+ *
14399
+ * ⇒ 取舍:**首请求就给完整工具目录**(放弃「首请求窄目录」这个 cache 断点优化),
14400
+ * 换来**日志合法**。`bootstrapTools` 因此只是历史字段,`boundary < 0` 分支已让位。 */
14308
14401
  function resolveBootstrapSettings() {
14309
14402
  return {
14310
14403
  bootstrapTools: [...DEFAULT_BOOTSTRAP_TOOLS],
@@ -14352,24 +14445,32 @@ function registerBootstrap(ctx) {
14352
14445
  const root = cccRootForCwd(cwd);
14353
14446
  if (!root) return;
14354
14447
  trackerFor(root).observe(session, event);
14448
+ if (event.type === "system/message") setTimeout(() => {
14449
+ try {
14450
+ maybeAnchor(session, root);
14451
+ } catch {}
14452
+ }, 0);
14355
14453
  } catch {}
14356
14454
  });
14455
+ /** 造锚定历史(幂等;防重靠 `anchoredSessions`)。 */
14456
+ const maybeAnchor = (session, _root) => {
14457
+ const s = session;
14458
+ s?.header?.delegationDepth;
14459
+ const sid = typeof s?.id === "string" ? s.id : void 0;
14460
+ if (sid !== void 0 && anchoredSessions.has(sid)) return;
14461
+ if (sid !== void 0 && (sid.startsWith("cheap-worker-") || isSkiffSessionId(sid))) return;
14462
+ if (hasAssistantHistory(session)) return;
14463
+ const wrote = appendAnchorHistory(session, SETTINGS.anchorMessages);
14464
+ if (wrote && sid !== void 0) anchoredSessions.add(sid);
14465
+ if (wrote) warnOnce(`anchor history fabricated: ${SETTINGS.anchorMessages.length} 条 user 消息(不再真跑)`);
14466
+ };
14357
14467
  ctx.on("agent/inbox/inserted", ({ agent, message }) => {
14358
14468
  try {
14359
14469
  if (!agentRoot(agent)) return;
14360
14470
  const session = agent.session;
14361
- const depth = session?.header?.delegationDepth ?? 0;
14362
14471
  const sid = typeof session?.id === "string" ? session.id : void 0;
14363
14472
  if (sid !== void 0 && (sid.startsWith("cheap-worker-") || isSkiffSessionId(sid))) return;
14364
- if (depth === 0) {
14365
- if (hasUserMessageHistory(session)) return;
14366
- } else if (sid !== void 0 && anchoredSessions.has(sid)) return;
14367
14473
  if (message?.source?.kind === "plugin:dsh-serenity-hooks") return;
14368
- const route = agent.options;
14369
- const wrote = appendAnchorHistory(session, typeof route?.provider === "string" ? route.provider : "unknown", typeof route?.model === "string" ? route.model : "unknown");
14370
- if (wrote && sid !== void 0) anchoredSessions.add(sid);
14371
- if (wrote) warnOnce(`anchor history fabricated: ${SETTINGS.anchorMessages.length} 轮(不再真跑)`);
14372
- return;
14373
14474
  } catch {}
14374
14475
  });
14375
14476
  let warned = false;
@@ -14408,10 +14509,11 @@ function registerBootstrap(ctx) {
14408
14509
  };
14409
14510
  }
14410
14511
  if (status.boundary < 0) return assembled;
14411
- const keep = new Set(SETTINGS.compactionTools);
14512
+ const keep = new Set(SETTINGS.bootstrapTools);
14513
+ for (const toolName of SETTINGS.compactionTools) keep.add(toolName);
14412
14514
  const missing = [...keep].filter((name) => !available.has(name));
14413
14515
  if (missing.length > 0) {
14414
- warnOnce(`expected compaction tools missing=${JSON.stringify(missing)} — bootstrap disabled, full catalog exposed`);
14516
+ warnOnce(`expected bootstrap tools missing=${JSON.stringify(missing)} — bootstrap disabled, full catalog exposed`);
14415
14517
  return assembled;
14416
14518
  }
14417
14519
  return {
@@ -60,34 +60,57 @@ export declare const DEFAULT_ANCHOR_MESSAGES: string[];
60
60
  */
61
61
  export declare function hasUserMessageHistory(session: unknown): boolean;
62
62
  /**
63
- * 把两轮 first-anchor 对话**直接写进会话日志**,取代原来的「真跑两轮」。
63
+ * 会话里是否已有模型回复(= 已经不是首轮)。
64
64
  *
65
- * ## 为什么可以这样(三条取证,全部打在真 `@deepseek-ai/dsh-session` 上)
65
+ * 🔴 这是锚定的**形状判据**(v1.62):锚定只能在**首轮**造。
66
+ * ⛔ 不能用 `hasUserMessageHistory`:宿主在 system 之后、真人 prompt 之前就写了
67
+ * ACC 横幅,而锚定实际执行点(`setTimeout(0)`)更晚 ⇒ 真人历史**必然已存在** ⇒ 恒真。
68
+ * 而 `assistant/message` 只在**模型真的答过一轮之后**才出现 ⇒ 它在首轮时必然为空。
69
+ */
70
+ export declare function hasAssistantHistory(session: unknown): boolean;
71
+ /**
72
+ * 把 first-anchor 的锚定话语**直接写进会话日志**,取代「真跑两轮」。
73
+ *
74
+ * ## 🔴 两条硬约束(宿主 v4 格式校验器,实测逐条踩过)
75
+ *
76
+ * **约束 A — 只能写 `user/message`**:`system/message` 与 `assistant/message` 都在
77
+ * `STEP_EVENT_TYPES` 里 ⇒ 必须过 `requireStep()`(`data.turn`/`data.step` 精确匹配
78
+ * **当前打开的** turn 与 step,且为 null 时同样拒绝)。而本函数在 `agent/pre-step`
79
+ * 前后被调用时 **turn/step 都还没打开**(`turn/start` 在 preStep 之前,`step/start`
80
+ * 在 preStep 之后)⇒ 代写它们**结构性非法**。
81
+ * ⚠️ 症状:写通道不校验 ⇒ **首轮正常**;**重新进入**时重放才抛
82
+ * `system/message does not match an open turn and step` ⇒ 会话 corrupt、界面空白。
83
+ * ⇒ **v1.60 那版「先写占位 system 等宿主 replace」的整套方案因此作废**。
66
84
  *
67
- * 1. **system 必须是 surface node 0**,而 `agent/pre-step` 跑在宿主提交 system **之前**
68
- * ⇒ 若直接写 [anchor…],system 会被追加到**末尾**(实测:`systemAtIndex: 4`,
69
- * 且**不报错**、静默走偏)。解法 = **先写一条占位 system**:宿主随后看到 node0
70
- * 已有 system 且文本不同 ⇒ 走 `replace(head)` 换成真提示词,锚定历史原样留在其后。
71
- * 2. **`contentGeneration` 只在 replace 时涨,append 不涨**;而 `buildRequest()`
72
- * 取 generation 快照**晚于** `systemPrompt.project()` ⇒ 那次 replace 不会被
73
- * 误判成「系列断裂」(不会误触发 `startsSeries`)。
74
- * 3. **造出来的 `assistant/message` 合法**:`stream: []` 通过校验;整段历史之后
75
- * 仍可被 compaction 的 `surfaceOp: replace` 整体覆盖(不炸)。
85
+ * **约束 B — 必须写在宿主提交 `system/message` 之后**:格式层有 `protectedHead`
86
+ * 机制 —— `foldSurface()` 里 `system/message` 若在 **surface 非空且尚无 protectedHead**
87
+ * 时到来,直接抛 `system/message requires a protected first surface head`。
88
+ * 也就是说:**surface 的第一个节点必须是 system**。
89
+ * ⇒ 若我们先写 user 消息,宿主随后的 system 就落不到首位 ⇒ 重放同样判 corrupt。
90
+ * ⚠️ 这条是**第一次修复后仍然翻车**的原因(同一会话、不同报错),
91
+ * 判据仍是「重启宿主后重新进入该会话」。
76
92
  *
77
- * ## ⚠️ 为什么不发 turn/start、turn/end
93
+ * ⇒ 因此调用时机 = **`session/event` 里看到该会话第一条 `system/message` 之后**(见下方注册处),
94
+ * 而不是 `agent/inbox/inserted`(那太早)。
78
95
  *
79
- * 宿主的 turn 计数是**构造时缓存**的:`phase.lastTurn` 在本钩子触发前已读定(0),
80
- * 之后 `turn()` 恒用 `phase.turn + 1`。我们若自己发 `turn/start{1}{2}`,宿主随后
81
- * 仍会再发一条 `turn/start{1}` ⇒ **重复编号 ∧ 两个 turn 同时开着**。
82
- * ⇒ 只给消息打 **turn 标签**(模型可见顺序不依赖 turn 事件,`deriveMessages` 只看
83
- * surface 节点),**不碰宿主的 turn 账本**。
96
+ * ## 为什么去掉 assistant 那半不损失什么
97
+ *
98
+ * 锚定话语的**唯一目的**是让模型认为自己已经同意过协作协议。
99
+ * 保留 **2 条 `user/message`** 即可达成;丢掉的只是"模型自己的 ack 回复"——
100
+ * 而那原本也是**我们伪造的**,不是模型真说过的。
101
+ *
102
+ * ## 判据(改这个函数前后都必须跑)
103
+ *
104
+ * ① 单测钉形状(只许 user/message);
105
+ * ② **容器内真跑一轮 → 重启宿主 → 重新进入该会话** —— 只有这条路能证明日志合法
106
+ * (前两次修复都是"单测全绿 + 首轮正常"却仍然 corrupt)。
107
+ * ③ 直接调宿主自己的 `assertReleasedV4Relationships()` 判形状,**不要自写复刻**。
84
108
  *
85
109
  * @param session 会话(需有 append)
86
- * @param provider 当前路由 provider(写入 assistant 消息来源)
87
- * @param model 当前模型 id
110
+ * @param anchorMessages 锚定话语序列(缺省协议固有两条)
88
111
  * @returns 是否成功写入
89
112
  */
90
- export declare function appendAnchorHistory(session: unknown, provider: string, model: string, anchorMessages?: string[]): boolean;
113
+ export declare function appendAnchorHistory(session: unknown, anchorMessages?: string[]): boolean;
91
114
  interface PromotionStatus {
92
115
  /** 最后一次 compaction/end 的 seq(-1 = 未压缩过) */
93
116
  boundary: number;
@@ -114,16 +137,18 @@ interface PromotionTracker {
114
137
  * 通常 ≤ 2 < 默认兜底 3,不受影响。
115
138
  */
116
139
  export declare function createEpochPromotion(promoteEvents: Set<'tool/call' | 'assistant/message'>, requiredSignals?: number, maxRoundsFallback?: number): PromotionTracker;
117
- /** 协议固有 bootstrap 设置:**zeroTools=false**(v1.60 起)。
140
+ /** 协议固有 bootstrap 设置(v1.62 / D140)。
141
+ *
142
+ * 🔴 **zeroTools 必须为 false**:v1.62 起锚定历史**只造 `user/message`**(不含伪造的
143
+ * `assistant/message`)⇒ **首请求时晋升信号尚未满足** ⇒ 若仍 `zeroTools: true`,
144
+ * 首请求会真的退化成「0 工具」,模型看不到任何 ACC 工具。
118
145
  *
119
- * 🔴 **为什么从 true 改成 false**(owner 令 2026-10-17,造历史取代真跑):
120
- * 锚定历史现在**直接写进日志**,因此 `assistant/message` 晋升信号在首请求前
121
- * 就已满足 ⇒ `boundary < 0` 分支实际不可达。但**不能依赖"恰好满足"这个副作用**:
122
- * 若哪天锚定历史没写成(例如非 CCC 会话/异常降级),`zeroTools: true` 会退回到
123
- * 「首请求 0 工具」,那是**另一种**退化。显式关掉 ⇒ 任何情况下首请求都拿完整目录。
146
+ * 🔴 **为什么不改回去伪造一条 assistant**:宿主 v4 校验要求 `assistant/message` 落在
147
+ * 已打开的 turn+step 内(见 `appendAnchorHistory` 头注释),而锚定写入时 turn/step
148
+ * 都还没打开 ⇒ 伪造它**结构性非法**,代价是「重新进入会话即 corrupt」。
124
149
  *
125
- * ⚠️ 这意味着 `bootstrapTools` 窄化也随之失效 —— 它的作用本来就是"首请求窄目录",
126
- * 而造历史的目的正是**取消这个断点**。保留字段只为不破坏旧测试的引用面。 */
150
+ * ⇒ 取舍:**首请求就给完整工具目录**(放弃「首请求窄目录」这个 cache 断点优化),
151
+ * 换来**日志合法**。`bootstrapTools` 因此只是历史字段,`boundary < 0` 分支已让位。 */
127
152
  export declare function resolveBootstrapSettings(): BootstrapSettings;
128
153
  export declare function registerBootstrap(ctx: Context): void;
129
154
  export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.61.0",
4
- "description": "宁静号 ACC harness(Native Cordis 插件)——给 DeepSeek Harness 装一个「AI 工作区」:14 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/ontology/serenity-skill-creator/cheap-worker/localstore/container_admin;另三个 im-bridge/acc-diag/diagram 按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 工作日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.7-rc.1 与 0.2.0-rc.1(双档)。",
3
+ "version": "1.63.1",
4
+ "description": "宁静号 ACC harness(Native Cordis 插件)——给 DeepSeek Harness 装一个「AI 工作区」:14 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/ontology/serenity-skill-creator/cheap-worker/localstore/container_admin;另三个 im-bridge/acc-diag/diagram 按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 工作日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.7-rc.1 / 0.2.0-rc.1 / 0.2.1-rc.1(三档)。",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",