clearai-dsh 0.1.6 → 0.1.7

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/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  All notable changes to this project are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [0.1.7] — 2026-09-16
6
+
7
+ **同一件事实只有一个来源。** 一轮"按真值表逐条核对 → 按症状打补丁 → 发现自己在打补丁 →
8
+ 按权威归属复核 → 删掉补丁"的完整收敛。净效果是**更少的机制、更少的字段、更少的分支**。
9
+
10
+ ### Fixed
11
+
12
+ - **"Ended" is not "lost": audits now have the same recovery path as scouts.** When the host's subagent catalog says an evaluator's run has ended, the kernel first **recovers the verdict from the child's own session log** (`recoverVerdictFromChildSession`) — the same path `sweepScouts` has always had — and only records `unknown` when recovery fails. Three honest outcomes replace the old single "lost, this verdict will have no result" (which induced re-delivery ⇒ the same evaluation was redone while its result lay on disk): recovered (verdict + audit card land), ended-but-incomplete (`audit_incomplete`), and log-unreadable (`auditor_ended_uncollected`). The settlement text no longer gives advice — whether to retry is a plan-level decision, not the ledger's to make.
13
+ - **The ruler's scale must be a nameable reference, not prose.** `decide_by_scale_not_reference`: the right side of `量 = 口径` must reference **a file that actually exists in the workspace**; prose and dead paths are rejected. `评分 = 按本路线情况评分` passed the old format check and guaranteed nothing. Whether the branches actually *used* the measuring instrument remains the evaluator's job — the string check stops here and no longer pretends to verify.
14
+ - **`runEvaluator`'s three `unknown` exits now push `audit/settled`.** An evaluator that crashed, didn't finish normally, or whose card could not be written previously left only a `audit/dispatched` on the ledger — looking like "still running" when it had already ended. All three paths now settle: a bad ending is still an ending.
15
+
16
+ ### Changed
17
+
18
+ - **The host-invariant companion now advances state with the production fold.** It previously folded its own index of plans/steps/forks/branches/audits/hypotheses (ten Maps) — a second interpreter that needed two repairs in its first hour because its shapes disagreed with the main projection. It now calls `applyEvent` from `fold.js` on the same events, keeping only the five contract predicates and one `admitted` set (the fold deliberately keeps `admission/checked` as ledger-only). 386 → 190 lines. Two real contract holes fixed in the same pass: dispatch+settle and admission+advance legitimately occur **in the same batch** (the kernel emits them that way), so the judge now accumulates as it iterates.
19
+ - **Turn-end bookkeeping shrank to a workspace snapshot.** The `clearai/turn-ended` event, `turnEnds` state, the in-flight list, and the run-state card's "their conclusions will not come back" (an inference with no evidence — a parent turn ending proves only that the parent turn ended) are all **deleted**. The closing beat (`agent/turn-stopping` / `agent/error`) now only records a ledger commit of the turn's writes — the one thing that belongs to us. `STATE_VERSION` 8 → 9.
20
+ - **Sub-run settlement texts no longer give advice.** "Re-delivering this step dispatches a fresh evaluator" (audits) and "if you need that material, dispatch another scout" (scouts) are gone. The ledger states facts; retry decisions belong to the plan layer.
21
+
22
+ ### Added
23
+
24
+ - **The authority map** (`docs/authority-map.zh-CN.md` + English): who produces each fact, where it lives, who consumes it, whether it can be derived — with the four confirmed findings (each now marked as fixed or under review) and the acceptance criterion: one failure class explained in one place; one fact one authority; the same run never re-executed because a read failed; the system can quietly say it does not know.
25
+ - **`subagent/end` as a settlement channel** (in-process): fires on the same promise settlement as the handle we already trust, so settlement is not lost when the handle is gone (restart, mode switch, early return). Unknown child ids are ignored — someone else's sub-run is not our fact.
26
+
5
27
  ## [0.1.6] — 2026-09-16
6
28
 
7
29
  **机制不许再说自己没有的话。** 一轮「按真值表逐条核对文档 vs 代码」的清点,把三处
package/README.md CHANGED
@@ -39,7 +39,7 @@ If it stops because **pnpm is not on your `PATH`**: DSH manages a profile by dri
39
39
  From a checkout (development, not the install path):
40
40
 
41
41
  ```bash
42
- npm test # 13 suites — the list lives in test/run.sh
42
+ npm test # 14 suites — the list lives in test/run.sh
43
43
  node tools/build-package.mjs # assemble dist/ from source
44
44
  node tools/verify-package.mjs # rebuild and compare byte-for-byte
45
45
  node tools/verify-clean-install.mjs # install into an empty DSH_HOME through the real CLI
@@ -122,7 +122,7 @@ They are illustrations of the mechanism, not shipped run records.
122
122
  - [Soul map: principle → mechanism → test](docs/soul-map.md)
123
123
  - [Glossary](docs/glossary.md)
124
124
  - [Loop philosophy](docs/loop-philosophy.md) · [Verification ontology](docs/verification-loop.md)
125
- - [Known gaps](docs/known-gaps.md) · [Release verification](docs/release-verification.md)
125
+ - [Known gaps](docs/known-gaps.md) · [Authority map](docs/authority-map.md) · [Release verification](docs/release-verification.md)
126
126
  - [Convergence and slimming plan](docs/optimization/plan.md) · [Full-coverage design](docs/optimization/epistemic-coverage.md) · [Execution progress](docs/optimization/progress.zh-CN.md)
127
127
 
128
128
  ## Work attribution
package/README.zh-CN.md CHANGED
@@ -39,7 +39,7 @@ npx clearai-dsh install
39
39
  从仓库开发(这是开发路径,不是安装路径):
40
40
 
41
41
  ```bash
42
- npm test # 13 份套件 —— 清单在 test/run.sh
42
+ npm test # 14 份套件 —— 清单在 test/run.sh
43
43
  node tools/build-package.mjs # 由源装配 dist/
44
44
  node tools/verify-package.mjs # 现场重建并逐字节比对
45
45
  node tools/verify-clean-install.mjs # 空 DSH_HOME + 真 CLI 装一遍(16 条断言)
@@ -122,7 +122,7 @@ ClearAI 把认识论层加在 DSH 的**组合面**上——一个宿主包、一
122
122
  - [灵魂映射:原则 → 机制 → 测试](docs/soul-map.zh-CN.md)
123
123
  - [术语表](docs/glossary.zh-CN.md)
124
124
  - [循环哲学](docs/loop-philosophy.zh-CN.md) · [验证本体](docs/verification-loop.zh-CN.md)
125
- - [已知缺口](docs/known-gaps.zh-CN.md) · [发布验收](docs/release-verification.zh-CN.md)
125
+ - [已知缺口](docs/known-gaps.zh-CN.md) · [权威归属](docs/authority-map.zh-CN.md) · [发布验收](docs/release-verification.zh-CN.md)
126
126
  - [收敛与瘦身计划](docs/optimization/plan.zh-CN.md) · [认识论循环全覆盖设计](docs/optimization/epistemic-coverage.zh-CN.md) · [执行进度](docs/optimization/progress.zh-CN.md)
127
127
 
128
128
  ## 工作署名
package/lib/fold.js CHANGED
@@ -37,7 +37,7 @@ export const MUTATION_KIND = 'clearai'
37
37
  * 后者以人为准、一个字都不动。没有它,这两种情形在外部长得一模一样。
38
38
  * 投影缓存按版本判定,所以旧缓存会被丢弃、从日志重折一遍——用量是**从日志折出来的**,重折才完整。
39
39
  */
40
- export const STATE_VERSION = 7
40
+ export const STATE_VERSION = 9
41
41
 
42
42
  /**
43
43
  * **只留台账、不折进视图**的变更类型(词汇表的另一半)。
package/lib/host.js CHANGED
@@ -19,6 +19,7 @@ import { readdirSync, statSync } from 'node:fs'
19
19
  import { join, relative, resolve, sep } from 'node:path'
20
20
  import { z } from 'zod'
21
21
  import { HUMAN_GATE_ACTIONS, HUMAN_GATE_MARK, MUTATION_KIND, STATE_VERSION, applyEvent, applyMutations, derive, emptyState, renderCard, view } from './fold.js'
22
+ import { install as installInvariants } from './invariant.js'
22
23
 
23
24
  export const name = 'clearai-host'
24
25
  /** 投影注册表与会话存储:两个都是宿主服务,这里只消费。 */
@@ -67,6 +68,26 @@ const viewSchema = z.looseObject({
67
68
  })
68
69
 
69
70
  export function apply(ctx) {
71
+ /**
72
+ * **把自己的不变量交给宿主**(`@deepseek-ai/dsh-invariants`)——但只在它挂了的时候。
73
+ *
74
+ * 为什么在这里注册、而不是另起一行组合:不变量服务**不是每个部署都挂**的诊断面
75
+ * (它由组合决定,还带 `enabled` 与包名白/黑名单)。在我们这一侧读一次、有就加入,
76
+ * 于是任何挂了它的部署(宿主自己的开发组合、我们跑验收的那套)都自动带上这几条契约,
77
+ * 而没挂它的部署里这段是**零成本**的一行判断,不是一个永远等服务的悬空行。
78
+ *
79
+ * 认不出服务、或这个名字已经被别的路径注册过(同包名重复注册宿主会抛):都安静放过——
80
+ * 诊断面不许把产品弄坏。
81
+ */
82
+ try {
83
+ const invariants = ctx.get('invariants')
84
+ if (invariants !== undefined && typeof invariants.register === 'function') {
85
+ invariants.register('clearai-dsh', installInvariants)
86
+ }
87
+ } catch (error) {
88
+ ctx.logger?.warn?.(`clearai: 宿主不变量没挂上 ${String(error?.message ?? error).slice(0, 160)}`)
89
+ }
90
+
70
91
  // 视图按状态引用记忆:同一份状态不重复造对象(投影用 Object.is 判断要不要发布)
71
92
  let lastState = null
72
93
  let lastView = null
@@ -0,0 +1,202 @@
1
+ /**
2
+ * ClearAI 的**宿主不变量**(`@deepseek-ai/dsh-invariants` 的伴生件)。
3
+ *
4
+ * 这一份与上一版的根本差别:它**不再自己折**。上一版为了"落账之前判"维护了一套
5
+ * plans/steps/forks/branches/… 的索引,上岗第一小时就因为与主投影**对不上**被修了两次
6
+ * ——那不是业务有错,是**第二套解释器**必然漂移。现在状态直接用生产折法
7
+ * (`fold.js` 的 `applyEvent`)推进,契约只在生产状态与候选变更上判:
8
+ *
9
+ * · 状态只有一个权威(投影),这里只是把它**再跑一遍**——同一份代码,不可能漂;
10
+ * · 本文件独有的只剩**时机**(落账之前)与**判据**(五条契约);
11
+ * · 唯一的私有累积是 admitted 步骤集合(折法刻意把 `admission/checked` 留在台账层,
12
+ * 投影里没有它——那不是解释形状,是一行 `add(step)`)。
13
+ *
14
+ * 三条纪律:
15
+ * · **只判契约,不判判断**:这里只问"这条事实能不能和已经落下的事实同时成立"。
16
+ * · **已经发生的失败必须允许入账**:评估者崩溃、落盘失败是事实,拦下它们
17
+ * 就是"只允许好看的事实进入账本"——那本身是不实陈述。
18
+ * · **诊断面不许假装承重**:没挂这个服务的部署里它是零成本的一行判断。
19
+ */
20
+ import { applyEvent, emptyState } from './fold.js'
21
+
22
+ /** 归属包名:违反时报出去的是这个名字(宿主据此过滤与归属)。 */
23
+ const PACKAGE_NAME = 'clearai-dsh'
24
+ /** Cordis 伴生插件名。 */
25
+ const name = 'clearai-invariant'
26
+ /** 服务由宿主组合提供;没有它时这一行进等待,不报错。 */
27
+ const inject = ['invariants']
28
+
29
+ /** 一条 fact 里的 mutations:两种装载点(插件消息的段、工具结果的 meta)。 */
30
+ function mutationsOf(event) {
31
+ const out = []
32
+ const source = event?.data?.source
33
+ if (source !== null && typeof source === 'object' && Array.isArray(source.sections)) {
34
+ const section = source.sections.find((item) => item?.name === 'clearai/mutations')
35
+ if (section !== undefined && typeof section.text === 'string') {
36
+ try {
37
+ const payload = JSON.parse(section.text)
38
+ if (Array.isArray(payload?.mutations)) out.push(...payload.mutations)
39
+ } catch {
40
+ /* 坏 payload 由折法决定怎么办;这里不猜。 */
41
+ }
42
+ }
43
+ }
44
+ const meta = event?.data?.meta
45
+ if (meta !== null && meta !== undefined && typeof meta === 'object' && meta.kind === 'clearai') {
46
+ if (Array.isArray(meta.mutations)) out.push(...meta.mutations)
47
+ else if (meta.mutation !== undefined) out.push(meta.mutation)
48
+ }
49
+ return out
50
+ }
51
+
52
+ /**
53
+ * **伪步骤**:目标轴(`goal:<id>`)与世界线轴(`<forkId>:<branchId>`)的裁决也带 `step`,
54
+ * 但它们本来就不在 `plan/created` 里登记。计划步骤 id 的取字纪律是字母/数字/下划线/短横
55
+ * (见内核 `STEP_SCHEMA`),两条伪轴一律带冒号。
56
+ */
57
+ const isPseudoStep = (step) => String(step).includes(':')
58
+
59
+ const LEVEL_RANK = { L0: 0, L1: 1, L2: 2, L3: 3, L4: 4 }
60
+
61
+ /** 在**生产状态**里找一个计划步骤(裸 id;这是 `view.stepIndex` 的同一份事实)。 */
62
+ function findStep(state, step) {
63
+ if (step === null || step === undefined || isPseudoStep(step)) return undefined
64
+ for (const plan of state?.plans ?? []) {
65
+ const found = (plan.steps ?? []).find((item) => String(item.id) === String(step))
66
+ if (found !== undefined) return found
67
+ }
68
+ return undefined
69
+ }
70
+
71
+ /**
72
+ * 五条契约,逐条判。输入是**生产折法**算出的 `before`(落这条之前的状态)、候选事件的
73
+ * 变更、与 admitted 累积。违反就 `fail(消息)`;判据查不到的那一半**安静地不判**
74
+ * (查不到 ≠ 不存在——判据比事实窄就是误伤,这一条是被真跑教出来的)。
75
+ */
76
+ function judge(before, mutations, admittedIn, fail) {
77
+ const plans = Array.isArray(before?.plans) ? before.plans : []
78
+ const hasAnyStep = plans.some((plan) => Array.isArray(plan.steps) && plan.steps.length > 0)
79
+ /**
80
+ * **本批累积**:一次交付的变更里,`admission/checked` 与 `step/advanced`、
81
+ * `audit/dispatched` 与 `audit/settled` 常常**同一批**落(内核就是这么发的——
82
+ * 派遣先落、结论在同一笔里回填)。判据只看"之前"就会把这批自己拦下,
83
+ * 所以这里把两条累积随循环一起长:顺序仍然要求(先派遣后结算),只是允许在同批。
84
+ */
85
+ const admitted = new Set(admittedIn ?? [])
86
+ const auditsSeen = new Set((Array.isArray(before?.audits) ? before.audits : []).map((item) => String(item.id)))
87
+ const scoutsSeen = new Set((Array.isArray(before?.scouts) ? before.scouts : []).map((item) => String(item.id)))
88
+ for (const mutation of mutations) {
89
+ if (mutation === null || typeof mutation !== 'object' || typeof mutation.t !== 'string') continue
90
+ const t = mutation.t
91
+ const step = mutation.step === undefined || mutation.step === null ? null : String(mutation.step)
92
+
93
+ // ── 契约①:引用完整性(查生产状态,不是另一份索引) ────────────────────
94
+ if (step !== null && !isPseudoStep(step) && hasAnyStep && ['evidence/recorded', 'observation/recorded', 'step/advanced', 'scout/dispatched'].includes(t)) {
95
+ if (findStep(before, step) === undefined) fail(`引用完整性:${t} 指向不存在的步骤 ${step}——事实不能指向还没登记的东西`)
96
+ }
97
+
98
+ // ── 契约②:准入先于推进(同批允许先准入后推进;跨批看 admitted 累积) ────────
99
+ if (t === 'admission/checked' && step !== null) admitted.add(step)
100
+ if (t === 'step/advanced' && step !== null && !isPseudoStep(step) && !admitted.has(step)) {
101
+ fail(`准入先于推进:step/advanced(${step}) 之前没有该步的 admission/checked`)
102
+ }
103
+
104
+ // ── 契约③:结算必有派遣(生产状态 + 本批已见的派遣) ─────────────────────
105
+ if (t === 'audit/settled' && !auditsSeen.has(String(mutation.id))) {
106
+ fail(`结算必有派遣:audit/settled(${String(mutation.id)}) 之前没有 audit/dispatched`)
107
+ }
108
+ if (t === 'scout/settled' && !scoutsSeen.has(String(mutation.id))) {
109
+ fail(`结算必有派遣:scout/settled(${String(mutation.id)}) 之前没有 scout/dispatched`)
110
+ }
111
+ if (t === 'audit/dispatched' && mutation.id !== undefined && mutation.id !== null) auditsSeen.add(String(mutation.id))
112
+ if (t === 'scout/dispatched' && mutation.id !== undefined && mutation.id !== null) scoutsSeen.add(String(mutation.id))
113
+
114
+ // ── 契约④:升格有据(目标登记的门槛 vs 已落账的支持证据) ─────────────────
115
+ if (t === 'fact/promoted') {
116
+ const goal = before?.goal ?? null
117
+ const need = LEVEL_RANK[String(goal?.promote_at_level ?? '')]
118
+ if (goal !== null && need !== undefined) {
119
+ /** 顺着 假设(claim 匹配)→ 步骤(tests.hypothesis)→ 证据(support) 这条边走。 */
120
+ const hypothesis = (Array.isArray(before?.hypotheses) ? before.hypotheses : []).find((item) => String(item.claim ?? '') === String(mutation.text ?? ''))
121
+ const hypId = hypothesis === undefined ? null : String(hypothesis.id)
122
+ const stepIds = new Set(
123
+ hypId === null ? [] : plans.flatMap((plan) => (plan.steps ?? []).filter((s) => String(s?.tests?.hypothesis ?? '') === hypId).map((s) => String(s.id))),
124
+ )
125
+ const support = (Array.isArray(before?.evidence) ? before.evidence : []).filter((item) => String(item.verdict) === 'support' && (hypId === null || stepIds.has(String(item.step))))
126
+ const best = support.reduce((max, item) => Math.max(max, LEVEL_RANK[String(item.level)] ?? -1), -1)
127
+ if (best < need) fail(`升格有据:fact/promoted 的最高支持等级(${best === -1 ? '无' : `L${best}`})没到目标登记的 promote_at_level(${String(goal.promote_at_level)})`)
128
+ }
129
+ }
130
+
131
+ // ── 契约⑤:事实棘轮(生产折法对降级是**静默忽略**的;这里是唯一的检测点) ────
132
+ if (t === 'step/advanced' && step !== null) {
133
+ const found = findStep(before, step)
134
+ if (found !== undefined && String(found.status) === 'void') {
135
+ fail(`事实棘轮:步骤(${step})已作废,不能再被推进——已经落下的事实不能被改写`)
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ /**
142
+ * 安装件:宿主把 `(childCtx, fail)` 交给我们。
143
+ * 状态用**生产折法**推进(`applyEvent`);候选先叠在副本上,判不过就不污染。
144
+ */
145
+ const install = Object.assign(
146
+ (ctx, fail) => {
147
+ const states = new WeakMap()
148
+ const admitted = new WeakMap()
149
+ const staged = new WeakMap()
150
+ const seed = (session) => {
151
+ let state = emptyState()
152
+ const adm = new Set()
153
+ for (const event of session.snapshotEvents()) {
154
+ state = applyEvent(state, event)
155
+ for (const mutation of mutationsOf(event)) {
156
+ if (mutation?.t === 'admission/checked' && mutation.step !== undefined && mutation.step !== null) adm.add(String(mutation.step))
157
+ }
158
+ }
159
+ states.set(session, state)
160
+ admitted.set(session, adm)
161
+ return state
162
+ }
163
+ ctx.sessions.list().forEach(seed)
164
+ ctx.on('session/created', (session) => seed(session), { global: true })
165
+ ctx.on(
166
+ 'internal/dispatch',
167
+ (_mode, eventName, args) => {
168
+ if (eventName !== 'session/event') return
169
+ const [session, event] = args
170
+ const mutations = mutationsOf(event)
171
+ if (mutations.length === 0) return
172
+ const before = states.get(session) ?? emptyState()
173
+ judge(before, mutations, admitted.get(session) ?? new Set(), fail)
174
+ // 判过了才推进:候选状态与累积都改在副本上。
175
+ const candidate = applyEvent(before, event)
176
+ const adm = new Set(admitted.get(session) ?? new Set())
177
+ for (const mutation of mutations) {
178
+ if (mutation?.t === 'admission/checked' && mutation.step !== undefined && mutation.step !== null) adm.add(String(mutation.step))
179
+ }
180
+ staged.set(event, { session, state: candidate, admitted: adm })
181
+ },
182
+ { global: true },
183
+ )
184
+ ctx.on(
185
+ 'session/event',
186
+ (session, event) => {
187
+ const candidate = staged.get(event)
188
+ if (candidate === undefined) return
189
+ staged.delete(event)
190
+ states.set(session, candidate.state)
191
+ admitted.set(session, candidate.admitted)
192
+ },
193
+ { global: true },
194
+ )
195
+ },
196
+ { inject: ['sessions'] },
197
+ )
198
+
199
+ /** 把这一包的不变量注册进宿主(`apply` 是伴生插件的入口)。 */
200
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
201
+
202
+ export { PACKAGE_NAME, apply, inject, install, judge, mutationsOf, name }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clearai-dsh",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "ClearAI: The Epistemic Loop, native to DSH.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -14,7 +14,8 @@
14
14
  ".": "./lib/host.js",
15
15
  "./client": "./lib/client.js",
16
16
  "./cordis.patch.yml": "./cordis.patch.yml",
17
- "./package.json": "./package.json"
17
+ "./package.json": "./package.json",
18
+ "./invariant": "./lib/invariant.js"
18
19
  },
19
20
  "bin": {
20
21
  "clearai-dsh": "bin/clearai.mjs"
@@ -1123,7 +1123,7 @@ export function apply(ctx, config = {}) {
1123
1123
  `- 要裁决的分歧:${fork.question}`,
1124
1124
  `- **你这一个方案**:${branch.label} —— ${branch.approach}`,
1125
1125
  `- **判定标准(你必须做到,并且可被客观核对)**:${branch.done_criteria}`,
1126
- `- **裁决指标(所有世界线共用同一把尺子,不许各自另定口径)**:${fork.decide_by?.metric ?? '(未登记)'}(${fork.decide_by?.direction === 'min' ? '越小越好' : '越大越好'})`,
1126
+ `- **裁决指标(所有世界线共用同一把尺子,不许各自另定口径;口径里引用的那个文件就是测量仪,你的读数必须出自它)**:${fork.decide_by?.metric ?? '(未登记)'}(${fork.decide_by?.direction === 'min' ? '越小越好' : '越大越好'})`,
1127
1127
  /**
1128
1128
  * **声明的物证路径必须告诉干活的人**。
1129
1129
  *
@@ -1152,7 +1152,7 @@ export function apply(ctx, config = {}) {
1152
1152
  })
1153
1153
  if (dispatched.ok !== true) return { ok: false, note: `执行者派不出去(${dispatched.reason})`, mutations }
1154
1154
  const childId = String(dispatched.run.id)
1155
- const entry = { fork: fork.id, branch: branch.id, label: branch.label, workspace: branch.workspace, child: childId, settled: null, reported: false, run: dispatched.run }
1155
+ const entry = { sessionId, fork: fork.id, branch: branch.id, label: branch.label, workspace: branch.workspace, child: childId, settled: null, reported: false, run: dispatched.run }
1156
1156
  executorRuns.set(childId, entry)
1157
1157
  mutations.push({ t: 'worldline/executing', fork: fork.id, branch: branch.id, child: childId, capability: dispatched.capability, degraded_reason: dispatched.degraded ?? null })
1158
1158
  /**
@@ -1524,7 +1524,7 @@ export function apply(ctx, config = {}) {
1524
1524
 
1525
1525
 
1526
1526
  /**
1527
- * **失联的评估者**。
1527
+ * **已结束的评估者**。
1528
1528
  *
1529
1529
  * `pendingAudits` 是**进程内**的:重启之后它空了,而投影里那条 `audit/dispatched` 还在
1530
1530
  * (`verdict === null`)。后果有两条,后一条更狠:
@@ -1533,13 +1533,16 @@ export function apply(ctx, config = {}) {
1533
1533
  * 一条永远不会回来的裁决,把整个目标按死在挂起上。
1534
1534
  *
1535
1535
  * 判据不看内存,看**宿主的目录**:`subagents.listChildren(sessionId)` 给每个子会话一个
1536
- * `activity: 'running' | 'inactive'`(还在跑 / 只剩日志),而且它不需要把子代理加载起来。
1537
- * 投影里在裁决 + 子会话不在跑 ⇒ 落一条 `audit/settled{verdict:'unknown', note:'lost'}`:
1538
- * 与别的结局用同一个转变,只是缘由写具体(「失联」)。下一次交付会重新派评估者。
1536
+ * `activity: 'running' | 'inactive'`(还在跑 / 只剩日志)——**宿主是"还在不在跑"的唯一权威**。
1537
+ * 目录说已结束 ⇒ 这次运行**结束了**。结束不等于失联:结论可能就躺在它自己的会话日志里,
1538
+ * 所以**先取回**(与侦察的 `sweepScouts` 同一条路),取不回才如实落 unknown。
1539
+ *
1540
+ * 结算只报事实、不给建议(「重新交付会派一个新的评估者」那类话删了):
1541
+ * 要不要重试是计划层的决定,不是账本该说的话。
1539
1542
  *
1540
1543
  * 拿不到目录(服务不在 / 查询失败)时**什么都不做**:不猜、不误伤正在跑的裁决。
1541
1544
  */
1542
- async function sweepLostAudits(sessionId, state) {
1545
+ async function sweepEndedAudits(sessionId, state) {
1543
1546
  const pending = (state?.audits ?? []).filter((audit) => audit.verdict === null && typeof audit.child === 'string' && audit.child !== '')
1544
1547
  if (pending.length === 0) return { mutations: [], lost: 0, lines: [] }
1545
1548
  // 这个进程里正攥着的那几次派遣:它们是活的,不问目录。
@@ -1560,18 +1563,41 @@ export function apply(ctx, config = {}) {
1560
1563
  const lines = []
1561
1564
  for (const audit of unresolved) {
1562
1565
  if (running.has(String(audit.child))) continue
1563
- mutations.push({
1564
- t: 'audit/settled',
1565
- id: audit.id,
1566
- step: audit.step,
1567
- verdict: 'unknown',
1568
- basis: '评估者失联:派它的那次进程已经不在了,子会话也不在跑——这次裁决不会有结果。重新交付这一步会派一个新的评估者。',
1569
- shortfalls: ['auditor_lost'],
1570
- card_path: null,
1571
- })
1572
- lines.push(`${audit.step}:裁决失联(记为 unknown,可重新交付)`)
1566
+ /**
1567
+ * 宿主说已结束 ⇒ **先把它的结论取回来**(读它自己的会话日志,与侦察同一条路)。
1568
+ * 跳过它就把「结束」误报成「死亡」,还会诱导重新交付 ⇒ 同一次评估被重做。
1569
+ */
1570
+ const recovered = recoverVerdictFromChildSession(audit.child)
1571
+ if (recovered !== null && recovered.ok === true) {
1572
+ const card = { schema_version: 'clearai.audit.v1', kind: audit.kind ?? 'evidence_audit', step_id: audit.step, auditor_run_id: String(audit.child), verdict: recovered.verdict.verdict, shortfalls: recovered.verdict.shortfalls, card: recovered.verdict.basis, created_at: Date.now() }
1573
+ const cardPath = writeAuditCard(sessionId, audit.step, card)
1574
+ mutations.push({ t: 'audit/settled', id: audit.id, step: audit.step, verdict: recovered.verdict.verdict, basis: recovered.verdict.basis, shortfalls: recovered.verdict.shortfalls, card_path: cardPath })
1575
+ lines.push(`${audit.step}:裁决从子会话日志取回(${recovered.verdict.verdict})`)
1576
+ continue
1577
+ }
1578
+ if (recovered !== null && recovered.ok !== true) {
1579
+ mutations.push({ t: 'audit/settled', id: audit.id, step: audit.step, verdict: 'unknown', basis: `评估者已结束,但未正常完成(${recovered.stopReason})。`, shortfalls: ['audit_incomplete'], card_path: null })
1580
+ lines.push(`${audit.step}:评估者已结束、未正常完成(记为 unknown)`)
1581
+ continue
1582
+ }
1583
+ mutations.push({ t: 'audit/settled', id: audit.id, step: audit.step, verdict: 'unknown', basis: '评估者已结束(宿主目录报告),但其结论未能从子会话日志取回。', shortfalls: ['auditor_ended_uncollected'], card_path: null })
1584
+ lines.push(`${audit.step}:评估者已结束、结论未取回(记为 unknown)`)
1573
1585
  }
1574
- return { mutations, lost: mutations.length, lines }
1586
+ return { mutations, settled: mutations.length, lines }
1587
+ }
1588
+
1589
+ /**
1590
+ * 从子会话日志里取回**评估者**的裁决——与侦察的 `recoverFromChildSession` 同一条路。
1591
+ *
1592
+ * 返回三档:`{ok:true, verdict}` 取回了;`{ok:false, stopReason}` 它结束了但未正常完成;
1593
+ * `null` 连它的日志都读不到。三档都只是**读数**,判断留给调用方如实分档。
1594
+ */
1595
+ function recoverVerdictFromChildSession(childId) {
1596
+ const settled = recoverFromChildSession(childId)
1597
+ if (settled === null) return null
1598
+ if (settled.ok !== true) return { ok: false, stopReason: settled.stopReason }
1599
+ const verdict = normalizeVerdict(parseLooseJson([{ type: 'text', text: settled.conclusion }]))
1600
+ return { ok: true, verdict }
1575
1601
  }
1576
1602
 
1577
1603
  function sweepWorldlineExecutors(state, sessionId) {
@@ -1711,7 +1737,7 @@ export function apply(ctx, config = {}) {
1711
1737
  * 事实该在副作用之前/同批落账:所以派遣立即返回,结论由 `sweepScouts()` 在
1712
1738
  * 「下一个回合边界 / 用到世界线与侦察的那几件工具」上收(与执行者完全同一套)。
1713
1739
  */
1714
- const entry = { scoutId, stepId: step.id, planId: plan?.id ?? null, trigger, digest, child: childId, settled: null, reported: false, run: dispatched.run }
1740
+ const entry = { sessionId, scoutId, stepId: step.id, planId: plan?.id ?? null, trigger, digest, child: childId, settled: null, reported: false, run: dispatched.run }
1715
1741
  scoutRuns.set(childId, entry)
1716
1742
  /**
1717
1743
  * 只有一次性派遣才有 `result` promise。可续跑那一档没有 ⇒ 结算改由
@@ -1753,7 +1779,7 @@ export function apply(ctx, config = {}) {
1753
1779
  return { verdict: 'unknown', basis: `独立评估者无法派遣(${dispatched.reason})`, shortfalls: ['audit_dispatch_failed'], cardPath: null, mutations }
1754
1780
  }
1755
1781
  mutations.push({ t: 'audit/dispatched', id: auditKey, step: step.id, plan: plan?.id ?? 'goal', kind, evaluator_session: String(dispatched.run.id), capability: dispatched.capability })
1756
- entry = { run: dispatched.run, capability: dispatched.capability, auditKey, settled: undefined }
1782
+ entry = { sessionId, run: dispatched.run, capability: dispatched.capability, auditKey, step: step.id, plan: plan?.id ?? 'goal', kind, settled: undefined }
1757
1783
  pendingAudits.set(key, entry)
1758
1784
  entry.settled = dispatched.run.result.then(
1759
1785
  (value) => ({ ok: true, value }),
@@ -1774,12 +1800,24 @@ export function apply(ctx, config = {}) {
1774
1800
  // 已落定:这次派遣的生命周期到此为止。下一次交付是**新的一次评估**(新证据),必须重新派遣。
1775
1801
  pendingAudits.delete(key)
1776
1802
  if (outcome.ok !== true) {
1777
- return { verdict: 'unknown', basis: `评估者失败:${String(outcome.error?.message ?? outcome.error)}`, shortfalls: ['audit_failed'], cardPath: null, mutations }
1803
+ return settleUnknown(`评估者失败:${String(outcome.error?.message ?? outcome.error)}`, ['audit_failed'])
1804
+ }
1805
+ /**
1806
+ * **裁决一旦结束,就要落一条结算事实**——不管结局是什么。
1807
+ *
1808
+ * 三条路原来直接 `return unknown`(评估者失败 / 没正常结束 / 评估卡落盘失败),
1809
+ * 账上只剩 `audit/dispatched`:看上去像"它还在跑",而它**已经结束了**。
1810
+ * 结局不好也是结局,如实落下来——不然那条派发事实会在账上挂到天荒地老,
1811
+ * 而"评估者没有悬空"这条不变量也只能红着,连解释都拿不出证据。
1812
+ */
1813
+ const settleUnknown = (basis, shortfalls) => {
1814
+ mutations.push({ t: 'audit/settled', id: entry.auditKey, step: step.id, verdict: 'unknown', basis, shortfalls, card_path: null })
1815
+ return { verdict: 'unknown', basis, shortfalls, cardPath: null, mutations }
1778
1816
  }
1779
1817
  const settled = outcome.value
1780
1818
  const stopReason = String(settled?.stopReason ?? 'completed')
1781
1819
  if (stopReason !== 'completed' && settled?.structured === undefined) {
1782
- return { verdict: 'unknown', basis: `评估者未正常结束(${stopReason})`, shortfalls: ['audit_incomplete'], cardPath: null, mutations }
1820
+ return settleUnknown(`评估者未正常结束(${stopReason})`, ['audit_incomplete'])
1783
1821
  }
1784
1822
  const verdict = settled?.structured !== undefined ? normalizeVerdict(settled.structured) : normalizeVerdict(parseLooseJson(settled?.output))
1785
1823
  try {
@@ -1790,7 +1828,7 @@ export function apply(ctx, config = {}) {
1790
1828
  const card = { schema_version: 'clearai.audit.v1', kind, step_id: step.id, auditor_run_id: String(entry.run.id), verdict: verdict.verdict, shortfalls: verdict.shortfalls, card: verdict.basis, created_at: Date.now() }
1791
1829
  const cardPath = writeAuditCard(sessionId, step.id, card)
1792
1830
  if (cardPath === null) {
1793
- return { verdict: 'unknown', basis: '评估卡落盘失败:裁决降级', shortfalls: ['card_persist_failed'], cardPath: null, mutations }
1831
+ return settleUnknown('评估卡落盘失败:裁决降级', ['card_persist_failed'])
1794
1832
  }
1795
1833
  mutations.push({ t: 'audit/settled', id: entry.auditKey, step: step.id, verdict: verdict.verdict, basis: verdict.basis, shortfalls: verdict.shortfalls, card_path: cardPath })
1796
1834
  return { ...verdict, cardPath, mutations }
@@ -3661,18 +3699,23 @@ export function apply(ctx, config = {}) {
3661
3699
  *
3662
3700
  * 失败如实告警并返回空串:账本没记上就说没记上,不假装记过。
3663
3701
  */
3664
- function snapshotWorkspace(sessionId, cwd, state, mutations, turn) {
3702
+ function snapshotWorkspace(sessionId, cwd, state, mutations, turn, phase = 'mid') {
3665
3703
  const calls = Number(state?.writeCalls ?? 0)
3666
3704
  if (calls <= 0 || lastWorkspaceSnapshot.get(sessionId) === calls) return ''
3667
3705
  lastWorkspaceSnapshot.set(sessionId, calls)
3668
- const committed = commitLedger(cwd, `clearai: 探索期快照 — 第 ${turn} 回合中尚未交付的写入`)
3706
+ /**
3707
+ * 措辞分流:pre-step 是**回合中途**的恢复点,turn-stopping 是**回合结束时**那一笔
3708
+ * ——后者才是"跑动在这里停下,工作区长这样",也是唯一在一次性形态里会落地的那笔。
3709
+ */
3710
+ const when = phase === 'end' ? `第 ${turn} 回合结束时尚未交付的写入` : `第 ${turn} 回合中尚未交付的写入`
3711
+ const committed = commitLedger(cwd, `clearai: 探索期快照 — ${when}`)
3669
3712
  if (committed.ok !== true) {
3670
3713
  ctx.logger?.warn?.(`clearai: 工作区快照失败 ${String(committed.reason ?? '').slice(0, 160)}`)
3671
3714
  return ''
3672
3715
  }
3673
3716
  if (committed.skipped === true) return ''
3674
3717
  // 落一条**台账事实**:系统对工作区做过什么,日志里要说得出来(与交付那笔同一条纪律)。
3675
- mutations.push({ t: 'git/snapshot', commit: committed.commit, reason: '探索期快照(尚未交付的写入)', turn })
3718
+ mutations.push({ t: 'git/snapshot', commit: committed.commit, reason: phase === 'end' ? '探索期快照(回合结束时尚未交付的写入)' : '探索期快照(尚未交付的写入)', turn })
3676
3719
  const short = committed.commit === null ? '' : `(${String(committed.commit).slice(0, 7)})`
3677
3720
  return `\n(工作区已记入账本${short}:探索期的产出从此可查、可恢复——不需要你为它声明什么。)`
3678
3721
  }
@@ -3977,7 +4020,7 @@ export function apply(ctx, config = {}) {
3977
4020
  }
3978
4021
  }
3979
4022
 
3980
- function validateForkOptions(options, decideBy) {
4023
+ function validateForkOptions(options, decideBy, cwd) {
3981
4024
  if (!Array.isArray(options) || options.length < 2 || options.length > 4) return 'fork_needs_2to4_options:世界线要 2–4 条(分叉是「选一」,不是清单)'
3982
4025
  if (decideBy === null || decideBy === undefined || typeof decideBy !== 'object') return 'decide_by_required:分叉必须登记一把尺子 {metric, direction}——没有判定契约就不能收敛'
3983
4026
  if (typeof decideBy.metric !== 'string' || decideBy.metric.trim() === '') return 'decide_by_metric_required:尺子要有指标名(如 yield_pct)'
@@ -3994,7 +4037,26 @@ export function apply(ctx, config = {}) {
3994
4037
  */
3995
4038
  const scale = decideBy.metric.split(/[==]/).slice(1).join('=').trim()
3996
4039
  if (!/[==]/.test(decideBy.metric) || scale.length < 4) {
3997
- return 'decide_by_scale_required:尺子要写成「量 = 口径」(口径 = 这个数怎么算出来、单位是什么),例:字节数 = 生成文件的字节数。只有指标名不是尺子:口径不能留给每条世界线各自去定,否则两条线各写一套公式,比出来的大小不作数'
4040
+ return 'decide_by_scale_required:尺子要写成「量 = 口径」——口径必须是**工作区里真有的一个文件**(脚本或规格),例:读数 = lab/measure.py 的输出。只有指标名不是尺子:口径留给每条世界线各自去定,两条线各写一套公式,比出来的大小不作数'
4041
+ }
4042
+ /**
4043
+ * **口径必须是可指认的引用,不是散文**。
4044
+ *
4045
+ * 「评分 = 按本路线情况评分」同样能通过格式检查,却什么也没保证。一条引用
4046
+ * (工作区里的路径)则不同:路径相等是机器可核的,文件内容在账本里有版本——
4047
+ * 「同一把尺子」从一句声明变成一个可指认的对象。至于两条世界线**有没有真的
4048
+ * 用它**去量,那是评估者重跑的事,字符串检查到这里就到头了,不冒充能验。
4049
+ */
4050
+ const pathTokens = scale.match(/[^\s,;(){}"']+\/+[^\s,;(){}"']*/g) ?? []
4051
+ const resolvable = pathTokens.filter((token) => {
4052
+ try {
4053
+ return existsSync(isAbsolute(token) ? token : resolvePath(cwd, token))
4054
+ } catch {
4055
+ return false
4056
+ }
4057
+ })
4058
+ if (pathTokens.length === 0 || resolvable.length === 0) {
4059
+ return `decide_by_scale_not_reference:口径里必须引用**工作区里真有的一个文件**(现在写的是「${scale.slice(0, 60)}」)。先在工作区写好测量脚本或口径说明,再用它登记尺子——散文口径两条世界线各读各的,比出来的大小不作数`
3998
4060
  }
3999
4061
  if (decideBy.direction !== 'max' && decideBy.direction !== 'min') return 'decide_by_direction_required:尺子要说明方向:max(越大越好)或 min(越小越好)'
4000
4062
  /** 量那一半:判据里要点名它。 */
@@ -4252,7 +4314,7 @@ export function apply(ctx, config = {}) {
4252
4314
  defineTool({
4253
4315
  name: 'ForkPlan',
4254
4316
  description:
4255
- '分叉:把一个步骤分成 2–4 条互斥的世界线,每条自己做一份工作副本、自己交付。**必须先登记一把尺子**(decide_by{metric,direction}),而 metric 要写成**「量 = 口径」**(口径 = 这个数怎么算出来、单位是什么),并且这句话要**逐字**出现在每条世界线的判据里——把同一台测量仪装到每条世界线上。口径不能留给各条世界线自己定:两条线上各写一套公式,比出来的大小不作数。收敛由算术决定,不由谁说得响。分叉是「选一」,不是并行加速。',
4317
+ '分叉:把一个步骤分成 2–4 条互斥的世界线,每条自己做一份工作副本、自己交付。**必须先登记一把尺子**(decide_by{metric,direction}),而 metric 要写成**「量 = 口径」**,口径必须是**工作区里真有的一个文件**(测量脚本或口径说明),例:读数 = lab/measure.py 的输出;先写好那个文件再开分叉。量那一半要**逐字**出现在每条世界线的判据里。散文口径两条世界线各读各的,比出来的大小不作数。收敛由算术决定,不由谁说得响。分叉是「选一」,不是并行加速。',
4256
4318
  parameters: {
4257
4319
  type: 'object',
4258
4320
  properties: {
@@ -4297,7 +4359,7 @@ export function apply(ctx, config = {}) {
4297
4359
  if (step === null) return fail('no_open_step', '这份计划没有未落定的步了。')
4298
4360
  if (typeof args.question !== 'string' || args.question.trim() === '') return fail('question_required', '分叉要说清要裁决的分歧是什么。')
4299
4361
  if (forkOfStep(hostService.derive(sessionId).forks, step.id) !== null) return fail('fork_depth_exceeded', `步骤 ${step.id} 已经长过一个分叉了:一步只分叉一次。`)
4300
- const problem = validateForkOptions(args.options, args.decide_by)
4362
+ const problem = validateForkOptions(args.options, args.decide_by, cwd)
4301
4363
  if (problem !== null) return fail(problem.split(':')[0], problem)
4302
4364
  const forkId = uniqueId('k')
4303
4365
  const options = args.options.map((option) => ({
@@ -5853,6 +5915,62 @@ export function apply(ctx, config = {}) {
5853
5915
  }
5854
5916
 
5855
5917
  // 会话一被创建/接入就把工作区铺好(见 ensureWorkspace:要抢在原生目录快照之前)。
5918
+ /**
5919
+ * **子 run 落定由宿主告诉我们**(`subagent/end`),不再只靠"轮询子会话日志"。
5920
+ *
5921
+ * 载荷 `{runId, provider, id, local, stopReason, lastAssistantMessage?}` 由持有 `run.result`
5922
+ * 的那个 service 在**同一个 promise 落定的那一刻**发出(成功与失败都发)——与我们本来
5923
+ * 就信任的那次 `run.result` 是同一个触发点,所以权威性相同,而好处是:handle 已经不在
5924
+ * (重启、换档、早退)时结算也不会丢,失败一路也不用再靠日志里的停止原因去猜。
5925
+ *
5926
+ * 认出 id 才吃:只认我们自己派出去的那几个(别人的子 run 不是我们的事实)。
5927
+ */
5928
+ ctx.on('subagent/end', (info) => {
5929
+ try {
5930
+ const childId = String(info?.id ?? '')
5931
+ if (childId === '') return
5932
+ const settled = settleSubRun({ output: Array.isArray(info.lastAssistantMessage) ? info.lastAssistantMessage : [], stopReason: String(info.stopReason ?? 'completed') })
5933
+ for (const table of [executorRuns, scoutRuns]) {
5934
+ const entry = table.get(childId)
5935
+ // 已经落定的不覆盖:先到的那一条为准(事件与 promise 是同一个触发点)。
5936
+ if (entry !== undefined && (entry.settled === null || entry.settled === undefined)) entry.settled = settled
5937
+ }
5938
+ } catch (error) {
5939
+ ctx.logger?.warn?.(`clearai: 子 run 结算事件处理失败 ${String(error?.message ?? error).slice(0, 160)}`)
5940
+ }
5941
+ })
5942
+
5943
+
5944
+ /**
5945
+ * **回合收尾**:宿主在回合将停、且队列里没有下一步时**串行 await** 这一拍。
5946
+ *
5947
+ * 这里只做一件真正属于我们的事:把这一回合的写入记进账本(**回合边界**的那一笔,
5948
+ * 一次性形态里唯一会落地的那笔)。**不判在飞、不写裁决、不叫醒任何人**:
5949
+ * 「还在不在跑」的权威是宿主的子任务目录,「结果收没收回」是收集通道的事——
5950
+ * 收尾那一拍各有一句要说的话,但都不该由这里替它们说。
5951
+ */
5952
+ function snapshotAtTurnEnd(payload) {
5953
+ try {
5954
+ const hostService = host()
5955
+ if (hostService === undefined) return
5956
+ const sessionId = String(payload?.agent?.id ?? '')
5957
+ if (sessionId === '') return
5958
+ const turn = typeof payload?.turn === 'number' ? payload.turn : null
5959
+ // mutations 进空数组:这一拍没有工具结果可以携带变更,账本提交本身就是记录。
5960
+ snapshotWorkspace(sessionId, sessionCwd(sessionId), hostService.state(sessionId), [], turn, 'end')
5961
+ } catch (error) {
5962
+ ctx.logger?.warn?.(`clearai: 回合收尾失败 ${String(error?.message ?? error).slice(0, 160)}`)
5963
+ }
5964
+ }
5965
+
5966
+ ctx.on('agent/turn-stopping', snapshotAtTurnEnd)
5967
+
5968
+ /**
5969
+ * **回合以错误结束也要记一笔工作区**:那一拍的写入不能因为出错就丢。
5970
+ * 宿主在出错那一档**不派** `agent/turn-stopping`(它 throw 出去了),所以这里也挂一份。
5971
+ */
5972
+ ctx.on('agent/error', snapshotAtTurnEnd)
5973
+
5856
5974
  ctx.on('agent/created', (payload) => {
5857
5975
  const agent = payload?.agent
5858
5976
  const id = agent?.id ?? agent?.session?.id
@@ -5986,14 +6104,14 @@ export function apply(ctx, config = {}) {
5986
6104
  */
5987
6105
  let auditsResolved = false
5988
6106
  try {
5989
- const audits = await sweepLostAudits(sessionId, hostService.state(sessionId))
6107
+ const audits = await sweepEndedAudits(sessionId, hostService.state(sessionId))
5990
6108
  if (audits.mutations.length > 0) {
5991
6109
  factMutations.push(...audits.mutations)
5992
6110
  auditsResolved = true
5993
- brainNote = `${brainNote}\n(有 ${audits.lost} 条独立裁决**失联**(子会话已不在跑):已如实记为 unknown——不必再等它,重新交付这一步就会派一个新的评估者。)`
6111
+ brainNote = `${brainNote}\n(有 ${audits.settled} 条此前在等的独立裁决已收口:结论从子会话日志取回,或如实记为 unknown。)`
5994
6112
  }
5995
6113
  } catch (error) {
5996
- ctx.logger?.warn?.(`clearai: 失联裁决盘点失败 ${String(error?.message ?? error).slice(0, 160)}`)
6114
+ ctx.logger?.warn?.(`clearai: 已结束裁决盘点失败 ${String(error?.message ?? error).slice(0, 160)}`)
5997
6115
  }
5998
6116
  /**
5999
6117
  * **失联的侦察**:同一拍、同一套判据(原生子代理目录 + 本进程攥着的派遣)。
@@ -6005,7 +6123,7 @@ export function apply(ctx, config = {}) {
6005
6123
  const scouts = await sweepLostScouts(sessionId, hostService.state(sessionId), settledInBatch)
6006
6124
  if (scouts.mutations.length > 0) {
6007
6125
  factMutations.push(...scouts.mutations)
6008
- brainNote = `${brainNote}\n(有 ${scouts.lost} 条侦察**失联**(子会话已不在跑):已如实记下「结论收不回来」——不必再等它,需要那份材料就重新派一次。)`
6126
+ brainNote = `${brainNote}\n(有 ${scouts.lost} 条侦察已结束但结论未能取回:已如实记下。)`
6009
6127
  }
6010
6128
  } catch (error) {
6011
6129
  ctx.logger?.warn?.(`clearai: 失联侦察盘点失败 ${String(error?.message ?? error).slice(0, 160)}`)