clearai-dsh 0.1.2 → 0.1.4

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.
@@ -59,7 +59,7 @@ const SELF_JUDGE_MAX_INDEX = 2
59
59
  const MAX_PLAN_STEPS = 25
60
60
  /**
61
61
  * `_DEFAULT_BLOCKED_THRESHOLD`:连续未过闸达阈值 → 计划置 blocked,等人。
62
- * 2026-09-11 起它**不再按档取值**(曾经 dialogue 2 / max 3):它是证据质量闸,不是预算,
62
+ * 它**不按档取值**:它是证据质量闸,不是预算,
63
63
  * 人在不在场都得先过闸。部署想要「人就在旁边,早点回来问」,就在配置面写小一点(现在是 2)。
64
64
  */
65
65
  const DEFAULT_BLOCKED_THRESHOLD = 3
@@ -71,7 +71,7 @@ const MIN_BRIEF_CHARS = 280
71
71
  */
72
72
  const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url))
73
73
  /**
74
- * 模板从哪来(2026-09-11 打包纪律④:**发行物里不许出现仓库路径**)。
74
+ * 模板从哪来(打包纪律:**发行物里不许出现仓库路径**)。
75
75
  *
76
76
  * 原来这里有一条 fallback 指回 ClearAI 仓库里那份工作区模板(同一份模板的第二个位置)——
77
77
  * 在 dev checkout 里很方便,**但它把仓库结构写进了发行物**:
@@ -306,7 +306,7 @@ function collectText(output) {
306
306
  /**
307
307
  * 子 run 的结局 —— **必须过这里**,别各自 `then(v => ok:true)`。
308
308
  *
309
- * 2026-09-11 用户实测的 bug:中断一次跑动之后,状态里**所有子代理都显示"执行完成"**。
309
+ * 一个修过的 bug:中断一次跑动之后,状态里**所有子代理都显示"执行完成"**。
310
310
  * 根因:DSH 里「中断 / 报错」**不是 reject**——`run.result` 照常 resolve,只是
311
311
  * `stopReason` 变成 `aborted` / `error` / `interrupted`(`dsh-subagent` 的
312
312
  * `settleSubagent` 就是这么落的)。只按 reject 判失败,就会把被打断的侦察记成"完成"、
@@ -390,7 +390,7 @@ const OUTPUT_SCHEMA = {
390
390
  *
391
391
  * 两份实现必须同格式,所以不是靠人记住,而是靠测试:
392
392
  * `test/kernel.test.mjs` 拿同一批消息喂两个解析器,断言结论一致。
393
- * 走过一次教训——2026-09-11 收敛动词表时才发现内核这份是**抄在闭包里的私本**,
393
+ * 这份白名单不能是**抄在闭包里的私本**——
394
394
  * 改标记要改两处、忘了就静默失效。现在它在模块层、被导出,漏一处测试就红。
395
395
  */
396
396
  export const HUMAN_GATE_MARK = '[clearai·人门]'
@@ -398,7 +398,7 @@ export const HUMAN_GATE_MARK = '[clearai·人门]'
398
398
  * 人门**动词白名单**(与宿主半 `fold.js` 的同名表逐字一致 —— 两边不 import 对方,
399
399
  * 靠 `test/kernel.test.mjs` 的等价性用例钉住)。
400
400
  *
401
- * §34 摘掉 `set_autonomy` 时漏改了这一侧 ⇒ 内核仍认它、宿主已经不认 ⇒ **等价性用例立刻红** ✓。
401
+ * 两侧白名单一旦各自维护,摘动词时只改一侧 ⇒ 内核仍认、宿主不认 ⇒ **等价性用例立刻红** ✓。
402
402
  * 这正是那条用例存在的意义:两份实现漂移不许静默。
403
403
  */
404
404
  export const HUMAN_GATE_ACTIONS = ['adopt_branch', 'abandon_fork', 'promote_skill']
@@ -490,28 +490,17 @@ const TOOL_CATALOG = new Set(Object.values(MECHANISM_TOOLS).flat())
490
490
  const AUTONOMY_VARIANTS = new Set(Object.values(SECTION_SLOTS).flatMap((variants) => Object.values(variants)))
491
491
 
492
492
  /**
493
- * 续跑轮数:两个档的**默认额度**(各自的最大自动回合数)。
493
+ * 续跑轮数:一个**保险丝**,不是用户的档位(「预算档」这个概念已拆,奥卡姆)。
494
494
  *
495
- * dialogue 档 6 轮 ← 人在场:「一条人类消息 = 一个窗口,最多 6 个自动续跑回合(≈144 步工具调用),
496
- * 够一口气跑完一个中等计划;跑飞了最多烧 6 回合自停」
497
- * max 档 512 轮 ← 无人值守:人不在场,跑得久才划算
495
+ * 历史上这里是 `{attended: 6, unattended: 512}`,由面板上「多问我 / 自己跑」那个开关选。
496
+ * 从第一性原理看错了两次:
497
+ * · 「我要不要在场」是**运行时状态**(有没有门开着、有没有裁决在飞、有没有开着的步),
498
+ * 不是**配置项**——它现在由 `turnDemand` 从门状态算出来,不看档;
499
+ * · 那一档还顺手把「计划经人确认」变成系统自己签的 ✗。
498
500
  *
499
- * 2026-09-11 拆掉「预算档」这个概念(奥卡姆,用户拍板),三件事一起说清:
500
- * · **执行它的本来就是原生**——`maxAutoTurns` → 宿主目标的 `maxGoalRounds`,到限由
501
- * `dsh-goal-round-driver` 自己 `block(code='round-limit')`。我们只负责给数字,不造机制;
502
- * · `tokenBudget`(8M / 32M)**删掉**:它从未被执行(上下文预算本来就是原生
503
- * `dsh-token-meter` + `dsh-compaction-basic` 的活),留着的两个死数字只会让人以为
504
- * 「这里还有个额度旋钮」——不可执行的旋钮不该出现在任何清单上;
505
- * · `blockedThreshold`(连拦阈值)**移出**这个概念:它是观察准入的**质量闸**,不是预算。
506
- * 现在是一个独立旋钮(默认 2),不再按档取值。
507
- */
508
- /**
509
- * 续跑窗口的**额度**(§34):一个保险丝,不是用户的档位。
510
- *
511
- * 原先这里是 `{attended: 6, unattended: 512}` —— 由面板上「多问我 / 自己跑」那个开关选。
512
- * 从第一性原理看:「我要不要在场」是**运行时状态**(有没有门开着、有没有裁决在飞、有没有开着的步),
513
- * 不是**配置项** ✗;而且那一档还顺手把「计划经人确认」变成系统自己签的 ✗(见 §34)。
514
501
  * 现在只剩一个默认值:够长到能跑完一件真活,又短到不会无声烧掉一整夜;要更长由人显式表达。
502
+ * 执行它的是**原生**——`maxAutoTurns` → 宿主目标的 `maxGoalRounds`,到限由
503
+ * `dsh-goal-round-driver` 自己 `block(code='round-limit')`。我们只负责给数字,不造机制。
515
504
  */
516
505
  const DEFAULT_MAX_AUTO_TURNS = 128
517
506
 
@@ -535,7 +524,7 @@ const CONTRIB_DEFAULTS = {
535
524
  function resolveContributions(contributions, autonomy) {
536
525
  const declared = contributions ?? {}
537
526
  /**
538
- * 贡献表**顶层**也走白名单:2026-09-11 删掉 `budgets` 块之后,旧写法
527
+ * 贡献表**顶层**也走白名单:`budgets` 块删掉之后,旧写法
539
528
  * (`contributions: { budgets: {...} }`)必须装配期就炸——否则它会**静默无效**,
540
529
  * 而「配了没生效」正是这份移植最想消灭的一类错(与未知机制/未知工具同一条纪律)。
541
530
  */
@@ -569,7 +558,7 @@ function resolveContributions(contributions, autonomy) {
569
558
  if (AUTONOMY_VARIANTS.has(entry)) throw new Error(`autonomy_section_must_use_slot:clearai-kernel:${entry}`)
570
559
  }
571
560
  /**
572
- * **本体(§22)**:贡献表的第八项,也是唯一一项**状态面**(前七项都是行为面)。
561
+ * **本体**:贡献表的第八项,也是唯一一项**状态面**(前七项都是行为面)。
573
562
  * 三条装配期纪律(逐条对着 ClearAI 本体 P1 的定案):
574
563
  * · **一个进程一份本体**:两份声明的合并语义未定义(两个插件各声明一个 hypothesis 算什么?),
575
564
  * 所以第二份直接拒;换装 = 换发行清单那一行。
@@ -585,7 +574,7 @@ function resolveContributions(contributions, autonomy) {
585
574
  if (problems.length > 0) throw new Error(`invalid_ontology:clearai-kernel:${problems[0]}`)
586
575
  ontology = VERIFICATION_LOOP
587
576
  }
588
- // 2026-09-11:「预算档」这个概念拆了。轮数与连拦阈值不再是贡献表里的一档,
577
+ // 「预算档」这个概念已拆。轮数与连拦阈值不再是贡献表里的一档,
589
578
  // 而是两个普通配置键(maxAutoTurns / blockedThreshold,见 CONFIG_KEYS)——
590
579
  // 它们跟「装哪些机制」不是一类事,混在一张清单里只会让人以为关掉机制就得关掉预算。
591
580
  return { mechanisms, tools, sections, ontology }
@@ -619,13 +608,13 @@ export function apply(ctx, config = {}) {
619
608
  const CFG = {
620
609
  /**
621
610
  * 连拦阈值:同一件事连续冲闸这么多次没过,计划置 blocked、停下等人。
622
- * **它不是预算**(2026-09-11 从「预算档」里拿出来):它管的是证据质量,
611
+ * **它不是预算**:它管的是证据质量,
623
612
  * 而且不再按档取值——人在不在场都得先过闸。
624
613
  */
625
614
  blockedThreshold: config.blockedThreshold ?? DEFAULT_BLOCKED_THRESHOLD,
626
615
  /**
627
- * 续跑轮数上限:显式写了就用它;没写则由**当档**决定(人在场 6 / 无人值守 512)。
628
- * 当档是会话事实(人可以在面板上切),所以这里只存「人写没写」,值在 armContinuation 现算。
616
+ * 续跑轮数上限:显式写了就用它,没写回落到 `DEFAULT_MAX_AUTO_TURNS`(128)。
617
+ * 这里只存「人写没写」,真正的回落发生在布防点(见 `armContinuation`)。
629
618
  */
630
619
  maxAutoTurns: config.maxAutoTurns ?? null,
631
620
  minBriefChars: config.minBriefChars ?? MIN_BRIEF_CHARS,
@@ -633,7 +622,7 @@ export function apply(ctx, config = {}) {
633
622
  /**
634
623
  * 收上来的结论「多久没在投影里落地就重收一次」(缺省 2000ms)。
635
624
  * 为什么需要它:内存里的 `reported` 只是「我发布过」,不等于**事实已经到了账本上**
636
- * (2026-09-11 长测现场:侦察结论发布后被丢掉,而条目已删、`reported` 已置位 ⇒ 永久丢)。
625
+ * (失效模式:侦察结论发布后被丢掉,而条目已删、`reported` 已置位 ⇒ 永久丢)。
637
626
  * 判据改成看**投影**:投影里还没落地就再发一次(同 id 的 `scout/settled`/`worldline/executed`
638
627
  * 在 fold 里是幂等的,重复发布不会长出第二条事实)。
639
628
  */
@@ -656,7 +645,7 @@ export function apply(ctx, config = {}) {
656
645
  // 部署里没有,`tools.restrict` 会**直接抛**(未知工具名)→ 侦察整条路 fail-closed。
657
646
  auditToolFilter: config.auditToolFilter ?? ['read', 'glob', 'grep', 'read_image'],
658
647
  /** 侦察的只读工具面:读文件、找文件、找内容、查公网——都不能写、不能执行。 */
659
- // 侦察是**只读**角色,看图也是读:`read_image` 必须在这张脸上(2026-09-11 用户指出)。
648
+ // 侦察是**只读**角色,看图也是读:`read_image` 必须在这张脸上。
660
649
  scoutToolFilter: config.scoutToolFilter ?? ['read', 'glob', 'grep', 'read_image', 'web_search', 'web_fetch'],
661
650
  /** 世界线是否物化成 git 分支 + worktree(工作区是 git 仓库时)。关掉就退化成声明的目录。 */
662
651
  gitWorldlines: config.gitWorldlines !== false,
@@ -691,10 +680,12 @@ export function apply(ctx, config = {}) {
691
680
  forkArbitration: config.forkArbitration !== false,
692
681
  runtimeCard: config.runtimeCard !== false,
693
682
  /**
694
- * 人在场 / 人不在场。只决定两件**机制**上的事(不决定人格、不决定工具面——
695
- * 按 D1,模式已合并,`if mode === ...` 在内核里不该出现):
696
- * · 澄清协议装哪一段(槽位 `clarification`,见末尾装配);
697
- * · (阶段 3.3)无人值守时的续跑与预算策略。
683
+ * 人在场 / 人不在场。**现在只决定一件事**:澄清协议装哪一段(槽位 `clarification`)。
684
+ *
685
+ * 它不再决定续跑(那由 `turnDemand` 从门状态算)、不再决定预算
686
+ * (只有一个 `DEFAULT_MAX_AUTO_TURNS`)、也不再是用户可切换的运行档
687
+ * (`set_autonomy` 已摘除;这里写的是部署初值)。
688
+ * 它不决定人格、不决定工具面——`if mode === ...` 在内核里不该出现。
698
689
  */
699
690
  autonomy: config.autonomy === 'unattended' ? 'unattended' : 'attended',
700
691
  /** 贡献表。缺省 = 全开;要裁剪就从这里裁,而不是去改装配代码。 */
@@ -704,7 +695,7 @@ export function apply(ctx, config = {}) {
704
695
  /** 贡献表:先校验(装配期炸),后登记(见文件末尾的装配段)。 */
705
696
  const CONTRIB = resolveContributions(CFG.contributions, CFG.autonomy)
706
697
  const SECTION_LIST = resolveSections(CONTRIB.sections, CFG.autonomy)
707
- // 档决定缺省的连拦阈值(显式配置优先):对话档 2、目标档 3。
698
+ // 连拦阈值**不按档取**(见 CFG.blockedThreshold):它是质量闸,不是预算。
708
699
 
709
700
  /** 宿主读面。缺了它整件事不成立——所以每个工具都显式报错,不静默降级。 */
710
701
  const host = () => ctx.get('clearai')
@@ -727,7 +718,7 @@ export function apply(ctx, config = {}) {
727
718
  /**
728
719
  * 认一条假设:`id` 最稳,**原文**与**唯一前缀**(≥8 字)也认。
729
720
  *
730
- * 为什么放宽:2026-09-10 的实跑里,模型连着三轮把假设**原文**填进 `tests.hypothesis`,
721
+ * 为什么放宽:模型会把假设**原文**整句填进 `tests.hypothesis`(真跑里连着三轮都是),
731
722
  * 而当时只认 id,错误信息又不列出有效 id——它于是逐字猜哪里差了一个标点,白烧了三轮上下文。
732
723
  * 「机制把模型逼进猜谜」是机制的问题,不是模型的问题。
733
724
  */
@@ -767,7 +758,7 @@ export function apply(ctx, config = {}) {
767
758
  message: `${value.message ?? value.code ?? 'ok'}\n\n${preview.card}`,
768
759
  }
769
760
  // 输出**越界就裁掉并告警**:宿主会拿 output.schema 校验工具结果,多一个未声明的字段
770
- // 会让整个工具调用失败(2026-09-10 实际发生过:CreatePlan 因此全军覆没)。
761
+ // 会让整个工具调用失败(CreatePlan 曾因此全军覆没)。
771
762
  // 裁掉会让模型少看到一个字段(可接受的降级),但绝不让一次成功的动作整个作废。
772
763
  const declared = new Set(Object.keys(OUTPUT_SCHEMA.properties))
773
764
  const trimmed = Object.keys(result).filter((key) => !declared.has(key))
@@ -916,6 +907,13 @@ export function apply(ctx, config = {}) {
916
907
  - **只读**:你**不能写文件、不能执行命令**。若任务要求"产出文件/写入某路径",这不是你能做的——
917
908
  把该文件**应有的内容直接写进你的最终答复文本**,由父任务据此落盘。
918
909
 
910
+ **产出纪律(结论是给父任务省上下文的,不是流水账):**
911
+ - 用 Markdown 写结论,**3000 字以内**;要的是判断与可复核的锚点(文件路径、行号、数字、原文摘录),
912
+ 不是把你读过的过程复述一遍。
913
+ - 超了就**压缩**:先给结论与关键读数,再给「要细节去哪里看」的指针(哪个文件、哪一节)。
914
+ 3000 字装不下的细节,本来就不该指望父任务一次读完。
915
+ - 你只读、不能落盘;父任务会把你的答复落成文件。所以宁可写短而准,也不要长而糊。
916
+
919
917
  **怎么算查清了(侦察的方法,不只是禁令):**
920
918
  - **落到第一手证据**:结论要落在你**亲眼读到**的东西上——文件里的原话、真实的目录清单、
921
919
  日志里的退出码、代码里的那一行。"按常理应该是"不是证据;凭文件名猜内容也不是。
@@ -1052,6 +1050,7 @@ export function apply(ctx, config = {}) {
1052
1050
  const persona = options.persona
1053
1051
  const schema = options.outputSchema ?? undefined
1054
1052
  const toolFilter = options.toolFilter ?? null
1053
+ const failures = []
1055
1054
  const attempts = []
1056
1055
  if (toolFilter !== null) attempts.push({ variant: { persona, outputSchema: schema, toolFilter }, capability: 'persona+outputSchema+toolFilter' })
1057
1056
  attempts.push({ variant: { persona, outputSchema: schema }, capability: 'persona+outputSchema' })
@@ -1059,11 +1058,15 @@ export function apply(ctx, config = {}) {
1059
1058
  attempts.push({ variant: { persona }, capability: 'persona' })
1060
1059
  if (schema !== undefined) attempts.push({ variant: { outputSchema: schema }, capability: 'outputSchema' })
1061
1060
  attempts.push({ variant: {}, capability: 'prompt-only' })
1062
- const failures = []
1063
1061
  for (const attempt of attempts) {
1064
1062
  try {
1065
1063
  const run = await subagents.start(CFG.auditProvider, { ...base, ...attempt.variant })
1066
- return { ok: true, run, capability: attempt.capability }
1064
+ /**
1065
+ * `native: false` = 结论不会由运行时投递,得靠收集那一刻的返回值带上(降级路径)。
1066
+ * `degraded` 把**为什么降级**如实带出去(调用方落进账本):只说「能力不是 continuable」
1067
+ * 不解释原因,读账的人无法判断这是部署限制还是代码 bug。
1068
+ */
1069
+ return { ok: true, run, capability: attempt.capability, native: false, degraded: failures[0] ?? null }
1067
1070
  } catch (error) {
1068
1071
  failures.push(`${attempt.capability}:${String(error?.message ?? error).slice(0, 100)}`)
1069
1072
  }
@@ -1078,20 +1081,24 @@ export function apply(ctx, config = {}) {
1078
1081
  /**
1079
1082
  * 把一条已落定的执行者结果折成变更(仍在跑就返回 null)。
1080
1083
  *
1081
- * **这里不再管「报过没有」**(2026-09-11):发布的时机与重试由 sweep 按**投影**判断
1084
+ * **这里不管「报过没有」**:发布的时机与重试由 sweep 按**投影**判断
1082
1085
  * (「报过」不等于「到账本了」)。`dispose` 也挪到「确认到账」之后——早释放会把子会话
1083
1086
  * 从会话服务里摘掉,而它正是回收结论的最后一份凭据。
1084
1087
  */
1085
- function collectExecutor(entry) {
1088
+ function collectExecutor(entry, recovered = false) {
1086
1089
  if (entry.settled === null) return null
1087
1090
  const ok = entry.settled.ok === true
1088
1091
  return {
1089
1092
  fork: entry.fork,
1090
1093
  branch: entry.branch,
1091
1094
  label: entry.label,
1092
- // `note` 写**具体**的结局(aborted / error / …),不写笼统的 failed——与侦察那条路同一个纪律
1093
- // (2026-09-11:卡片上写着「执行没跑成:failed」,而当时真正发生的是 aborted)。
1094
- mutation: { t: 'worldline/executed', fork: entry.fork, branch: entry.branch, child: entry.child, ok, conclusion: String(entry.settled.conclusion ?? '').slice(0, 4000), note: ok ? null : String(entry.settled.stopReason ?? 'failed') },
1095
+ /**
1096
+ * `note` 写**出处或具体结局**,两件事都写清:
1097
+ * · 成功且是从它自己的会话日志回收的 ⇒ `recovered`(派它的那次进程可能已经不在了,
1098
+ * 读账的人必须看得出这条结论不是当场拿到的);
1099
+ * · 失败 ⇒ 具体结局(aborted / error / …),不写笼统的 failed——与侦察那条路同一个纪律。
1100
+ */
1101
+ mutation: { t: 'worldline/executed', fork: entry.fork, branch: entry.branch, child: entry.child, ok, conclusion: clipConclusion(entry.settled.conclusion, entry.workspace ?? null), note: ok ? (recovered ? 'recovered' : null) : String(entry.settled.stopReason ?? 'failed') },
1095
1102
  }
1096
1103
  }
1097
1104
 
@@ -1109,7 +1116,7 @@ export function apply(ctx, config = {}) {
1109
1116
  /**
1110
1117
  * **只派遣,不等**:把执行者放出去、登记进表,立刻返回。
1111
1118
  *
1112
- * 为什么必须分开(2026-09-11 用户在 GUI 里抓到的):原来 `ForkPlan` 在这里
1119
+ * 为什么必须分开:`ForkPlan` 若在这里
1113
1120
  * `await Promise.all(四个执行者)`,而变更记录是**随工具结果**进日志的——
1114
1121
  * 于是「长出四条世界线」这条事实要等四个执行者全部跑完才落账:
1115
1122
  * · 树上十几分钟看不到分叉(用户看到的就是这个窗口);
@@ -1147,22 +1154,29 @@ export function apply(ctx, config = {}) {
1147
1154
  const childId = String(dispatched.run.id)
1148
1155
  const entry = { fork: fork.id, branch: branch.id, label: branch.label, workspace: branch.workspace, child: childId, settled: null, reported: false, run: dispatched.run }
1149
1156
  executorRuns.set(childId, entry)
1150
- mutations.push({ t: 'worldline/executing', fork: fork.id, branch: branch.id, child: childId, capability: dispatched.capability })
1151
- // 先把结果挂上,再等:等超时了也不要紧——条目留在表里,WorldlineStatus 之后来收。
1152
- // 中断/报错是 resolve 带 stopReason(见 settleSubRun):不判它就会把「被打断的执行者」
1153
- // 记成「交付成功」,而世界线的算术会拿半截读数去比较。
1154
- entry.promise = dispatched.run.result.then(
1155
- (value) => {
1156
- const settled = settleSubRun(value)
1157
- entry.settled = { ok: settled.ok, conclusion: settled.conclusion, stopReason: settled.stopReason }
1158
- return entry.settled
1159
- },
1160
- (error) => {
1161
- const settled = settleSubRun(undefined, error)
1162
- entry.settled = { ok: false, conclusion: settled.conclusion, stopReason: settled.stopReason }
1163
- return entry.settled
1164
- },
1165
- )
1157
+ mutations.push({ t: 'worldline/executing', fork: fork.id, branch: branch.id, child: childId, capability: dispatched.capability, degraded_reason: dispatched.degraded ?? null })
1158
+ /**
1159
+ * 先把结果挂上,再等:等超时了也不要紧——条目留在表里,WorldlineStatus 之后来收。
1160
+ * 中断/报错是 resolve 带 stopReason(见 settleSubRun):不判它就会把「被打断的执行者」
1161
+ * 记成「交付成功」,而世界线的算术会拿半截读数去比较。
1162
+ *
1163
+ * **可续跑那一档没有 `result` promise**(与侦察同一个形状):结算改由运行时的结算通知
1164
+ * 或子会话日志给出,所以这里要会「没有」。
1165
+ */
1166
+ if (dispatched.run?.result !== undefined) {
1167
+ entry.promise = dispatched.run.result.then(
1168
+ (value) => {
1169
+ const settled = settleSubRun(value)
1170
+ entry.settled = { ok: settled.ok, conclusion: settled.conclusion, stopReason: settled.stopReason }
1171
+ return entry.settled
1172
+ },
1173
+ (error) => {
1174
+ const settled = settleSubRun(undefined, error)
1175
+ entry.settled = { ok: false, conclusion: settled.conclusion, stopReason: settled.stopReason }
1176
+ return entry.settled
1177
+ },
1178
+ )
1179
+ }
1166
1180
  return { ok: true, mutations, entry, child: childId }
1167
1181
  }
1168
1182
 
@@ -1172,9 +1186,9 @@ export function apply(ctx, config = {}) {
1172
1186
  * 两个调用者:pre-step 的 sweep(不问自答,结论自动回灌)与 `WorldlineStatus`(模型主动查)。
1173
1187
  */
1174
1188
  /**
1175
- * **从执行者自己的会话日志里回收结论**(2026-09-11 R3 长测:结论只活在父进程内存的 promise 上)。
1189
+ * **从执行者自己的会话日志里回收结论**(结论若只挂在父进程内存的 promise 上,父进程一断就永久丢)。
1176
1190
  *
1177
- * 现场:四条执行者**都正常跑完了**(各自子会话日志最后一条都是 `turn/end{completed}`),
1191
+ * 典型形态:四条执行者**都正常跑完了**(各自子会话日志最后一条都是 `turn/end{completed}`),
1178
1192
  * 可父会话退出时只收上来一条 —— 另外三条的结论还在内存那个 `.then()` 上,进程一没就没了。
1179
1193
  * 而结论**并没有丢**:它就写在执行者自己的会话日志里(DSH 的会话日志就是账本)。
1180
1194
  * 「什么都不删」在这里的具体含义是:**没丢的东西不该当成丢了**。
@@ -1229,7 +1243,13 @@ export function apply(ctx, config = {}) {
1229
1243
  if (text !== '') conclusion = text
1230
1244
  }
1231
1245
  const reason = String(end.data?.reason?.kind ?? 'unknown')
1232
- return { ok: reason === 'completed', stopReason: reason, conclusion }
1246
+ const ok = reason === 'completed'
1247
+ /**
1248
+ * 异常结束的结论要**自带说明**,与一次性派遣那条路(`settleSubRun`)同一个形状:
1249
+ * 半截文本被当成「回灌过的结论」是这里最危险的假话。两条路形状一致,
1250
+ * 「这句话是从哪条路来的」就不再影响账本的可读性。
1251
+ */
1252
+ return { ok, stopReason: reason, conclusion: ok ? conclusion : `子任务未正常结束(${reason}):${conclusion.slice(0, 1200)}` }
1233
1253
  }
1234
1254
 
1235
1255
  /**
@@ -1240,11 +1260,11 @@ export function apply(ctx, config = {}) {
1240
1260
  * 执行者的产物还在它自己的工作副本里,模型可以照常 `AdvanceWorldline` 用那些产物交付。
1241
1261
  */
1242
1262
  /**
1243
- * 收集器的**回合纪元**(§21):纪元 = 当前回合号(`payload.turn`)。
1263
+ * 收集器的**回合纪元**:纪元 = 当前回合号(`payload.turn`)。
1244
1264
  *
1245
1265
  * 为什么:投影**在回合内不前进**(本回合落的变更要等回合边界才折进去),所以
1246
1266
  * 「投影里还没落地」在同一回合内**永远为真**;旧写法配的是「过了 2 秒就重发」,
1247
- * 于是每个成功路径都重发一遍(长测现场:1 次侦察派遣 ⇒ **31 条** `scout/settled`;
1267
+ * 于是每个成功路径都重发一遍(踩过的坑:1 次侦察派遣 ⇒ **31 条** `scout/settled`;
1248
1268
  * 3 条世界线 ⇒ 14 条 `worldline/executed`),每次都还读一遍子会话日志,那一场跑了 8408s。
1249
1269
  * 重发只该发生在**下一个回合**——那时投影才有机会说话。
1250
1270
  *
@@ -1253,8 +1273,25 @@ export function apply(ctx, config = {}) {
1253
1273
  * (第一版就是这么把 3/3 掉成 2/3 的,真跑验收抓住的)。它按**时间窗口**限流,不按回合。
1254
1274
  */
1255
1275
  const collectEpochs = new Map()
1256
- /** 本窗口里已经从子会话日志捞过的侦察(会话:侦察 id → 上次尝试的毫秒数):限 I/O,不限机会。 */
1257
- const recoverAttempts = new Map()
1276
+ /**
1277
+ * **本回合已经落过账的子 run**(会话 → 回合号 + id 集)。
1278
+ *
1279
+ * 投影在回合内不前进,同一件事会被每一拍重新「发现」一次;靠这张表保证**同一回合不重发**。
1280
+ * 跨回合仍不见落地才补发——那时回合号变了,自然放行。keyed by 会话+记录 id,
1281
+ * 所以重启之后同样成立(不依赖内存里的条目)。
1282
+ */
1283
+ const publishedInEpoch = new Map()
1284
+ function alreadyPublished(sessionId, key) {
1285
+ const record = publishedInEpoch.get(String(sessionId))
1286
+ return record !== undefined && record.epoch === collectEpoch(sessionId) && record.keys.has(String(key))
1287
+ }
1288
+ function markPublished(sessionId, key) {
1289
+ const sessionKey = String(sessionId)
1290
+ const epoch = collectEpoch(sessionId)
1291
+ const record = publishedInEpoch.get(sessionKey)
1292
+ if (record === undefined || record.epoch !== epoch) publishedInEpoch.set(sessionKey, { epoch, keys: new Set([String(key)]) })
1293
+ else record.keys.add(String(key))
1294
+ }
1258
1295
  function collectEpoch(sessionId) {
1259
1296
  return collectEpochs.get(String(sessionId)) ?? -1
1260
1297
  }
@@ -1262,7 +1299,7 @@ export function apply(ctx, config = {}) {
1262
1299
  collectEpochs.set(String(sessionId), Number.isFinite(turn) ? Number(turn) : collectEpoch(sessionId) + 1)
1263
1300
  }
1264
1301
 
1265
- function sweepLostExecutors(state) {
1302
+ function sweepLostExecutors(state, sessionId) {
1266
1303
  const mutations = []
1267
1304
  let recovered = 0
1268
1305
  let lost = 0
@@ -1279,7 +1316,7 @@ export function apply(ctx, config = {}) {
1279
1316
  const child = execution.child === null || execution.child === undefined ? null : String(execution.child)
1280
1317
  const entry = child === null ? undefined : executorRuns.get(child)
1281
1318
  /**
1282
- * **表里有条目但还没落定**时也要试着回收(2026-09-11 最后一场长测:四条执行者都跑完了,
1319
+ * **表里有条目但还没落定**时也要试着回收(形态:四条执行者都跑完了,
1283
1320
  * 表里那条 promise 却没落定,于是「有条目」把回收挡住了,那条世界线永久停在「执行者未归」)。
1284
1321
  * 判据不看内存表,看**执行者自己的会话日志**:它写了 `turn/end`,结论就存在了。
1285
1322
  * 回收成功就把条目标成已报,免得那份 promise 之后落定时又报一次(一条事实一份账)。
@@ -1288,14 +1325,21 @@ export function apply(ctx, config = {}) {
1288
1325
  const found = child === null ? null : recoverFromChildSession(child)
1289
1326
  if (found !== null) {
1290
1327
  recovered += 1
1291
- if (entry !== undefined) entry.reported = true
1328
+ /**
1329
+ * 回收成功要把条目也标成已落定:可续跑那一档没有 promise,条目会永远停在
1330
+ * `settled === null`,于是每一拍回收都成功、每一拍都重发同一条事实。
1331
+ * 重发本身由 `alreadyPublished` 兜住,这里只是让条目如实反映「已经拿到了」。
1332
+ */
1333
+ if (entry !== undefined) entry.settled = { ok: found.ok, conclusion: found.conclusion, stopReason: found.stopReason }
1334
+ if (alreadyPublished(sessionId, `${fork.id}:${branch.id}`)) continue
1335
+ markPublished(sessionId, `${fork.id}:${branch.id}`)
1292
1336
  mutations.push({
1293
1337
  t: 'worldline/executed',
1294
1338
  fork: fork.id,
1295
1339
  branch: branch.id,
1296
1340
  child,
1297
1341
  ok: found.ok,
1298
- conclusion: String(found.conclusion ?? '').slice(0, 4000),
1342
+ conclusion: clipConclusion(found.conclusion, branch.workspace ?? null),
1299
1343
  note: found.ok ? 'recovered' : found.stopReason,
1300
1344
  })
1301
1345
  continue
@@ -1319,7 +1363,7 @@ export function apply(ctx, config = {}) {
1319
1363
  }
1320
1364
 
1321
1365
  /**
1322
- * **收侦察的结论**(2026-09-11,AUDIT §14-C)。
1366
+ * **收侦察的结论**。
1323
1367
  *
1324
1368
  * 两级,和 `sweepLostExecutors` 同一套:
1325
1369
  * ① 表里已落定的 → 落 `scout/settled`;正常结束且结论非空 → 再落一条 `observation/recorded`(资料面);
@@ -1330,79 +1374,157 @@ export function apply(ctx, config = {}) {
1330
1374
  function sweepScouts(state, sessionId) {
1331
1375
  const mutations = []
1332
1376
  const lines = []
1333
- const emitted = new Set()
1334
- const publish = (scoutId, stepId, ok, conclusion, stopReason) => {
1335
- mutations.push({ t: 'scout/settled', id: scoutId, step: stepId, conclusion: String(conclusion ?? '').slice(0, 4000), note: ok ? null : String(stopReason ?? 'failed') })
1336
- if (ok && String(conclusion ?? '').trim() !== '') {
1377
+ /** 降级形态的送达(见 `noticeBlock`):有原生结算通知时不使用。 */
1378
+ const notices = []
1379
+ /** 派出去了、这一拍还没落定的条数:`AwaitWorldlines` 靠它决定要不要继续等。 */
1380
+ let pending = 0
1381
+ const publish = (scoutId, stepId, ok, conclusion, stopReason, meta = {}) => {
1382
+ const full = String(conclusion ?? '')
1383
+ /**
1384
+ * **全文落盘**。一条事实有三个当事人:下达侦察的模型、独立评估者、人。
1385
+ * 结论只活在折叠出的资料面里时,只有面板读得到;落进 `clear/` 之内,三者都能读。
1386
+ * 账本仍是真值源,这份文件是投影产物。
1387
+ */
1388
+ const path = ok && full.trim() !== '' ? persistMaterial(sessionId, scoutId, meta, full) : null
1389
+ // 账本与观测**同一个上限、同一句截断标记**(超出时必须说清全文在哪)。
1390
+ const clipped = clipConclusion(full, path)
1391
+ mutations.push({ t: 'scout/settled', id: scoutId, step: stepId, conclusion: clipped, note: ok ? null : String(stopReason ?? 'failed'), path })
1392
+ if (ok && full.trim() !== '') {
1337
1393
  mutations.push({
1338
1394
  t: 'observation/recorded',
1339
1395
  id: `m-${Math.random().toString(36).slice(2, 8)}`,
1340
1396
  ref: `scout:${scoutId}`,
1341
1397
  source: 'scout',
1342
1398
  digest: null,
1343
- bytes: String(conclusion).length,
1344
- note: String(conclusion).slice(0, 2000),
1399
+ bytes: full.length,
1400
+ note: clipped,
1401
+ path,
1345
1402
  step: stepId ?? null,
1346
1403
  })
1347
1404
  }
1405
+ if (ok && full.trim() !== '') notices.push({ kind: 'scout', id: scoutId, trigger: meta.trigger ?? null, conclusion: full, path, native: meta.native === true })
1348
1406
  }
1349
- /** 投影里这条侦察收到结论了吗(`scout/settled` 折进去之后 `conclusion` 就不再是 null)。 */
1350
- const landedIn = (scoutId) => {
1351
- const record = (state?.scouts ?? []).find((item) => item.id === scoutId)
1352
- return record !== undefined && record.conclusion !== null && record.conclusion !== undefined
1353
- }
1354
- for (const entry of scoutRuns.values()) {
1355
- // 已经落到账本上了:这条子 run 退休(摘表 + 释放),免得表无限长。
1356
- if (landedIn(entry.scoutId)) {
1357
- scoutRuns.delete(entry.child)
1407
+ /**
1408
+ * **唯一收集通道**:投影里**还没收口**的侦察——不管内存表里有没有它的条目。
1409
+ *
1410
+ * 两条来源,按优先级:
1411
+ * ① 本进程攥着的 handle 已经落定(只有**一次性派遣**才有 `result` promise);
1412
+ * ② **子会话自己的日志**(权威记录,重启之后也还在)。
1413
+ *
1414
+ * 运行时的结算通知**不在这条路上**:它是运行时给**模型**的送达(已经工作了),
1415
+ * 不是账本的依据。把一个面向模型的消息当成账本的承重结构,就是这一串 bug 的病根——
1416
+ * 通知没到/到得晚,账本就整条路失效;而执行者那条路之所以稳,正是因为它只读日志与目录。
1417
+ */
1418
+ for (const scout of state?.scouts ?? []) {
1419
+ if (scout.conclusion !== null && scout.conclusion !== undefined) continue
1420
+ const child = scout.child === null || scout.child === undefined ? null : String(scout.child)
1421
+ if (child === null) continue
1422
+ const entry = scoutRuns.get(child)
1423
+ let settled = entry?.settled ?? null
1424
+ let recovered = false
1425
+ if (settled === null) {
1426
+ const found = recoverFromChildSession(child)
1427
+ if (found !== null) {
1428
+ settled = { ok: found.ok, conclusion: found.conclusion, stopReason: found.stopReason }
1429
+ recovered = true
1430
+ }
1431
+ }
1432
+ // 读不到:还在跑(或这个形态读不了子会话)——留着,别冤枉它。
1433
+ if (settled === null) {
1434
+ if (entry !== undefined) pending += 1
1435
+ continue
1436
+ }
1437
+ // 同一回合里不重发(投影在回合内不前进);跨回合仍不见落地才补发。
1438
+ if (alreadyPublished(sessionId, scout.id)) continue
1439
+ markPublished(sessionId, scout.id)
1440
+ publish(scout.id, scout.step ?? null, settled.ok === true, settled.conclusion, settled.stopReason, {
1441
+ trigger: scout.trigger ?? null,
1442
+ digest: scout.digest ?? null,
1443
+ child,
1444
+ native: entry?.native === true,
1445
+ })
1446
+ lines.push(`侦察 · ${scout.trigger ?? scout.id}:${settled.ok === true ? (recovered ? '从会话日志回收' : '完成') : `未完成(${settled.stopReason ?? 'unknown'})`}`)
1447
+ // 结论已经拿到,handle 没用了:退休(可续跑那一档没有 dispose,子会话按原生语义留着)。
1448
+ if (entry !== undefined) {
1449
+ scoutRuns.delete(child)
1358
1450
  try {
1359
1451
  void entry.run?.dispose?.().catch?.(() => {})
1360
1452
  } catch {
1361
1453
  /* dispose 失败不影响结论 */
1362
1454
  }
1363
- continue
1364
1455
  }
1365
- if (entry.settled === null) continue
1366
- /**
1367
- * **`reported` 不等于「已落账」**(2026-09-11 长测现场:侦察结论发布之后被丢掉,
1368
- * 而条目已删、`reported` 已置位 ⇒ 那条结论永久丢)。判据改成看**投影**:
1369
- * 投影里还没落地,过了重试窗口就**再发一次**。重复发布是安全的——
1370
- * fold 按 id 找记录、覆写同样的字段,不会长出第二条事实。
1371
- */
1372
- // 同一个回合内不重发(投影在回合内不前进,再发也是白发);跨回合仍不见落地才补发。
1373
- if (entry.reported === true && entry.reportedEpoch === collectEpoch(sessionId)) continue
1374
- entry.reported = true
1375
- entry.reportedEpoch = collectEpoch(sessionId)
1376
- entry.reportedAt = Date.now()
1377
- emitted.add(entry.scoutId)
1378
- publish(entry.scoutId, entry.stepId, entry.settled.ok === true, entry.settled.conclusion, entry.settled.stopReason)
1379
- lines.push(`侦察 · ${entry.trigger}:${entry.settled.ok === true ? '完成' : `未完成(${entry.settled.stopReason ?? 'unknown'})`}`)
1380
- }
1381
- // ② 表里没有的:投影里**还没收口**的侦察(`scout/dispatched` 落了,`scout/settled` 没落),
1382
- // 去它自己的会话日志里读结论(进程重启过也收得回来)。
1383
- for (const scout of state?.scouts ?? []) {
1384
- if (scout.conclusion !== null && scout.conclusion !== undefined) continue
1385
- if (emitted.has(scout.id)) continue
1386
- const child = scout.child === null || scout.child === undefined ? null : String(scout.child)
1387
- if (child === null || scoutRuns.has(child)) continue
1456
+ }
1457
+ return { mutations, lines, pending, notices }
1458
+ }
1459
+
1460
+ /**
1461
+ * **失联的侦察**。判据与评估者那条**逐字同构**(`sweepLostAudits`):
1462
+ * · 只看投影里还没收口的;
1463
+ * · 本进程攥着的那几次派遣是活的,**不问目录**;
1464
+ * · 问原生子代理目录:还在跑的不动;
1465
+ * · 目录里不在跑、结论又收不回来 ⇒ 如实落「失联」终局(一句永久「未回灌」是等不到下文的承诺);
1466
+ * · 目录拿不到 ⇒ **不判**(判不出就不编)。
1467
+ * 为什么放回合边界而不是每次收集都判:目录是异步读面,而「这次没捞到」不等于「永远捞不到」。
1468
+ */
1469
+ /**
1470
+ * 子会话日志里**有几条事件**。判不出回 `null`(读面不可用)——**不编**:
1471
+ * 「一片空白」与「读不到」是两回事,前者说明结论永远不会来,后者什么也说明不了。
1472
+ */
1473
+ function childSessionEventCount(childId) {
1474
+ const sessions = ctx.get('sessions')
1475
+ if (sessions === undefined || typeof sessions.get !== 'function') return null
1476
+ try {
1477
+ const session = sessions.get(childId)
1478
+ if (session === null || session === undefined) return 0
1479
+ const events = typeof session.ownEvents === 'function' ? session.ownEvents() : []
1480
+ return Array.isArray(events) ? events.length : 0
1481
+ } catch {
1482
+ return null
1483
+ }
1484
+ }
1485
+
1486
+ async function sweepLostScouts(sessionId, state, justSettled = new Set()) {
1487
+ // `justSettled`:这一拍**刚刚**收上来的(还在同一批 `factMutations` 里,投影尚未前进)。
1488
+ // 不排除它们,就会把刚落定的结论再判成「失联」——自己和自己打架,还把那一条结论写没。
1489
+ const pending = (state?.scouts ?? []).filter(
1490
+ (scout) => (scout.conclusion === null || scout.conclusion === undefined) && typeof scout.child === 'string' && scout.child !== '' && !justSettled.has(scout.id),
1491
+ )
1492
+ if (pending.length === 0) return { mutations: [], lost: 0, lines: [] }
1493
+ const known = new Set([...scoutRuns.values()].map((entry) => String(entry.child ?? '')))
1494
+ const unresolved = pending.filter((scout) => !known.has(String(scout.child)))
1495
+ if (unresolved.length === 0) return { mutations: [], lost: 0, lines: [] }
1496
+ const subagents = ctx.get('subagents')
1497
+ if (subagents === undefined || typeof subagents.listChildren !== 'function') return { mutations: [], lost: 0, lines: [] }
1498
+ let children = []
1499
+ try {
1500
+ children = await subagents.listChildren(sessionId)
1501
+ } catch (error) {
1502
+ ctx.logger?.warn?.(`clearai: 列子代理失败,本次不判侦察失联 ${String(error?.message ?? error).slice(0, 120)}`)
1503
+ return { mutations: [], lost: 0, lines: [] }
1504
+ }
1505
+ const running = new Set((Array.isArray(children) ? children : []).filter((item) => item?.kind === 'child' && item.activity === 'running').map((item) => String(item.id)))
1506
+ const listed = new Set((Array.isArray(children) ? children : []).filter((item) => item?.kind === 'child').map((item) => String(item.id)))
1507
+ const mutations = []
1508
+ const lines = []
1509
+ for (const scout of unresolved) {
1510
+ const child = String(scout.child)
1511
+ if (running.has(child)) continue
1388
1512
  /**
1389
- * 这条是**收集机会**,不是重发:迟到一步的结论全靠它接住(第一版把它按回合限流,
1390
- * 真跑里立刻掉了一条回灌)。所以限流只针对 **I/O**:同一个窗口里对同一条只读一次。
1513
+ * 目录里**没有**这个子会话时,不能立刻判死:可能它刚派出、注册表还没认领。
1514
+ * 退回**它自己的日志**判——一片空白 ⇒ 结论永远不会来了,如实落失联;
1515
+ * 有事件却没有 `turn/end` ⇒ 还在跑,不动它。(目录里**有**它、但不在跑,
1516
+ * 那就是注册表也认它已经结束 ⇒ 直接判失联。)
1391
1517
  */
1392
- const recoveryKey = `${String(sessionId)}:${scout.id}`
1393
- const lastTry = Number(recoverAttempts.get(recoveryKey) ?? 0)
1394
- if (Date.now() - lastTry < CFG.collectRetryMs) continue
1395
- recoverAttempts.set(recoveryKey, Date.now())
1396
- const found = recoverFromChildSession(child)
1397
- if (found === null) continue
1398
- publish(scout.id, scout.step ?? null, found.ok === true, found.conclusion, found.stopReason)
1399
- lines.push(`侦察 · ${scout.trigger ?? scout.id}:从会话日志回收(${found.ok === true ? '完成' : found.stopReason})`)
1518
+ if (!listed.has(child) && childSessionEventCount(child) !== 0) continue
1519
+ mutations.push({ t: 'scout/settled', id: scout.id, step: scout.step ?? null, conclusion: '', note: '失联', path: null })
1520
+ lines.push(`${scout.trigger ?? scout.id}:侦察失联(子会话已不在跑,结论收不回来)`)
1400
1521
  }
1401
- return { mutations, lines }
1522
+ return { mutations, lost: mutations.length, lines }
1402
1523
  }
1403
1524
 
1525
+
1404
1526
  /**
1405
- * **失联的评估者**(2026-09-11,AUDIT §14-D)。
1527
+ * **失联的评估者**。
1406
1528
  *
1407
1529
  * `pendingAudits` 是**进程内**的:重启之后它空了,而投影里那条 `audit/dispatched` 还在
1408
1530
  * (`verdict === null`)。后果有两条,后一条更狠:
@@ -1455,6 +1577,8 @@ export function apply(ctx, config = {}) {
1455
1577
  function sweepWorldlineExecutors(state, sessionId) {
1456
1578
  const mutations = []
1457
1579
  const lines = []
1580
+ /** 本次刚收到的执行者报告(降级形态靠它送达:有原生通知时这一项不被使用)。 */
1581
+ const notices = []
1458
1582
  /** 投影里这条世界线的执行者收到了结论吗(`execution.ok` 不再是 null)。 */
1459
1583
  const landedIn = (forkId, branchId) => {
1460
1584
  const fork = (state?.forks ?? []).find((item) => item.id === forkId)
@@ -1475,31 +1599,41 @@ export function apply(ctx, config = {}) {
1475
1599
  }
1476
1600
  continue
1477
1601
  }
1478
- if (entry.settled === null) {
1479
- lines.push(`${entry.label}:仍在跑`)
1480
- continue
1481
- }
1482
1602
  /**
1483
- * 与侦察同一条纪律:**`reported` 不等于「已落账」**。投影里还没落地,过了重试窗口就再发一次
1484
- * (`worldline/executed` 按分支覆写,重复发布不会长出第二条事实)。
1603
+ * 结算只认两条来源:① 本进程攥着的 handle 已落定(一次性派遣);② 子会话自己的日志。
1604
+ * **结算通知不在这条路上**——它是给模型的送达,不是账本的依据(与侦察同一条纪律)。
1605
+ * 读不到就是还在跑:留着,别冤枉。
1485
1606
  */
1486
- // 与侦察同一条判据:同回合不重发,跨回合仍不见落地才补发。
1487
- if (entry.reported === true && entry.reportedEpoch === collectEpoch(sessionId)) continue
1488
- entry.reported = true
1489
- entry.reportedEpoch = collectEpoch(sessionId)
1490
- entry.reportedAt = Date.now()
1491
- const collected = collectExecutor(entry)
1607
+ let recoveredFromLog = false
1608
+ if (entry.settled === null) {
1609
+ const found = recoverFromChildSession(entry.child)
1610
+ if (found === null) {
1611
+ lines.push(`${entry.label}:仍在跑`)
1612
+ continue
1613
+ }
1614
+ entry.settled = { ok: found.ok, conclusion: found.conclusion, stopReason: found.stopReason }
1615
+ recoveredFromLog = true
1616
+ }
1617
+ // 同一回合里不重发(投影在回合内不前进);跨回合仍不见落地才补发。
1618
+ if (alreadyPublished(sessionId, `${entry.fork}:${entry.branch}`)) continue
1619
+ markPublished(sessionId, `${entry.fork}:${entry.branch}`)
1620
+ const collected = collectExecutor(entry, recoveredFromLog)
1492
1621
  if (collected === null) continue
1493
1622
  mutations.push(collected.mutation)
1494
- lines.push(`${entry.label}:${collected.mutation.ok === true ? '完成' : `未完成(${collected.mutation.note ?? 'unknown'})`}`)
1623
+ lines.push(
1624
+ `${entry.label}:${collected.mutation.ok !== true ? `未完成(${collected.mutation.note ?? 'unknown'})` : recoveredFromLog ? '从会话日志回收' : '完成'}`,
1625
+ )
1626
+ if (collected.mutation.ok === true && String(entry.settled.conclusion ?? '').trim() !== '') {
1627
+ notices.push({ kind: 'worldline', id: entry.branch, trigger: entry.label, conclusion: String(entry.settled.conclusion), path: entry.workspace ?? null, native: entry.native === true })
1628
+ }
1495
1629
  }
1496
- return { mutations, lines }
1630
+ return { mutations, lines, notices }
1497
1631
  }
1498
1632
 
1499
1633
  /**
1500
1634
  * 一条侦察任务的**身份**:锚定的步 + 任务原文(空白归一)。
1501
1635
  *
1502
- * 为什么要它(2026-09-11 用户实测):中途被打断之后再跑,所有侦察**从头重派**——
1636
+ * 为什么要它:没有它,中途被打断之后再跑,所有侦察**从头重派**——
1503
1637
  * 已经正常回灌过的那些也白跑一遍。任务原文相同 = 同一件事,认出来就能复用它的结论。
1504
1638
  * 身份里带锚定的步:同一个问法落在不同的步上,是两件事。
1505
1639
  */
@@ -1565,9 +1699,9 @@ export function apply(ctx, config = {}) {
1565
1699
  return { ok: false, note: `侦察派不出去(${dispatched.reason})`, mutations }
1566
1700
  }
1567
1701
  const childId = String(dispatched.run.id)
1568
- mutations.push({ t: 'scout/dispatched', id: scoutId, step: step.id, plan: plan?.id ?? null, goal: step.goal ?? null, trigger, child: childId, capability: dispatched.capability, digest })
1702
+ mutations.push({ t: 'scout/dispatched', id: scoutId, step: step.id, plan: plan?.id ?? null, goal: step.goal ?? null, trigger, child: childId, capability: dispatched.capability, digest, degraded_reason: dispatched.degraded ?? null })
1569
1703
  /**
1570
- * **派出去就返回**(2026-09-11,AUDIT §14-C:与「世界线执行者」同一个病,同一个修法)。
1704
+ * **派出去就返回**(与「世界线执行者」同一个病,同一个修法)。
1571
1705
  *
1572
1706
  * 原来这里 `await Promise.race([结论, 240s 超时])`,而 `scout/dispatched` 是**随工具结果**
1573
1707
  * 进日志的 —— 于是这 240 秒里跑动一断(用户打断、进程退出、模型换路),「派过侦察」这条事实
@@ -1579,7 +1713,12 @@ export function apply(ctx, config = {}) {
1579
1713
  */
1580
1714
  const entry = { scoutId, stepId: step.id, planId: plan?.id ?? null, trigger, digest, child: childId, settled: null, reported: false, run: dispatched.run }
1581
1715
  scoutRuns.set(childId, entry)
1582
- entry.promise = dispatched.run.result.then(
1716
+ /**
1717
+ * 只有一次性派遣才有 `result` promise。可续跑那一档没有 ⇒ 结算改由
1718
+ * `sweepScouts()` 从**子会话自己的日志**里读(它本来就更权威,也是重启后唯一的路)。
1719
+ */
1720
+ if (dispatched.run?.result !== undefined) {
1721
+ entry.promise = dispatched.run.result.then(
1583
1722
  (value) => {
1584
1723
  const settled = settleSubRun(value)
1585
1724
  entry.settled = { ok: settled.ok, conclusion: settled.conclusion, stopReason: settled.stopReason }
@@ -1590,7 +1729,8 @@ export function apply(ctx, config = {}) {
1590
1729
  entry.settled = { ok: false, conclusion: settled.conclusion, stopReason: settled.stopReason }
1591
1730
  return entry.settled
1592
1731
  },
1593
- )
1732
+ )
1733
+ }
1594
1734
  return { ok: true, pending: true, conclusion: '', note: null, digest, mutations, child: childId, scoutId }
1595
1735
  }
1596
1736
 
@@ -1669,7 +1809,7 @@ export function apply(ctx, config = {}) {
1669
1809
  }
1670
1810
 
1671
1811
  /**
1672
- * 从「依据」里挑出一个**真实存在**的工作区文件(§27b)。
1812
+ * 从「依据」里挑出一个**真实存在**的工作区文件。
1673
1813
  *
1674
1814
  * 依据是模型写的一句话,通常点着某个产物(「lab/roots.csv 的读数」)。这里把候选 token 逐个
1675
1815
  * 落到盘上核一次:存在才算数。**这不是猜,是核** —— 面板上每个可点的东西都必须此刻真的在盘上。
@@ -1688,7 +1828,7 @@ export function apply(ctx, config = {}) {
1688
1828
  }
1689
1829
 
1690
1830
  /**
1691
- * 一条证据的**出处**(§27b):记账那一刻就解析成事实,界面只渲染、不猜。
1831
+ * 一条证据的**出处**:记账那一刻就解析成事实,界面只渲染、不猜。
1692
1832
  *
1693
1833
  * 四类(与面板上的四个入口一一对应):
1694
1834
  * · `artifact` —— 产物文件(准入闸门 stat 过的,或依据里点名且此刻在盘上的)
@@ -1740,14 +1880,14 @@ export function apply(ctx, config = {}) {
1740
1880
  }
1741
1881
 
1742
1882
  /**
1743
- * 「该收口哪个分叉」的目标步(**2026-09-11 补的一处结构洞**)。
1883
+ * 「该收口哪个分叉」的目标步。
1744
1884
  *
1745
1885
  * 洞长这样:`AbandonFork`/`ConvergeFork` 都在 `firstOpenStep` 上取步。步骤一旦被作废,
1746
1886
  * 它就不再是「第一个未落定步」——于是挂在上面的未收口分叉**既不能收敛也不能放弃**,
1747
- * 工作副本永久留在盘上。S3 长测里真的发生了(`lab/` 被声明成物证 → 准入拒 → 模型作废该步改道,
1887
+ * 工作副本永久留在盘上(形态:`lab/` 被声明成物证 → 准入拒 → 模型作废该步改道,
1748
1888
  * 两条世界线就此失联)。
1749
1889
  *
1750
- * 收口的判据(用户 2026-09-11 拍板,走「只落事实 + 放开可达性」那条):
1890
+ * 收口的判据(「只落事实 + 放开可达性」):
1751
1891
  * · 常规:目标步 = 第一个未落定步(不带 step_id 时的默认);
1752
1892
  * · 例外:**已作废**的步,且它上面的分叉还没收口(没收敛、没放弃)——允许显式收口。
1753
1893
  * **只允许放弃**,不允许把成果并进一个已经撤回的承诺(交付必须有归宿)。
@@ -1771,7 +1911,7 @@ export function apply(ctx, config = {}) {
1771
1911
  }
1772
1912
 
1773
1913
  /**
1774
- * **用到世界线的地方先收一次**结论(2026-09-11 长测抓到的缺口)。
1914
+ * **用到世界线的地方先收一次**结论。
1775
1915
  *
1776
1916
  * 原来只有 pre-step 的 sweep 收结论。可一次真跑里整条链(ForkPlan → 四条交付 → 收敛 → 交付
1777
1917
  * → 收尾)可能**全在一个回合内**走完,中间根本没有回合边界——于是三条执行者跑完了、
@@ -1788,7 +1928,7 @@ export function apply(ctx, config = {}) {
1788
1928
  * 而失败的返回(`fail(...)`)不带走 `mutations` —— 在入口处收,一旦这次调用失败,
1789
1929
  * 结论就**永久丢了**(没人会再收它)。放在成功返回前收,失败时它们原封不动留到下一次。
1790
1930
  *
1791
- * 为什么两级一起收(2026-09-11 R3 长测:四条执行者只收上来一条):
1931
+ * 为什么两级一起收(只收一级时,四条执行者只收上来一条):
1792
1932
  * 内存表是**尽力而为**的——表里没有、或者扫的时候它还没落定的那些,答案在**执行者自己的
1793
1933
  * 会话日志**里。所以每个「结果会用到世界线」的返回点都顺手做一次回收:
1794
1934
  * 交付、收敛、收尾、以及两件观察工具。少收一条就是少一条结论叙事,而它是可回收的。
@@ -1796,12 +1936,49 @@ export function apply(ctx, config = {}) {
1796
1936
  function collectExecutors(mutations, state, sessionId) {
1797
1937
  const swept = sweepWorldlineExecutors(state, sessionId)
1798
1938
  mutations.push(...swept.mutations)
1799
- const late = sweepLostExecutors(state)
1939
+ const late = sweepLostExecutors(state, sessionId)
1800
1940
  mutations.push(...late.mutations)
1801
- // 侦察同一套:它也是「派出去就不等」的子 run,结论同样由这里收(§14-C)。
1941
+ // 侦察同一套:它也是「派出去就不等」的子 run,结论同样由这里收。
1802
1942
  const scouts = sweepScouts(state, sessionId)
1803
1943
  mutations.push(...scouts.mutations)
1804
- return { lines: [...swept.lines, ...scouts.lines], recovered: late.recovered, lost: late.lost, scouts: scouts.mutations.length }
1944
+ return {
1945
+ lines: [...swept.lines, ...scouts.lines],
1946
+ recovered: late.recovered,
1947
+ lost: late.lost,
1948
+ scouts: scouts.mutations.length,
1949
+ scoutsPending: scouts.pending,
1950
+ scoutsLost: scouts.lost,
1951
+ // 本次刚收到的结论:有原生通知的那一档由运行时投递,这里只带「没有原生通知」的那些。
1952
+ notices: [...(swept.notices ?? []), ...(scouts.notices ?? [])],
1953
+ }
1954
+ }
1955
+
1956
+ /** 结论正文进消息时的上限(账本、文件、消息三处同一个数;文件里是全文)。 */
1957
+ const CONCLUSION_CHARS = 4000
1958
+
1959
+ /** 正文进消息时的截断标记:不许静默截断——读者要知道自己拿到的是不是全文。 */
1960
+ function clipConclusion(text, path) {
1961
+ const full = String(text ?? '')
1962
+ if (full.length <= CONCLUSION_CHARS) return full
1963
+ return `${full.slice(0, CONCLUSION_CHARS)}\n…已截断(全文 ${full.length} 字${path === null || path === undefined ? '' : `,见 ${path}`})`
1964
+ }
1965
+
1966
+ /**
1967
+ * 「没有原生通知」那一档的送达:把**本次刚收到**的结论正文拼进这次工具返回。
1968
+ *
1969
+ * 有原生通知时**不拼**——运行时已经把它投给模型了,同一段话出现两遍是这个仓库
1970
+ * 一直反对的事。所以这里只挑 `native !== true` 的那些(即降级到一次性派遣的形态)。
1971
+ */
1972
+ function noticeBlock(notices) {
1973
+ const pendingNotices = (notices ?? []).filter((item) => item.native !== true)
1974
+ if (pendingNotices.length === 0) return ''
1975
+ return pendingNotices
1976
+ .map((item) => {
1977
+ const who = item.kind === 'scout' ? '侦察' : '执行者'
1978
+ const head = `【${who}结论 · ${item.id}】${item.trigger === null || item.trigger === undefined ? '' : `${item.trigger}\n`}`
1979
+ return `${head}${clipConclusion(item.conclusion, item.path)}`
1980
+ })
1981
+ .join('\n\n')
1805
1982
  }
1806
1983
 
1807
1984
  /** 开一次调用的上下文:拿宿主读面、取状态、备一个变更列表。 */
@@ -1827,7 +2004,7 @@ export function apply(ctx, config = {}) {
1827
2004
  const CONTINUATION_CODES = { stalled: 'clearai_loop_stalled', abandoned: 'clearai_loop_abandoned' }
1828
2005
 
1829
2006
  /**
1830
- * 计划审阅的两个标签(§19-A):它们是**机制**定义的措辞,不是模型的即兴表达。
2007
+ * 计划审阅的两个标签:它们是**机制**定义的措辞,不是模型的即兴表达。
1831
2008
  * 原生 `plan-review` 意图只要求 `approve` 精确指向本问题自己的某个选项,
1832
2009
  * 所以标签怎么写由我们定——但**必须**与 `intent.approve` 是同一个字面值。
1833
2010
  */
@@ -1835,7 +2012,7 @@ export function apply(ctx, config = {}) {
1835
2012
  const PLAN_REVIEW_REVISE = '先改再交'
1836
2013
 
1837
2014
  /**
1838
- * 内核**自己**对平台说过的关于续跑窗口的话,落进投影(§17.2)。
2015
+ * 内核**自己**对平台说过的关于续跑窗口的话,落进投影。
1839
2016
  *
1840
2017
  * 为什么不再记在进程内存里:宿主的 `paused` 相位分不清「人按的」与「策略按的」,
1841
2018
  * 而这两者的处置正好相反——人按的绝不覆盖,自己按的要能恢复。记在内存里,
@@ -1894,12 +2071,12 @@ export function apply(ctx, config = {}) {
1894
2071
  if (plan !== null && plan.blocked !== undefined) return 'stop'
1895
2072
  // 计划在场但**授权记号未落账**(`PLAN_AWAITING_CONFIRM`):那是等人的一道门,
1896
2073
  // 不是「还有活可干」——推它就是替人做决定(ClearAI 原话:「已经有人在推它了」)。
1897
- // 注意:门开着时**人在场**那一档照样 hold;而无人值守那一档在立约时已自动确认,不会走到这里。
2074
+ // 它与档无关:没有任何一档会替你签这个记号(见 CreatePlan 的确认门)。
1898
2075
  if (plan !== null && plan.status === 'active' && !derived.planIsAuthorized(plan)) return 'hold'
1899
2076
  /**
1900
2077
  * 裁决还没回来(`audit/dispatched` 但未 `audit/settled`)= 机器等待态,与 fold 的派生同源。
1901
2078
  * `auditsResolved` 是「这一拍刚刚判定这些裁决已经失联」:那些事实要到下一拍才折进投影,
1902
- * 所以这一拍必须显式放行,否则一条永远不会回来的裁决会把目标按死在挂起上(§14-D)。
2079
+ * 所以这一拍必须显式放行,否则一条永远不会回来的裁决会把目标按死在挂起上。
1903
2080
  */
1904
2081
  if (auditsResolved !== true && state.audits.some((audit) => audit.verdict === null)) return 'hold'
1905
2082
  // 有一道门开着(收件箱非空)就不驱动:等人的事永远优先于往前跑。
@@ -1909,7 +2086,7 @@ export function apply(ctx, config = {}) {
1909
2086
  // (SetGoal 之后目标一定是开着的——那一拍投影还没前进)。
1910
2087
  const goalOpen = goalOpenOverride ?? (state.goal !== null && state.goal.status === 'open')
1911
2088
  /**
1912
- * 目标还开着、门都关着、也没有在飞的裁决 ⇒ **该继续**(§34)。
2089
+ * 目标还开着、门都关着、也没有在飞的裁决 ⇒ **该继续**。
1913
2090
  * 原先这里按档分叉(无人值守 drive / 人在场 hold)——那是让用户**预先声明**
1914
2091
  * 「请多问我」✗,而"要不要人"这件事已经由**门**表达了:有事要拍板 ⇒ 门开着 ⇒ 上面就 hold 了。
1915
2092
  */
@@ -1917,18 +2094,18 @@ export function apply(ctx, config = {}) {
1917
2094
  return 'hold'
1918
2095
  }
1919
2096
 
1920
- /** 令牌可用性:人在场不布防(那一档由人给下一轮);服务不在就如实说,而不是假装布防了。 */
2097
+ /** 令牌可用性:服务不在就如实说,而不是假装布防了。 */
1921
2098
  function continuationService() {
1922
- // **两档都有窗口**——ClearAI 原话:「每个 root run 都有预算窗口——两个模式的差别是**额度大小**
1923
- // (tier),不是「有没有预算」。对话档窗口小…且每条人类消息都会 reset_goal_loop 换新窗口」。
1924
- // 所以这里不按 autonomy 分叉;两档的差别在档(6 轮 vs 512 轮)与分层策略(见 turnDemand)。
2099
+ // **不按 autonomy 分叉**:要不要继续由 `turnDemand` 从门状态算出来
2100
+ // (有没有门开着、有没有裁决在飞、有没有开着的步),档位在这条路上不参与判断。
2101
+ // 额度也只有一个:`DEFAULT_MAX_AUTO_TURNS`(见布防点)。
1925
2102
  const goals = ctx.get('goals')
1926
2103
  if (goals === undefined || typeof goals.create !== 'function') return { goals: null, why: 'goals 服务不可用' }
1927
2104
  return { goals, why: null }
1928
2105
  }
1929
2106
 
1930
2107
  /**
1931
- * 续跑窗口的**身份**(§17.1):谁在跑 + 哪一档。
2108
+ * 续跑窗口的**身份**:谁在跑 + 哪一档。
1932
2109
  *
1933
2110
  * 为什么不再把目标的主张与判据铺进去:宿主那句 objective 有两个消费者——平台面板(**给人看**)
1934
2111
  * 与驱动器的续跑种子(**给模型看**)。把它写成「用户的目标」,面板上就出现第二个目标,
@@ -1940,14 +2117,14 @@ export function apply(ctx, config = {}) {
1940
2117
  * 这条也是「目标修订不重置预算」的机制保证——否则反复修订目标就能刷出无限轮数。
1941
2118
  */
1942
2119
  /**
1943
- * 窗口身份里那一档怎么写(§18.2):**用人话**,与工具行那颗控制同一套词。
2120
+ * 窗口身份里那一档怎么写:**用人话**,与工具行那颗控制同一套词。
1944
2121
  *
1945
2122
  * 为什么不是「人在场 / 无人值守」:那两个词是**机制**的词汇,给模型和写文档的人用
1946
2123
  * (运行态卡、提示词、AUDIT 里继续用它们)。而窗口身份是印在**人看的那块面板**上的
1947
- * ——实测里同一屏上出现「人在场」与「多问我」两个名字指同一件事,人只会更糊涂。
2124
+ * ——同一屏上出现「人在场」与「多问我」两个名字指同一件事,人只会更糊涂。
1948
2125
  * 两处的字面值由两侧的测试各钉一遍(内核这一份 + 客户端那一份),漂移会当场红。
1949
2126
  */
1950
- /** 窗口身份里**不再写档位**(§34):「多问我 / 自己跑」不是用户的配置,是运行时状态。 */
2127
+ /** 窗口身份里**不写档位**:「多问我 / 自己跑」不是用户的配置,是运行时状态。 */
1951
2128
  /** 一句话摘要:压平空白、超长截断(平台上那句给人看的话用它;内核里没有客户端的 `brief`)。 */
1952
2129
  function clip(text, max) {
1953
2130
  const flat = String(text ?? '').replace(/\s+/g, ' ').trim()
@@ -1960,7 +2137,7 @@ export function apply(ctx, config = {}) {
1960
2137
  return '继续把手上这一步做完'
1961
2138
  }
1962
2139
  /**
1963
- * 窗口上那句**给人看的话**(§17.1 的第二次修正,2026-09-12 实测)。
2140
+ * 窗口上那句**给人看的话**。
1964
2141
  *
1965
2142
  * 原来这里是 `ClearAI 续跑窗口 · 目标 g-mtyirobr2y2l`:一个机制词加一串机器 id。
1966
2143
  * 而这句是**印在平台面板上、给人看**的 —— 人该看到"在做什么",不是我们在内部怎么称呼它。
@@ -1990,7 +2167,7 @@ export function apply(ctx, config = {}) {
1990
2167
  function armContinuation(agent, state, derived, options = {}) {
1991
2168
  const { goals, why } = continuationService()
1992
2169
  /**
1993
- * 窗口不在时,把这句**事实**的后果也说清楚(R3 长测:一次性形态根本没有窗口,
2170
+ * 窗口不在时,把这句**事实**的后果也说清楚(一次性形态根本没有窗口,
1994
2171
  * 而提示词里「让出本轮等唤醒」那句在那种场合是不成立的)。
1995
2172
  * 只说事实与含义,不劝:**回合结束之后没有人会叫醒你** —— 这一句就够了。
1996
2173
  */
@@ -2012,7 +2189,7 @@ export function apply(ctx, config = {}) {
2012
2189
  const current = continuationView(agent)
2013
2190
  if (current === null) {
2014
2191
  /**
2015
- * **它不在了,而那不是我们干的**(§17.2)。我们自己每次清除都在同一次调用里
2192
+ * **它不在了,而那不是我们干的**。我们自己每次清除都在同一次调用里
2016
2193
  * 立刻建回一枚(见下面 freshWindow 与 blocked/complete 两支),所以「我们记过一枚
2017
2194
  * 活着的窗口、此刻它不在」在证据上只可能是外部清的——平台的人在面板上按了清空。
2018
2195
  * 那就**不重建**:人的动作即刻为真,我们只落一条账、并如实说明后果。
@@ -2041,18 +2218,18 @@ export function apply(ctx, config = {}) {
2041
2218
  }
2042
2219
  /**
2043
2220
  * 窗口活着(active + armed),而且账也对得上 → **一个字都不说、一次写都不做**。
2044
- * 这里以前还有一条 `edit` 对齐分支(文本随目标修订而改写);§17.1 之后窗口的身份
2045
- * 只由 (服务对象, 档位) 构成,**身份一变就是换一枚窗口**,所以那条路整个不需要了:
2221
+ * 文本随目标修订而改写的 `edit` 对齐分支已删:身份一变
2222
+ * 就是换一枚窗口,所以那条路整个不需要了:
2046
2223
  * 目标修订不再动窗口 ⇒ 预算也不会被反复修订刷掉。
2047
2224
  */
2048
2225
  /**
2049
- * **身份变了就换窗口**(§17.6 补上 §17.1 写下却没实现的那半句):
2050
- * 窗口的身份 = (服务对象, 档位),它同时也是**授权**——换档就是换一份授权,
2051
- * 额度得跟着走(人在场 6 轮 / 无人值守 512 轮)。而旧实现靠 `edit` 去对齐轮数,
2052
- * 那条路在 §17.1 删掉了,于是换档会把窗口锁在旧档的额度上——这是个真缺口。
2226
+ * **额度变了就换窗口**:
2227
+ * 窗口的额度就是**授权**,额度变了就是换一份授权(额度只有一个默认值,
2228
+ * 但 `maxAutoTurns` 仍可由人显式配置)。旧实现靠 `edit` 去对齐轮数,
2229
+ * 不在这里换窗口,额度变化会把窗口锁在旧额度上。
2053
2230
  *
2054
- * 怎么分清「身份变了」与「人改写过了」:看**平台上的文本还是不是我们记下的那句**。
2055
- * 是我们的 ⇒ 身份可以换;不是我们的 ⇒ 以人为准,一个字都不动(§17.2)。
2231
+ * 怎么分清「额度变了」与「人改写过了」:看**平台上的文本还是不是我们记下的那句**。
2232
+ * 是我们的 ⇒ 可以按额度换;不是我们的 ⇒ 以人为准,一个字都不动。
2056
2233
  */
2057
2234
  const ours = before !== null && before.label !== null && before.label !== undefined && before.label === current.objective
2058
2235
  // 额度变了 = 授权变了 ⇒ 换一枚新窗口(清 + 建,按宿主契约)。
@@ -2091,8 +2268,9 @@ export function apply(ctx, config = {}) {
2091
2268
  }
2092
2269
 
2093
2270
  /**
2094
- * 窗口的说明文字:别让人以为「人在场就没有窗口」(两档都有窗口,只是额度不同)。
2095
- * 上限由调用方现算的 maxGoalRounds 给——**它取决于当档**,而档是人可以在面板上切的。
2271
+ * 窗口的说明文字:别让人以为「没有窗口」——每个会话都有窗口,只是额度不同。
2272
+ * 上限由调用方现算的 maxGoalRounds 给:它是 `maxAutoTurns` 或默认值(128),
2273
+ * **不随任何运行档变化**(档位连面板入口都没有了)。
2096
2274
  */
2097
2275
  function windowLabel(maxGoalRounds) {
2098
2276
  return `${maxGoalRounds} 轮自动续跑,一条人类消息换一个新窗口`
@@ -2126,7 +2304,7 @@ export function apply(ctx, config = {}) {
2126
2304
  : '计划层已经收尾而目标还开着,下一阶段由人给。人一开口就换新窗口。'
2127
2305
  try {
2128
2306
  continuationService().goals.pause(agent, { id: current.id, revision: current.revision })
2129
- // 「这次暂停是我们按的」必须落账:宿主的 paused 相位分不清人按的与策略按的(§17.2)。
2307
+ // 「这次暂停是我们按的」必须落账:宿主的 paused 相位分不清人按的与策略按的。
2130
2308
  recordContinuation(mutations, state.continuation ?? null, { state: 'paused', goal: current.id, target: state.continuation?.target ?? null, why: reason })
2131
2309
  return `\n(这一档暂时停着:${why})`
2132
2310
  } catch (error) {
@@ -2136,7 +2314,7 @@ export function apply(ctx, config = {}) {
2136
2314
 
2137
2315
  /**
2138
2316
  * 让窗口服从策略。判定顺序就是 ClearAI 的语义顺序:
2139
- * ⓪ 人清掉过续跑 → 撤回一直有效,直到人再开口(§17.2:人的动作不被静默撤销);
2317
+ * ⓪ 人清掉过续跑 → 撤回一直有效,直到人再开口(人的动作不被静默撤销);
2140
2318
  * ① **人开口 = 重新授权**:无条件换新窗口(`reset_goal_loop` 连 budget_exhausted 一起清);
2141
2319
  * ② 窗口已经停着(blocked / complete / 人按的 paused)→ 如实说,不自动重开
2142
2320
  * ——自动重开一个「额度用尽」的窗口,等于把预算机制废掉;
@@ -2150,7 +2328,7 @@ export function apply(ctx, config = {}) {
2150
2328
  const planWork = plan !== null && plan.steps.some((step) => step.status === 'open')
2151
2329
  const goalOpen = options.goalOpen ?? (state.goal !== null && state.goal.status === 'open')
2152
2330
  const hasWork = goalOpen || planWork
2153
- // §34:档位已经是**部署预设的初值**,不随回合变化 ⇒ 这里没有"当档"要优先。
2331
+ // 档位已经是**部署预设的初值**,不随回合变化 ⇒ 这里没有"当档"要优先。
2154
2332
  const demand = turnDemand(state, derived, options.goalOpen ?? goalOpen, options.auditsResolved === true)
2155
2333
  const { goals, why } = continuationService()
2156
2334
  const current = continuationView(agent)
@@ -2195,7 +2373,7 @@ export function apply(ctx, config = {}) {
2195
2373
  if (plan !== null && plan.status === 'active' && !derived.planIsAuthorized(plan)) return 'plan_confirm'
2196
2374
  if (derived.hasOpenGate) return 'gate'
2197
2375
  /**
2198
- * §34:原来这里还有个 `goal_boundary`(「人在场时,一个阶段收尾就停下等人」)——
2376
+ * 这里曾有个 `goal_boundary`(「人在场时,一个阶段收尾就停下等人」)——
2199
2377
  * 那是**档位**的表达,档删了它也就不该存在。上面三条是**唯一**能让窗口停下的理由
2200
2378
  * (都是"真的有人的事"),所以走到这里说明判据与我理解的不一致 ⇒ 如实报未知 + 告警,
2201
2379
  * 而不是编一个好听的理由(理由那句话是要给模型读的,不许说假话)。
@@ -2228,22 +2406,23 @@ export function apply(ctx, config = {}) {
2228
2406
  }
2229
2407
 
2230
2408
  /**
2231
- * §34 **删掉了「当档从输入读」这整套**(`autonomyFromMessages` / `turnAutonomy` / `autonomyForTurn`):
2409
+ * **「当档从输入读」这整套已删**(`autonomyFromMessages` / `turnAutonomy` / `autonomyForTurn`):
2232
2410
  * 它服务的唯一动词是已摘掉的 `set_autonomy` ✗。档位现在只是**部署预设的初值**,
2233
2411
  * 不随回合变化 ⇒ 没有"这一拍按哪一档跑"这个问题,也就没有瞬时参数要传。
2234
2412
  */
2235
2413
 
2236
2414
 
2237
2415
  /**
2238
- * 请人**审阅计划**(§19-A):走原生 `ctx.userQuestions.ask` 的 `plan-review` 意图。
2416
+ * 请人**审阅计划**:走原生 `ctx.userQuestions.ask` 的 `plan-review` 意图。
2239
2417
  *
2240
2418
  * 为什么要借这一条:客户端为这个意图做了**专门的整屏审阅**(计划 markdown 由原生渲染),
2241
- * 批准/继续改的标签由机制定义,答案**程序化回到调用方**——于是「人在场那一档要不要开工」
2242
- * 从**提示词劝告**(模型记得问)变成**机制阻断**(系统自己问)。而授权记号仍然由我们落账:
2419
+ * 批准/继续改的标签由机制定义,答案**程序化回到调用方**——于是「计划要人审」从
2420
+ * **提示词劝告**(模型记得问)变成**机制动作**(系统自己问)。而授权记号仍然由我们落账:
2243
2421
  * **借界面,不借账。**
2244
2422
  *
2245
- * 四种结局只有一种放行:approved / declined(带回人的反馈)/ cancelled(他改为先说话)
2246
- * / unavailable(这个形态没有审阅通道)。后三种一律**不放行**——宁可停着等人,也不擅自开工。
2423
+ * 四种结局:approved(落 by='user')/ declined(带回人的反馈)/ cancelled(他改为先说话)
2424
+ * / unavailable(这个形态没有审阅通道)。后三种**不落授权记号**——记号是归属,不是闸门:
2425
+ * 未授权的唯一后果是自动续跑 hold;显式推进不被阻止,第一次交付会按事实补写 by='progress'。
2247
2426
  */
2248
2427
  async function requestPlanReview(agent, planMarkdown, signal) {
2249
2428
  const questions = ctx.get('userQuestions')
@@ -2281,7 +2460,7 @@ export function apply(ctx, config = {}) {
2281
2460
  * 请人审阅**已经立起来的**计划,并按结果落授权记号。
2282
2461
  *
2283
2462
  * 为什么必须有这条路:`CreatePlan` 会请人审阅,但**改完不会再请** —— 而审阅卡上那句
2284
- * 「改完再呈一次」正是我们承诺的。2026-09-12 实测的死胡同:人在审阅里选了「先改再交」,
2463
+ * 「改完再呈一次」正是我们承诺的。死胡同的形态:人在审阅里选了「先改再交」,
2285
2464
  * 模型照意见改了计划,然后**没有任何入口**能再呈一次 ⇒ 计划永远停在未授权,
2286
2465
  * 而内核又如实拒绝开工。**打不开的门比没有门更糟**:它把机制变成死胡同。
2287
2466
  *
@@ -2305,7 +2484,7 @@ export function apply(ctx, config = {}) {
2305
2484
  }
2306
2485
  if (review.outcome === 'declined') return { confirmed: false, note: `\n人又一次选择**先改再交**${review.note === '' ? '' : `,他的意见:${review.note}`}——仍未授权,按意见再改。` }
2307
2486
  if (review.outcome === 'cancelled') return { confirmed: false, note: '\n人把审阅撤下、改为先说话:仍未授权,等他的下一步指令。' }
2308
- return { confirmed: false, note: `\n(这份计划还没有得到人的授权:${review.note}。**不要开工**——如实停下等人。)` }
2487
+ return { confirmed: false, note: `\n(这份计划还没有得到人的授权:${review.note}。系统不会自动续跑它;你显式推进时,第一次交付会按事实记下归属。想再请人审,用 RequestPlanReview 重呈。)` }
2309
2488
  }
2310
2489
 
2311
2490
  /** 给人审阅的计划正文(markdown)。原生审阅界面渲染它,所以它得是人读得懂的一份计划。 */
@@ -2325,7 +2504,7 @@ export function apply(ctx, config = {}) {
2325
2504
  const ontologyShelved = new Set()
2326
2505
 
2327
2506
  /**
2328
- * **事实货架**(§23):把已升格的事实汇成 `clear/knowledge/facts/INDEX.md`。
2507
+ * **事实货架**:把已升格的事实汇成 `clear/knowledge/facts/INDEX.md`。
2329
2508
  *
2330
2509
  * 为什么必须有它:在它之前,事实只落进 `clear/knowledge/facts/<目标 id>.md`
2331
2510
  * —— **没有任何读者**(模型不知道有哪些事实、更不知道文件名按目标 id 拼)。
@@ -2373,7 +2552,7 @@ export function apply(ctx, config = {}) {
2373
2552
  }
2374
2553
 
2375
2554
  /**
2376
- * **本体货架**(§22):把已装的那份本体落成 `clear/ontology/<id>.md`。
2555
+ * **本体货架**:把已装的那份本体落成 `clear/ontology/<id>.md`。
2377
2556
  *
2378
2557
  * 为什么落成文件而不是只留在代码里:声明是**给模型读的**——它得知道这套系统认哪些对象、
2379
2558
  * 哪些转移合法、每一级谁来判,才能在写判据与交付时对得上。与 ClearAI 那一侧的
@@ -2482,8 +2661,10 @@ export function apply(ctx, config = {}) {
2482
2661
  if (typeof hypothesis?.claim !== 'string' || hypothesis.claim.trim() === '') return fail('hypothesis_claim_required', '每条假设要有一句话主张。')
2483
2662
  if (typeof hypothesis?.refute_when !== 'string' || hypothesis.refute_when.trim() === '') return fail('hypothesis_refute_required', '每条假设必须写清「什么结果会推翻它」——没有推翻条件的假设无法被检验。')
2484
2663
  }
2485
- if (CFG.minHypotheses > 0 && state.goal === null && hypotheses.length > 0 && hypotheses.length < CFG.minHypotheses) {
2486
- return fail('hypotheses_too_few', `至少登记 ${CFG.minHypotheses} 条候选假设。`)
2664
+ // 假设数量下限:首次立目标就得带够候选——0 条一样拦(候选对比是检验的前提,
2665
+ // 只有一个猜想时「验证」容易退化成找证据支持自己)。修订不受此限。
2666
+ if (CFG.minHypotheses > 0 && state.goal === null && hypotheses.length < CFG.minHypotheses) {
2667
+ return fail('hypotheses_too_few', `至少登记 ${CFG.minHypotheses} 条候选假设(每条:一句话主张 + 一句推翻条件)。只有一个猜想,检验容易退化成找证据支持自己;候选对比才让「推翻」成为可能。`)
2487
2668
  }
2488
2669
  const isRevision = state.goal !== null && state.goal.status === 'open'
2489
2670
  if (isRevision && (typeof args.reason !== 'string' || args.reason.trim() === '')) {
@@ -2524,7 +2705,7 @@ export function apply(ctx, config = {}) {
2524
2705
  exec.signal,
2525
2706
  )
2526
2707
  mutations.push(...scout.mutations)
2527
- // 侦察是「派出去就不等」的子 run(§14-C):结论由 sweep 收,进资料面。
2708
+ // 侦察是「派出去就不等」的子 run:结论由 sweep 收,进资料面。
2528
2709
  scoutNote =
2529
2710
  scout.pending === true
2530
2711
  ? '\n立约前侦察已派出(只读),结论会作为观测回灌到资料面 —— 下一步卡片的「资料面」里能看到;要在这个回合里就等它,用 AwaitWorldlines。'
@@ -2579,12 +2760,12 @@ export function apply(ctx, config = {}) {
2579
2760
  }
2580
2761
  const plan = activePlanOf(state)
2581
2762
  /**
2582
- * §38 **目标结案前先把计划收尾**(严格,不是提示)。
2763
+ * **目标结案前先把计划收尾**(严格,不是提示)。
2583
2764
  *
2584
2765
  * 为什么不让跳过:事实是在收尾那条路上沉淀的(`fact/promoted` 就在本工具里,
2585
2766
  * 而它按 `derived.hypotheses` 逐条升格)—— 先结目标、留一份 active 的计划,
2586
2767
  * 等于在账上留下一个开着的东西,还绕过了"沉淀"这道动作 ✗。
2587
- * 真长测实测到过这个组合:目标 achieved 而计划 active、`fact/promoted: 0`。
2768
+ * 真跑里出现过这个组合:目标 achieved 而计划 active、`fact/promoted: 0`。
2588
2769
  *
2589
2770
  * 放弃(`abandoned`)走的是另一条路,**不**受此限:如实说清阻塞就收兵,别为难人 ✓。
2590
2771
  */
@@ -2614,7 +2795,7 @@ export function apply(ctx, config = {}) {
2614
2795
  if (audit.verdict === 'pending') return fail('audit_pending', `目标评估者仍在跑:${audit.basis}。先观察当前事实,再谈重试。`)
2615
2796
  if (audit.verdict !== 'support') {
2616
2797
  /**
2617
- * 目标级裁决也要带得出出处(§27b):那条审计自己写了一张卡、也有它的评估者会话。
2798
+ * 目标级裁决也要带得出出处:那条审计自己写了一张卡、也有它的评估者会话。
2618
2799
  * 这一处原先 `refs: []` ⇒ 面板上这条证据一个可点的东西都没有 ✗。
2619
2800
  */
2620
2801
  const goalOrigin = buildEvidenceOrigins({
@@ -2646,7 +2827,17 @@ export function apply(ctx, config = {}) {
2646
2827
  { mutations },
2647
2828
  )
2648
2829
  }
2649
- mutations.push({ t: 'goal/closed', id: goal.id, status: 'achieved', verdict: 'support', note: args.note ?? null })
2830
+ /**
2831
+ * **没被任何证据触及的假设**,结案时如实记一笔。
2832
+ *
2833
+ * 两种「没结论」要分得开:证据说「无法判定」= 现有信息不足以定论(已经在账上);
2834
+ * 三样全零 = **没人碰过它**。不强制证实/证伪——但「没看过」不能被写成「没问题」,
2835
+ * 所以这里把它记进结案那条变更里,卡片与面板都说得出来。
2836
+ */
2837
+ const untouched = derived.hypotheses.filter(
2838
+ (hypothesis) => (hypothesis.supportedLevel === null || hypothesis.supportedLevel === undefined) && (hypothesis.refutations ?? 0) === 0 && (hypothesis.inconclusive ?? 0) === 0,
2839
+ )
2840
+ mutations.push({ t: 'goal/closed', id: goal.id, status: 'achieved', verdict: 'support', note: args.note ?? null, unjudged: untouched.map((hypothesis) => hypothesis.id) })
2650
2841
  const continuationNote = stopContinuation(exec.agent, null, '目标达成', mutations)
2651
2842
  const threshold = levelIndexOf(goal.promote_at_level)
2652
2843
  const promoted = []
@@ -2679,12 +2870,52 @@ export function apply(ctx, config = {}) {
2679
2870
  message:
2680
2871
  `目标 ${goal.id} 已达成(独立评估者裁决:${audit.basis})。` +
2681
2872
  (promoted.length > 0 ? `\n升格为事实:${promoted.join(' / ')}(写入 clear/knowledge/facts/${goal.id}.md)` : '\n没有达到升格门槛的假设。') +
2873
+ (untouched.length > 0
2874
+ ? `\n结案时有 ${untouched.length} 条假设**没有被任何证据触及**:${untouched.map((hypothesis) => hypothesis.id).join(', ')}——未判的假设不是「没问题」,是「没看过」;它们留在账上,随时可以补一次验证。`
2875
+ : '') +
2682
2876
  '\n被推翻与被改版的假设保留在日志里。' +
2683
2877
  continuationNote,
2684
2878
  })
2685
2879
  },
2686
2880
  })
2687
2881
 
2882
+ /**
2883
+ * 把一份侦察结论**全文**落成工作区文件(`clear/` 之内,账本仍是真值源,这是投影产物)。
2884
+ *
2885
+ * 为什么是文件而不是只留在账本里:一条事实有三个当事人——下达侦察的模型、独立评估者、
2886
+ * 人。只放在会话日志折叠出的资料面里,只有面板读得到;判据一旦写成「与侦察结论一致」,
2887
+ * 模型与评估者都无处可读,只能裁 inconclusive。落成文件之后三者读的是同一份。
2888
+ * 失败只 warn(照 `persistFact` 的做法):投递仍走消息,只是少了那份可读副本。
2889
+ */
2890
+ function persistMaterial(sessionId, scoutId, meta, conclusion) {
2891
+ const file = join(sessionCwd(sessionId), 'clear', 'knowledge', 'materials', `${scoutId}.md`)
2892
+ try {
2893
+ writeTextFile(
2894
+ file,
2895
+ [
2896
+ `# 侦察结论 · ${scoutId}`,
2897
+ '',
2898
+ `- 触发:${String(meta.trigger ?? '(未记)')}`,
2899
+ `- 锚在哪一步:${String(meta.stepId ?? meta.step ?? '(未记)')}`,
2900
+ meta.child === undefined ? null : `- 子会话:${String(meta.child)}`,
2901
+ meta.digest === undefined ? null : `- 任务指纹:${String(meta.digest)}`,
2902
+ `- 收到时间:${new Date().toISOString()}`,
2903
+ '',
2904
+ '> 这份文件由系统按账本落盘(投影产物);真值源是会话日志里的 `scout/settled`。',
2905
+ '',
2906
+ String(conclusion),
2907
+ '',
2908
+ ]
2909
+ .filter((line) => line !== null)
2910
+ .join('\n'),
2911
+ )
2912
+ return file
2913
+ } catch (error) {
2914
+ ctx.logger?.warn?.(`clearai kernel: 侦察结论落盘失败 ${String(error?.message ?? error)}`)
2915
+ return null
2916
+ }
2917
+ }
2918
+
2688
2919
  function persistFact(sessionId, goal, hypothesis, factId) {
2689
2920
  const file = join(sessionCwd(sessionId), 'clear', 'knowledge', 'facts', `${goal.id}.md`)
2690
2921
  try {
@@ -2754,39 +2985,32 @@ export function apply(ctx, config = {}) {
2754
2985
  const goal = state.goal !== null && state.goal.status === 'open' ? state.goal : null
2755
2986
  const planId = uniqueId('p')
2756
2987
  /**
2757
- * 计划确认门:
2758
- * 「选择 Goal 模式就是用户的**无人值守执行授权**」——所以无人值守那一档在立约时
2759
- * 就把授权记号落下,不再造一道人工确认门又在第二次状态迁移里补掉它。
2760
- * 人在场那一档**不**自动确认:记号等两条通道之一(人显式确认 / 交付一步按事实补写)。
2761
- */
2762
- // 「立约即授权」按**本回合**的当档判(人刚在面板上切过档的那一轮尤其要准,§17.5)。
2763
- /**
2764
- * **计划永远要人确认**(§34):原先无人值守那一档「立约即授权」✗——
2765
- * 那会让「计划经人确认」这条证据变成**系统自己签的**,和 L4「人放行」是同一类病。
2766
- * 门的意义就在"这一下是人按的":没有它,后面所有基于授权的推理都是空的。
2988
+ * 计划确认门:**永远请人审阅,没有任何一档自动确认**。
2989
+ *
2990
+ * 如果让系统替人签「计划经人确认」,这条证据就是**系统自己签的**,
2991
+ * 和 L4「人放行」是同一类病。门的意义就在"这一下是人按的":
2992
+ * 没有它,后面所有基于授权的推理都是空的。
2993
+ *
2994
+ * 走**原生审阅**:由**我们**在计划立起来的这一步请人审阅,计划正文交给原生界面渲染。
2995
+ * 批准 ⇒ 授权记号现在就落(`by:'user'`);其余三种结局 ⇒ 记号不落。这一条把
2996
+ * 「模型记得问才有一道门」换成了「机制自己问」——机制优于劝告。
2997
+ *
2998
+ * 注意记号**不是闸门**:未授权只让自动续跑 `hold`(见 turnDemand),`AdvancePlan`
2999
+ * 照常执行,并在同一条变更里补写 `by:'progress'`(行为即授权,见 AdvancePlan)。
2767
3000
  */
2768
- const autoConfirmed = false
2769
3001
  const brief = typeof args.brief === 'string' ? args.brief : ''
2770
- /**
2771
- * §19-A 人在场那一档的确认门走**原生审阅**(以前是提示词让模型自己去问):
2772
- * 由**我们**在计划立起来的这一步请人审阅,计划正文交给原生界面渲染。
2773
- * 批准 ⇒ 授权记号现在就落(by: 'user');其余三种结局 ⇒ 一个字都不落,如实停下等人。
2774
- * 这一条把「模型记得问才有一道门」换成了「机制自己问」——P1(机制优于劝告)。
2775
- */
2776
- let confirmed = autoConfirmed
3002
+ const review = await requestPlanReview(exec.agent, renderPlanForReview(planId, brief, goal, args.steps), exec.signal)
3003
+ let confirmed = false
2777
3004
  let reviewNote = ''
2778
- if (!autoConfirmed) {
2779
- const review = await requestPlanReview(exec.agent, renderPlanForReview(planId, brief, goal, args.steps), exec.signal)
2780
- if (review.outcome === 'approved') {
2781
- confirmed = true
2782
- reviewNote = '\n人在审阅里**批准**了这份计划(原生审阅卡),授权记号已落账——开始执行。'
2783
- } else if (review.outcome === 'declined') {
2784
- reviewNote = `\n人在审阅里选择**先改再交**${review.note === '' ? '' : `,他的意见:${review.note}`}——计划仍未授权,按他的意见改完再呈一次,不要开工。`
2785
- } else if (review.outcome === 'cancelled') {
2786
- reviewNote = '\n人把审阅撤下、改为先说话:计划仍未授权,**不要开工**,等他的下一步指令。'
2787
- } else {
2788
- reviewNote = `\n(这份计划还没有得到人的授权:${review.note}。**不要开工**——如实停下等人,别自己去问一遍(系统已经问过了)。)`
2789
- }
3005
+ if (review.outcome === 'approved') {
3006
+ confirmed = true
3007
+ reviewNote = '\n人在审阅里**批准**了这份计划(原生审阅卡),授权记号已落账——开始执行。'
3008
+ } else if (review.outcome === 'declined') {
3009
+ reviewNote = `\n人在审阅里选择**先改再交**${review.note === '' ? '' : `,他的意见:${review.note}`}——计划仍未授权,按他的意见改完再呈一次。`
3010
+ } else if (review.outcome === 'cancelled') {
3011
+ reviewNote = '\n人把审阅撤下、改为先说话:计划仍未授权,等他的下一步指令。'
3012
+ } else {
3013
+ reviewNote = `\n(这份计划还没有得到人的授权:${review.note}。系统不会自动续跑它;你显式推进时,第一次交付会按事实记下归属。想再请人审,用 RequestPlanReview 重呈。)`
2790
3014
  }
2791
3015
  mutations.push({
2792
3016
  t: 'plan/created',
@@ -2795,7 +3019,7 @@ export function apply(ctx, config = {}) {
2795
3019
  phase_id: goal?.id ?? null,
2796
3020
  brief,
2797
3021
  confirmed_at: confirmed ? new Date().toISOString() : null,
2798
- confirmed_by: confirmed ? (autoConfirmed ? 'autonomy' : 'user') : null,
3022
+ confirmed_by: confirmed ? 'user' : null,
2799
3023
  steps: args.steps.map((step) => ({ id: step.id, do: step.do, artifacts: step.artifacts ?? [], done_criteria: step.done_criteria, tests: step.tests ?? null })),
2800
3024
  })
2801
3025
  return done({
@@ -2923,7 +3147,7 @@ export function apply(ctx, config = {}) {
2923
3147
  if (typeof args.reason !== 'string' || args.reason.trim() === '') return fail('reason_required', '作废必须带原因。')
2924
3148
  mutations.push({ t: 'plan/voided', plan: plan.id, step: step.id, reason: args.reason.trim() })
2925
3149
  /**
2926
- * 作废**不动**分叉(2026-09-11 拍板):作废是承诺层的权威动作,它不改变尝试层已经发生的事实
3150
+ * 作废**不动**分叉:作废是承诺层的权威动作,它不改变尝试层已经发生的事实
2927
3151
  * ——那些世界线探索过、有的还出了读数。把它们改写成「已放弃」就是改写历史。
2928
3152
  * 但也不能装作没看见:这一步上要是还挂着没收口的分叉,那是一条**事实**,
2929
3153
  * 而且它有唯一一个出口(带 step_id 的 AbandonFork)。所以如实说,不说教。
@@ -2959,13 +3183,14 @@ export function apply(ctx, config = {}) {
2959
3183
  if (unsettled.length > 0) {
2960
3184
  return fail('plan_has_open_steps', `还有 ${unsettled.length} 步没落定:${unsettled.map((step) => step.id).join(', ')}。交付它们,或带因作废(VoidPlanStep)。`)
2961
3185
  }
2962
- collectExecutors(mutations, hostService.state(sessionId), sessionId)
3186
+ // 收尾也要把**结论正文**带给模型:这一拍收上来的东西,丢弃返回值就等于白收。
3187
+ const collected = collectExecutors(mutations, hostService.state(sessionId), sessionId)
2963
3188
  mutations.push({ t: 'plan/closed', plan: plan.id, summary: args.summary ?? null })
2964
3189
  persistArchive(sessionId, plan, args.summary ?? null)
2965
3190
  return done({
2966
3191
  ok: true,
2967
3192
  code: 'plan_closed',
2968
- message: `计划 ${plan.id} 已收束归档(clear/goals/plans/${plan.id}.md)。${state.goal === null || state.goal.status !== 'open' ? '' : `目标 ${state.goal.id} 仍未结案,继续开下一阶段。`}`,
3193
+ message: `计划 ${plan.id} 已收束归档(clear/goals/plans/${plan.id}.md)。${state.goal === null || state.goal.status !== 'open' ? '' : `目标 ${state.goal.id} 仍未结案,继续开下一阶段。`}${noticeBlock(collected.notices) === '' ? '' : `\n\n${noticeBlock(collected.notices)}`}`,
2969
3194
  })
2970
3195
  },
2971
3196
  })
@@ -3124,7 +3349,7 @@ export function apply(ctx, config = {}) {
3124
3349
  if (audit.verdict === 'refute') {
3125
3350
  const scout = await runScout(sessionId, exec.agent, plan, step, audit.shortfalls.join('; '), `audit_shortfall:${audit.shortfalls[0] ?? '未指明'}`, exec.signal)
3126
3351
  mutations.push(...scout.mutations)
3127
- // 派出去就不等(§14-C):结论会在下一个回合边界回灌到资料面。
3352
+ // 派出去就不等:结论会在下一个回合边界回灌到资料面。
3128
3353
  if (scout.pending === true) basis = `${basis}\n[已派出只读侦察补缺口 ${scout.scoutId}:结论会作为观测回灌到资料面,不在这次回执里]`
3129
3354
  }
3130
3355
  } else {
@@ -3140,12 +3365,12 @@ export function apply(ctx, config = {}) {
3140
3365
  }
3141
3366
 
3142
3367
  /**
3143
- * §19-B **人的放行先落账**(在别的门之前)。
3368
+ * **人的放行先落账**(在别的门之前)。
3144
3369
  *
3145
3370
  * 放行是**人的动作**,它成立与否与这次交付后来过不过得了别的门无关:
3146
- * · 发起者仍是 `tools/pre-execute` 瀑布(§14-B,在工具跑起来之前就把这次交付摆给人看);
3371
+ * · 发起者仍是 `tools/pre-execute` 瀑布(在工具跑起来之前就把这次交付摆给人看);
3147
3372
  * · 但旧写法把 `human/released` 留在第 ⑦ 步(交付成功之后)——于是「人放行了,可这一交付
3148
- * 栽在来源分离/判据门上」时,那条事实随失败一起消失,**同一步重试又问人一遍**(真跑现场)。
3373
+ * 栽在来源分离/判据门上」时,那条事实随失败一起消失,**同一步重试又问人一遍**。
3149
3374
  * 所以:一知道人放行过,就把这条事实落下来;失败时它跟着回去(见下面的 `refuse`)。
3150
3375
  */
3151
3376
  const releaseTarget = levelIndex === 4 && CFG.l4RequiresHumanRelease ? l4Delivery(state, 'AdvancePlan', args) : null
@@ -3169,7 +3394,7 @@ export function apply(ctx, config = {}) {
3169
3394
  }
3170
3395
 
3171
3396
  /**
3172
- * ⑥ L4 人放行:**只读**审批栈的权威记录,不推断(2026-09-11,AUDIT §14-B)。
3397
+ * ⑥ L4 人放行:**只读**审批栈的权威记录,不推断。
3173
3398
  * 发起者是 `tools/pre-execute` 瀑布;这里只认那条记录——`human/released`
3174
3399
  * 已经在上面(别的门之前)落过账,所以这里不写第二遍。
3175
3400
  */
@@ -3183,7 +3408,7 @@ export function apply(ctx, config = {}) {
3183
3408
 
3184
3409
  // ⑦ 写证据 + 推进(只增不改)
3185
3410
  /**
3186
- * **出处在这一刻定下来**(§27b):四类入口(产物 / 评估卡 / 评估者子会话 / 人放行)
3411
+ * **出处在这一刻定下来**:四类入口(产物 / 评估卡 / 评估者子会话 / 人放行)
3187
3412
  * 全部解析成事实写进账里,界面只渲染、不猜。
3188
3413
  */
3189
3414
  const originInfo = buildEvidenceOrigins({
@@ -3252,7 +3477,7 @@ export function apply(ctx, config = {}) {
3252
3477
 
3253
3478
  // ═══ git 世界线层:世界线 = 分支 + worktree ═══════════════════════════════
3254
3479
  //
3255
- // 探针实测换来三条硬事实(见 tools/spike-git-worldlines.mjs):
3480
+ // 探针换来三条硬事实(见 tools/spike-git-worldlines.mjs):
3256
3481
  // · worktree 隔离完美,落选后 branch ref 永久可读(P5 的落点);
3257
3482
  // · **主线脏且与世界线改动重叠时 git 直接拒绝合并** → 先把用户手上的快照落成一条提交,再合并;
3258
3483
  // · **落选世界线里还有未提交的活时 git 拒绝删 worktree** → 先提交到分支,再删工作副本。
@@ -3334,7 +3559,7 @@ export function apply(ctx, config = {}) {
3334
3559
  /**
3335
3560
  * 基线的提交信息可以**由调用方给**:账本是懒建的,如果第一次建立就发生在某一步交付那一刻,
3336
3561
  * 那么这一笔既是基线、也是那一步的账 —— 信息必须写成那一步,否则那一步就"没有自己的提交"了
3337
- * (实测:CI 上基线把第一步的内容吞掉,历史里那一步只剩一条匿名基线)。
3562
+ * (失效模式:基线把第一步的内容吞掉,历史里那一步只剩一条匿名基线)。
3338
3563
  */
3339
3564
  const committed = added.ok ? gitAt(context, [...GIT_IDENTITY, 'commit', '-qm', baselineMessage ?? 'clearai:旁路账本基线(工作区当时的全部内容)']) : added
3340
3565
  if (!committed.ok && !/nothing to commit|无文件要提交|working tree clean/i.test(`${committed.out}${committed.err}`)) {
@@ -3353,7 +3578,7 @@ export function apply(ctx, config = {}) {
3353
3578
  // ── 账本(D3/D4:账本 = git,与世界线共用一本) ─────────────────────────────
3354
3579
  //
3355
3580
  // ClearAI 的账本是**每次写入**都记一笔(带 turn/tool 归属),我们记在**交付点**:
3356
- // 一次提交 = 一次「这一步交付时工作区长什么样」。这是刻意的偏离(见 DESIGN §6):
3581
+ // 一次提交 = 一次「这一步交付时工作区长什么样」。这是刻意的偏离:
3357
3582
  // 每次写入的归属在 DSH 里属于宿主的 fs 领域,而交付点归属是内核真正知道的事实。
3358
3583
  // 两条性质照抄不变:**只前进**(恢复 = 新版本 + 新提交)与**留下来源**(提交信息写步 id)。
3359
3584
 
@@ -3413,11 +3638,11 @@ export function apply(ctx, config = {}) {
3413
3638
  /**
3414
3639
  * 世界线工作副本的容器:**工作区里**(`<workspace>/clear/worldlines/`)。
3415
3640
  *
3416
- * 2026-09-11 改的位置,原因是**宿主沙箱的硬边界**:它只允许写「会话自己的 cwd」,
3641
+ * 位置由**宿主沙箱的硬边界**决定:它只允许写「会话自己的 cwd」,
3417
3642
  * 而原生子代理**继承父会话的 cwd**(`dsh-subagent` 的 `childSessionMeta` 写死
3418
3643
  * `cwd: parentHeader.cwd`,`SubagentStartRequest` 没有 cwd 字段,沙箱也没有「额外可写根」的缝)。
3419
3644
  * 于是原设计那个位置(数据区、jail 之外)会让每个执行者**写不进自己的交付物**:
3420
- * 实测报 `[sandbox: file access denied under workspace-write mode]`,只能申请提权——而人只会拒绝。
3645
+ * 沙箱报 `[sandbox: file access denied under workspace-write mode]`,只能申请提权——而人只会拒绝。
3421
3646
  *
3422
3647
  * 前提是:「**分支 run 的根就是它自己的 worktree**」,
3423
3648
  * 所以它写自己是「jail 之内」。DSH 给不了子会话自己的根 ⇒ 位置必须让位于沙箱边界。
@@ -3425,7 +3650,7 @@ export function apply(ctx, config = {}) {
3425
3650
  * 为什么放 `clear/` 而不是 `.clearai/`:这个产品对用户有一句明确承诺——「系统只在你的文件夹里
3426
3651
  * 加 `clear/` 一个目录」。放 `clear/worldlines/` 完全在那句承诺之内,而且照样在沙箱可写区里。
3427
3652
  *
3428
- * 代价(三条,都记在 AUDIT §15):① 要往账本仓库(工作区本身是 git 仓库时是它的)写
3653
+ * 代价(三条):① 要往账本仓库(工作区本身是 git 仓库时是它的)写
3429
3654
  * `.git/info/exclude`,否则每次交付提交会把各条世界线的文件一起提进主线历史;
3430
3655
  * ② 「只准写自己那条」从此由**我们的路径守卫**承担(沙箱只保证「在工作区内」);
3431
3656
  * ③ 备份/同步会同时看到两份(与旧的放置方式同样的成本)。
@@ -3468,7 +3693,7 @@ export function apply(ctx, config = {}) {
3468
3693
  }
3469
3694
 
3470
3695
  /**
3471
- * **盘上残留的读数**(2026-09-11 R3-c):截断之后,上一轮的世界线工作副本与分支还躺在盘上,
3696
+ * **盘上残留的读数**:截断之后,上一轮的世界线工作副本与分支还躺在盘上,
3472
3697
  * 而**新会话的投影是从零开始的**——树上看不到、卡片不提,连「它们存在」这件事都没人报。
3473
3698
  *
3474
3699
  * 「什么都不删」(P5)不该等于「什么都看不见」:残留是一条**事实**,该像目录、当档一样
@@ -3981,7 +4206,7 @@ export function apply(ctx, config = {}) {
3981
4206
  if (typeof observation?.ref !== 'string' || observation.ref.trim() === '') continue
3982
4207
  const ref = observation.ref.trim()
3983
4208
  /**
3984
- * 相对路径按**这条世界线自己的工作副本**解析(2026-09-11 修)。
4209
+ * 相对路径按**这条世界线自己的工作副本**解析。
3985
4210
  *
3986
4211
  * 原来按主线 `cwd` 解析,于是「世界线的观测」永远落在主线路径上,再被下面那条
3987
4212
  * 隔离检查拒掉——用户那次真跑里 `AdvanceWorldline` 连续两次报
@@ -4028,7 +4253,7 @@ export function apply(ctx, config = {}) {
4028
4253
 
4029
4254
  const levelIndex = levelIndexOf(branch.level)
4030
4255
  /**
4031
- * **L4 的两道门,这条路也要走**(2026-09-11,AUDIT §14-A)。
4256
+ * **L4 的两道门,这条路也要走**。
4032
4257
  *
4033
4258
  * 等级是**事实的属性**,不是工具的属性:同一个 L4,走主线要人放行 + 只认外部来源,
4034
4259
  * 走世界线两样都不要——那是门挂错了轴。这里与 `AdvancePlan` 用**同一个**判定
@@ -4102,7 +4327,7 @@ export function apply(ctx, config = {}) {
4102
4327
  }
4103
4328
  if (validity === null && reading !== null) validity = metricReading(reading) === null ? 'unusable' : 'usable'
4104
4329
  /**
4105
- * 世界线证据的出处同样在**记账那一刻**定下来(§27b)。世界线在**自己的工作副本**里作业,
4330
+ * 世界线证据的出处同样在**记账那一刻**定下来。世界线在**自己的工作副本**里作业,
4106
4331
  * 所以 `cwd` 就是那份副本 —— 依据里点名的产物按副本核存在性。
4107
4332
  */
4108
4333
  const originInfo = buildEvidenceOrigins({
@@ -4124,7 +4349,7 @@ export function apply(ctx, config = {}) {
4124
4349
  level: branch.level,
4125
4350
  evaluator,
4126
4351
  basis,
4127
- /** `refs` 一律是**路径**:旧写法只写材料 id ⇒ 世界线证据一条都开不了(实测)。 */
4352
+ /** `refs` 一律是**路径**:只写材料 id ⇒ 世界线证据一条都开不了。 */
4128
4353
  refs: originInfo.paths,
4129
4354
  origins: originInfo.origins,
4130
4355
  anchor: evaluator === 'independent' ? 'auditor' : 'artifact',
@@ -4147,7 +4372,8 @@ export function apply(ctx, config = {}) {
4147
4372
  })
4148
4373
  const parsed = metricReading(reading)
4149
4374
  // 交付成功前收一次执行者结论:这条世界线的「执行者跑成没跑成」该跟它的读数一起落账。
4150
- collectExecutors(mutations, hostService.state(sessionId), sessionId)
4375
+ // 返回值不再丢弃:本次收到的结论正文要跟着这次交付一起给模型。
4376
+ const collectedOnDeliver = collectExecutors(mutations, hostService.state(sessionId), sessionId)
4151
4377
  return done({
4152
4378
  ok: true,
4153
4379
  code: 'worldline_delivered',
@@ -4155,7 +4381,8 @@ export function apply(ctx, config = {}) {
4155
4381
  evaluator,
4156
4382
  message:
4157
4383
  `世界线「${branch.label}」已交付(裁决 ${verdict}${evaluator === 'independent' ? ' · 独立评估者读数' : ' · 自判'})。` +
4158
- `读数:${reading ?? '未报'}${parsed === null ? '(读不出一个数 → 不参赛)' : ` → ${parsed}`};有效性:${validity ?? '未声明'}。\n胜负由 ConvergeFork 的算术决定。`,
4384
+ `读数:${reading ?? '未报'}${parsed === null ? '(读不出一个数 → 不参赛)' : ` → ${parsed}`};有效性:${validity ?? '未声明'}。\n胜负由 ConvergeFork 的算术决定。` +
4385
+ (noticeBlock(collectedOnDeliver.notices) === '' ? '' : `\n\n${noticeBlock(collectedOnDeliver.notices)}`),
4159
4386
  })
4160
4387
  },
4161
4388
  })
@@ -4246,7 +4473,7 @@ export function apply(ctx, config = {}) {
4246
4473
  /**
4247
4474
  * 采纳 = 合并,但**只在那个 git 对象今天真的还在的时候**。
4248
4475
  *
4249
- * 2026-09-12 的实测(案例 A):分叉落账时物化过,后来账本被清理,分支与工作副本一起没了
4476
+ * 形态:分叉落账时物化过,后来账本被清理,分支与工作副本一起没了
4250
4477
  * ——于是 `fork.git_branch` 还写着名字,合并却必然失败,一份计划被一个**机制上不可能收敛**
4251
4478
  * 的分叉卡住(agent 只能改走 AbandonFork)。
4252
4479
  *
@@ -4321,7 +4548,7 @@ export function apply(ctx, config = {}) {
4321
4548
  }
4322
4549
  }
4323
4550
  mutations.push({ t: 'fork/converged', fork: fork.id, winner: outcome.winner, margin: outcome.margin, tie: outcome.tie, metric: outcome.metric, direction: outcome.direction })
4324
- collectExecutors(mutations, hostService.state(sessionId), sessionId)
4551
+ const collectedOnConverge = collectExecutors(mutations, hostService.state(sessionId), sessionId)
4325
4552
  return done({
4326
4553
  ok: true,
4327
4554
  code: 'fork_converged',
@@ -4330,7 +4557,8 @@ export function apply(ctx, config = {}) {
4330
4557
  `算术裁决:采纳「${winner?.label ?? outcome.winner}」(${outcome.metric} ${outcome.direction === 'min' ? '越小越好' : '越大越好'}),` +
4331
4558
  `差额 ${outcome.margin}${outcome.tie ? ' · 并列(指标说一样好是买到的信息)' : ''}。\n` +
4332
4559
  `读数:${outcome.readings.map((row) => `${row.label}=${row.value}`).join('、')}\n` +
4333
- `落选的世界线标为未采纳,**留档不删**。${mergeNote}\n下一步:把采纳那条的产物落进步骤 ${step.id} 声明的位置,再用 AdvancePlan 交付这一步。`,
4560
+ `落选的世界线标为未采纳,**留档不删**。${mergeNote}\n下一步:把采纳那条的产物落进步骤 ${step.id} 声明的位置,再用 AdvancePlan 交付这一步。` +
4561
+ (noticeBlock(collectedOnConverge.notices) === '' ? '' : `\n\n${noticeBlock(collectedOnConverge.notices)}`),
4334
4562
  })
4335
4563
  },
4336
4564
  })
@@ -4366,11 +4594,11 @@ export function apply(ctx, config = {}) {
4366
4594
  return done({
4367
4595
  ok: true,
4368
4596
  code: 'scout_reported',
4369
- message: `这件事**上一次已经正常回灌过**(同一个任务原文,身份 ${scout.digest}),直接复用它的结论——没有再派一次。要真重跑,把任务原文改一个字。\n\n${String(scout.conclusion ?? '').slice(0, 2000)}`,
4597
+ message: `这件事**上一次已经正常回灌过**(同一个任务原文,身份 ${scout.digest}),直接复用它的结论——没有再派一次。要真重跑,把任务原文改一个字。\n\n${clipConclusion(scout.conclusion, null)}`,
4370
4598
  })
4371
4599
  }
4372
4600
  /**
4373
- * **派出去就不等**(2026-09-11,§14-C):结论走事实通道,不挤在工具结果里——
4601
+ * **派出去就不等**:结论走事实通道,不挤在工具结果里——
4374
4602
  * 工具结果是「这一动作做完了」的收据,不是「世界发生了什么」的记录。
4375
4603
  */
4376
4604
  return done({
@@ -4456,6 +4684,7 @@ export function apply(ctx, config = {}) {
4456
4684
  const lines = swept.lines.map((item) => `· ${item}`)
4457
4685
  if (swept.recovered > 0) lines.push(`· ${swept.recovered} 条结论是从执行者自己的会话日志里回收的`)
4458
4686
  if (swept.lost > 0) lines.push(`· ${swept.lost} 条失联(产物还在各自的工作副本里)`)
4687
+ if (noticeBlock(swept.notices) !== '') lines.push('', noticeBlock(swept.notices))
4459
4688
  if (lines.length === 0) lines.push('· 没有在跑的世界线执行者')
4460
4689
  return done({ ok: true, code: 'worldline_status', message: lines.join('\n') })
4461
4690
  },
@@ -4464,7 +4693,7 @@ export function apply(ctx, config = {}) {
4464
4693
  defineTool({
4465
4694
  name: 'AwaitWorldlines',
4466
4695
  description:
4467
- '**有界地等**在跑的世界线执行者(最长 300 秒),谁的结论回来了就顺手落账。它是「等」这件事的**唯一正当姿势**:换成 bash `sleep` 轮询会把一整个回合的时间烧在空等上,而且结论什么时候回来与你睡多久无关。等待期间不会阻塞别的会话;到点就返回,没回来的照样在跑。',
4696
+ '**有界地等**派出去还没回来的子 run——世界线执行者**与侦察**都算(最长 300 秒),谁的结论回来了就顺手落账。它是「等」这件事的**唯一正当姿势**:换成 bash `sleep` 轮询会把一整个回合的时间烧在空等上,而且结论什么时候回来与你睡多久无关。等待期间不会阻塞别的会话;到点就返回,没回来的照样在跑。',
4468
4697
  parameters: {
4469
4698
  type: 'object',
4470
4699
  properties: {
@@ -4484,16 +4713,31 @@ export function apply(ctx, config = {}) {
4484
4713
  const started = Date.now()
4485
4714
  let collected = 0
4486
4715
  let lines = []
4716
+ /**
4717
+ * 等到的**结论正文**要攒着:摘要行每一拍都重建,正文必须跨拍累积,
4718
+ * 否则「等到的那一拍」过去之后,模型只剩一句「回灌 1 条结论」。
4719
+ */
4720
+ const notices = []
4487
4721
  const pending = () => {
4488
4722
  const before = mutations.length
4489
4723
  const swept = collectExecutors(mutations, hostService.state(sessionId), sessionId)
4490
- // 这一拍到底收上来几条:两级收集(内存表 + 会话日志)都算数。
4491
- collected += mutations.length - before
4724
+ /**
4725
+ * 「回灌几条」数的是**结论**,不是变更条数:一条侦察会落两条变更
4726
+ * (`scout/settled` + 观测),一条世界线落一条 `worldline/executed`。
4727
+ * 模型读这句话是判断「它回来了没有」——数变更会报出比事实大的数字
4728
+ * (一条侦察报成两条),那是这一层最不该有的假话。
4729
+ */
4730
+ collected += mutations.slice(before).filter((mutation) => mutation.t === 'scout/settled' || mutation.t === 'worldline/executed').length
4492
4731
  lines = swept.lines.map((item) => `· ${item}`)
4493
4732
  if (swept.recovered > 0) lines.push(`· ${swept.recovered} 条结论是从执行者自己的会话日志里回收的`)
4494
4733
  if (swept.lost > 0) lines.push(`· ${swept.lost} 条失联(产物还在各自的工作副本里)`)
4495
- // 「还在跑」只认内存表里真有、且还没落定的那些(回收与失联都不算在跑)。
4496
- return swept.lines.filter((line) => line.endsWith('仍在跑')).length
4734
+ notices.push(...(swept.notices ?? []))
4735
+ /**
4736
+ * 「还在跑」= 世界线执行者(内存表里真有、还没落定的那些)**加上**侦察。
4737
+ * 少了侦察这一票,只有侦察在跑时这个循环第一拍就退出,
4738
+ * 而 SpawnScout 的返回原话恰恰让模型「用 AwaitWorldlines 在这个回合里等它」。
4739
+ */
4740
+ return swept.lines.filter((line) => line.endsWith('仍在跑')).length + (swept.scoutsPending ?? 0)
4497
4741
  }
4498
4742
  /**
4499
4743
  * 等:每 2 秒看一次表。**不 busy-wait**——每次都是一个真定时器,
@@ -4522,11 +4766,15 @@ export function apply(ctx, config = {}) {
4522
4766
  const recovered = lines.filter((line) => line.includes('从执行者自己的会话日志里回收')).length
4523
4767
  const lostLines = lines.filter((line) => line.includes('条失联'))
4524
4768
  const head =
4525
- `等了 ${waited}s(上限 ${wait}s):回灌 ${collected} 条` +
4769
+ `等了 ${waited}s(上限 ${wait}s):回灌 ${collected} 条结论` +
4526
4770
  (waited >= wait && running > 0 ? `,**还有 ${running} 条仍在跑**(到点返回,不是失败:它们跑的是真活,可以再来等一次,或先干别的)` : '') +
4527
4771
  (recovered > 0 ? `;其中 ${recovered} 条是从执行者自己的会话日志里回收的` : '') +
4528
4772
  (lostLines.length > 0 ? `;${lostLines.join('、')}` : '')
4529
- return done({ ok: true, code: 'worldlines_awaited', message: `${head}\n${(doneLines.length > 0 ? doneLines : lines).join('\n')}` })
4773
+ return done({
4774
+ ok: true,
4775
+ code: 'worldlines_awaited',
4776
+ message: `${head}\n${(doneLines.length > 0 ? doneLines : lines).join('\n')}${noticeBlock(notices) === '' ? '' : `\n\n${noticeBlock(notices)}`}`,
4777
+ })
4530
4778
  },
4531
4779
  })
4532
4780
 
@@ -4687,7 +4935,7 @@ export function apply(ctx, config = {}) {
4687
4935
  /**
4688
4936
  * ═══ 项目章程(``PROJECT.md``):把「它还是不是一个空壳」变成一条可查的事实 ═══
4689
4937
  *
4690
- * 为什么需要它(2026-09-11 用户实测):跑完五轮、交付了 HTML、记忆写了两份,
4938
+ * 为什么需要它:没有它,跑完几轮、交付了产物、记忆写了两份,
4691
4939
  * `PROJECT.md` 还是铺工作区那一刻的模板——18 处占位原封不动,谁也没发现。
4692
4940
  * 根因不是模型懒:章程的**读**侧已经是最好的(原生指令文件每步重投影),但**写**侧
4693
4941
  * 只有一句提示词、没有任何时机,而且**没有任何地方让模型或人看见「它还是空壳」**。
@@ -4701,9 +4949,9 @@ export function apply(ctx, config = {}) {
4701
4949
  /**
4702
4950
  * 章程的读数——**只有文件系统事实**:在不在、多大、最后改动什么时候。
4703
4951
  *
4704
- * 2026-09-11 砍掉了这里原来那份「占位 X/Y 条 + §1 当前阶段解析」(用户一问点醒:
4705
- * 「要有计数吗」)。三条理由,第一条是实测的:
4706
- * · **脆**:它是对文本做格式解析——模型把 §5 写成 `- **变更记录**(一行即…)`,
4952
+ * 这里原来那份「占位 X/Y 条 + 当前阶段解析」已砍掉(
4953
+ * 「要有计数吗」)。三条理由:
4954
+ * · **脆**:它是对文本做格式解析——模型把章程第 5 节写成 `- **变更记录**(一行即…)`,
4707
4955
  * 要求 `**标签**:` 的规则当场把条目数从 18 数成 17。改一个标点,「事实」就变了;
4708
4956
  * · **可刷**:「占位 0/18」可以靠删占位、灌废话达到——奖励的是把数字清掉,不是把章程写实;
4709
4957
  * · **第二本账**:章程每回合被**原生指令文件整份注入**上下文,模型手里就是原文。
@@ -4775,7 +5023,7 @@ export function apply(ctx, config = {}) {
4775
5023
  // 正文文件 = 技能目录下的 `SKILL.md`(原生 filesystem provider 自己的约定:
4776
5024
  // `path = join(dir,'SKILL.md')`、`resourceBase.path = 技能目录`)。
4777
5025
  // 记忆那条是**虚拟条目**(根本没有正文文件),所以它是 null——面板据此不画链接,
4778
- // 而不是让人点进去撞一个 404(2026-09-11 实测的「点技能跳 not found」)。
5026
+ // 而不是让人点进去撞一个 404。
4779
5027
  file: dir === null || String(skill?.source ?? '') === 'clearai-memory' ? null : join(dir, 'SKILL.md'),
4780
5028
  // 面板读正文走的是**工作区内**的读面(路径守卫),所以「在不在工作区里」这条
4781
5029
  // 事实由知道 cwd 的这一侧算好发下去,免得面板去猜。
@@ -4804,7 +5052,7 @@ export function apply(ctx, config = {}) {
4804
5052
  return CFG.autonomy
4805
5053
  }
4806
5054
  /**
4807
- * **本体形状**下发(§23):面板那一格页眉要从**声明**生成,不能在界面里手抄一份
5055
+ * **本体形状**下发:面板那一格页眉要从**声明**生成,不能在界面里手抄一份
4808
5056
  * (抄一份就会漂移——而漂移的界面比没有界面更坏)。所以把形状当事实发下去,
4809
5057
  * 与目录/当档同一条纪律:**变了才发**;它一个会话只发一次(本体在进程内是常量)。
4810
5058
  */
@@ -5127,7 +5375,7 @@ export function apply(ctx, config = {}) {
5127
5375
  }
5128
5376
 
5129
5377
  /**
5130
- * **人放行的权威记录**(2026-09-11,AUDIT §14-B)。
5378
+ * **人放行的权威记录**。
5131
5379
  *
5132
5380
  * 原生审批栈在**会话日志**里落了一对事件:`approval/asked{id,toolName,callId}` +
5133
5381
  * `approval/decided{id,outcome}`。`allowed-once` 是唯一的放行,而审批策略只有 `ask`/`never`
@@ -5165,7 +5413,7 @@ export function apply(ctx, config = {}) {
5165
5413
  }
5166
5414
 
5167
5415
  /**
5168
- * L4 的那道门挂在**等级**上,不挂在工具名上(2026-09-11,AUDIT §14-A)。
5416
+ * L4 的那道门挂在**等级**上,不挂在工具名上。
5169
5417
  *
5170
5418
  * 同一个 `level: 'L4'`,主线按**步骤**等级、世界线按**分支**等级——两条交付路径必须是同一道门。
5171
5419
  * 改之前:`l4RequiresHumanRelease` 与 `l4RejectSelfWritten` 只写在 `AdvancePlan` 里,
@@ -5255,7 +5503,7 @@ export function apply(ctx, config = {}) {
5255
5503
  return { kind: 'deny', reason: `clear/evidence、clear/knowledge/facts、clear/goals 由系统所有,做的人不能写:${suspect}。事实与评估卡只能由系统落盘。` }
5256
5504
  }
5257
5505
  // L4 人放行:ClearAI 的设计目标未实现,这里用宿主的审批瀑布实现。
5258
- // 门挂在**等级**上:主线看步骤等级,世界线看分支等级——两条路同一道门(AUDIT §14-A)。
5506
+ // 门挂在**等级**上:主线看步骤等级,世界线看分支等级——两条路同一道门。
5259
5507
  {
5260
5508
  const target = CFG.l4RequiresHumanRelease ? l4Delivery(state, exec.name, args) : null
5261
5509
  if (target !== null && !target.released) {
@@ -5294,7 +5542,7 @@ export function apply(ctx, config = {}) {
5294
5542
  * 技能目录是宿主原生那张**合并目录**的一部分,而原生 `tool-skill` 的 pre-step 会在我们前面
5295
5543
  * 先取一次快照(`cwd + 作用域 + revision` 做键的缓存)。等轮到自己才铺,那次快照就把**空表**
5296
5544
  * 缓存住了——后果不只是面板没内容:模型自己也一直看不到 `clear/skills` 里的 18 条模板技能,
5297
- * 人引用 `/技能名` 也注入不出正文(2026-09-11 的 E2E 实测)。
5545
+ * 人引用 `/技能名` 也注入不出正文。
5298
5546
  * pre-step 里仍然保留一次调用:铺之前就存在的会话、或事件没送到的情况,兜底。
5299
5547
  *
5300
5548
  * `cwdHint` 只信**调用方给的** cwd:`agent/created` 那一刻会话服务里可能还查不到这个会话,
@@ -5345,7 +5593,7 @@ export function apply(ctx, config = {}) {
5345
5593
  const humanSpoke = entering.some((message) => message?.source?.kind === 'user')
5346
5594
  const autoRound = entering.find((message) => message?.source?.kind === 'goal') ?? null
5347
5595
  /**
5348
- * **本回合的当档从输入读**(§17.5):人门消息先入 inbox、后落日志,而这一拍跑在它落账之前,
5596
+ * **本回合的当档从输入读**:人门消息先入 inbox、后落日志,而这一拍跑在它落账之前,
5349
5597
  * 所以这里读投影必然是旧档——而这一轮恰恰是人的动作触发的那一轮。
5350
5598
  */
5351
5599
  noteCollectTurn(sessionId, payload.turn)
@@ -5360,7 +5608,7 @@ export function apply(ctx, config = {}) {
5360
5608
  /**
5361
5609
  * 本体货架:每个会话铺一次;第一拍在卡里指一下它(只说一次——它是**常驻事实**,
5362
5610
  * 每拍重复就是往上下文里灌水)。模型据此知道「这套系统认哪些对象、每级谁来判」,
5363
- * 于是写判据与交付时对得上本体(§22)。
5611
+ * 于是写判据与交付时对得上本体。
5364
5612
  */
5365
5613
  let ontologyNote = ''
5366
5614
  if (CONTRIB.ontology !== null && CONTRIB.ontology !== undefined && !ontologyShelved.has(sessionId)) {
@@ -5410,7 +5658,7 @@ export function apply(ctx, config = {}) {
5410
5658
  if (autonomyPayload !== null && brainNote === '') brainNote = `\n(运行档:${autonomyPayload.value === 'unattended' ? '无人值守' : '人在场'}。)`
5411
5659
 
5412
5660
  /**
5413
- * **世界线结论的回灌**(2026-09-11):`ForkPlan` 现在把执行者放出去就返回(事实先落账),
5661
+ * **世界线结论的回灌**:`ForkPlan` 把执行者放出去就返回(事实先落账),
5414
5662
  * 结论由这里收——每个回合扫一次已落定的执行者,把 `worldline/executed` 当作**事实**注入。
5415
5663
  *
5416
5664
  * 为什么走「插件消息的 section」而不是等模型来问:结论是**世界发生的事**,该像目录、
@@ -5431,11 +5679,16 @@ export function apply(ctx, config = {}) {
5431
5679
  const done = swept.lines.filter((line) => !line.endsWith('仍在跑'))
5432
5680
  if (done.length > 0) brainNote = `${brainNote}\n(世界线执行者已回灌:${done.join('、')}——结论已进投影,逐条用 AdvanceWorldline 交付读数。)`
5433
5681
  }
5682
+ /**
5683
+ * 这一拍收上来的结论,**没有原生通知的那一档**要把正文直接放进注记:
5684
+ * 它是「没有工具调用却收到结论」的唯一路径,不带正文的话,这条结论就只剩计数了。
5685
+ */
5686
+ if (noticeBlock(swept.notices) !== '') brainNote = `${brainNote}\n\n${noticeBlock(swept.notices)}`
5434
5687
  } catch (error) {
5435
5688
  ctx.logger?.warn?.(`clearai: 世界线结论回灌失败 ${String(error?.message ?? error).slice(0, 160)}`)
5436
5689
  }
5437
5690
  /**
5438
- * 失联的评估者(§14-D):重启之后 `pendingAudits` 空了,而投影里那条裁决还停在「在跑」——
5691
+ * 失联的评估者:重启之后 `pendingAudits` 空了,而投影里那条裁决还停在「在跑」——
5439
5692
  * 它会把目标按在 hold 上。这里问一次宿主的子代理目录,把「它已经不在跑」如实落成一条事实。
5440
5693
  */
5441
5694
  let auditsResolved = false
@@ -5450,7 +5703,22 @@ export function apply(ctx, config = {}) {
5450
5703
  ctx.logger?.warn?.(`clearai: 失联裁决盘点失败 ${String(error?.message ?? error).slice(0, 160)}`)
5451
5704
  }
5452
5705
  /**
5453
- * **盘上残留**(2026-09-11 R3-c):投影从零开始,可盘上的上一轮还在。
5706
+ * **失联的侦察**:同一拍、同一套判据(原生子代理目录 + 本进程攥着的派遣)。
5707
+ * 一句永久「未回灌」在卡片上就是一句等不到下文的承诺。
5708
+ */
5709
+ try {
5710
+ // 本拍刚落定的侦察不算失联(它们的 `scout/settled` 还在这一批变更里,投影没前进)。
5711
+ const settledInBatch = new Set(factMutations.filter((mutation) => mutation.t === 'scout/settled').map((mutation) => mutation.id))
5712
+ const scouts = await sweepLostScouts(sessionId, hostService.state(sessionId), settledInBatch)
5713
+ if (scouts.mutations.length > 0) {
5714
+ factMutations.push(...scouts.mutations)
5715
+ brainNote = `${brainNote}\n(有 ${scouts.lost} 条侦察**失联**(子会话已不在跑):已如实记下「结论收不回来」——不必再等它,需要那份材料就重新派一次。)`
5716
+ }
5717
+ } catch (error) {
5718
+ ctx.logger?.warn?.(`clearai: 失联侦察盘点失败 ${String(error?.message ?? error).slice(0, 160)}`)
5719
+ }
5720
+ /**
5721
+ * **盘上残留**:投影从零开始,可盘上的上一轮还在。
5454
5722
  * 每个会话扫一次(签名 = 工作目录 + 当前这盘的分叉 id 列表,变了才重扫),
5455
5723
  * 有残留就如实报一条读数——不落状态、不新增变更类型。
5456
5724
  */
@@ -5458,7 +5726,7 @@ export function apply(ctx, config = {}) {
5458
5726
  const cwd = sessionCwd(sessionId)
5459
5727
  const state = hostService.state(sessionId)
5460
5728
  /**
5461
- * **事实货架**(§23):事实变了才重写、才在卡里提一句——**变了才发**,与目录同一条纪律。
5729
+ * **事实货架**:事实变了才重写、才在卡里提一句——**变了才发**,与目录同一条纪律。
5462
5730
  * 事实很少变(升格一次),所以这句话在大多数回合里都不出现。
5463
5731
  */
5464
5732
  const shelf = ensureFactsShelf(sessionId, state)
@@ -5489,7 +5757,7 @@ export function apply(ctx, config = {}) {
5489
5757
  if (brainNote === '' && brainPayload === null && autonomyPayload === null && ontologyPayload === null && factMutations.length === 0) return decision
5490
5758
  return { kind: 'enter', messages: [...decision.messages, pluginNotice(payload, brainNote, brainPayload, autonomyPayload, factMutations)] }
5491
5759
  }
5492
- // §34:档位不再随回合变化(它是部署预设的初值)⇒ 卡片照投影渲染,没有"本回合的新档"要覆盖。
5760
+ // 档位不再随回合变化(它是部署预设的初值)⇒ 卡片照投影渲染,没有"本回合的新档"要覆盖。
5493
5761
  card = hostService.renderCard(sessionId)
5494
5762
  const derived = hostService.derive(sessionId)
5495
5763
  // 续跑窗口的接管:策略说 drive 就布防(人开口则换新窗口)、hold 就停、