clearai-dsh 0.2.8 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -472,6 +472,9 @@ export const CONFIG_KEYS = [
472
472
  'l4RejectSelfWritten',
473
473
  'minHypotheses',
474
474
  'requireTypedPromotion',
475
+ 'requireCriteriaVerdict',
476
+ 'requireLandedEntities',
477
+ 'requireLevelReasons',
475
478
  'bashDenyRules',
476
479
  'auditProvider',
477
480
  'auditTimeoutMs',
@@ -504,8 +507,11 @@ export const MECHANISM_TOOLS = {
504
507
  brain: ['SaveSkill', 'WriteMemory'],
505
508
  /** 账本两件:它长在 git 上(A 层用工作区自己的仓库,B 层用数据区的旁路账本,与世界线共用一本)。 */
506
509
  ledger: ['FileHistory', 'RestoreFile'],
507
- /** 领域语言:七个动词 = 词汇的全部写入口(注册/修订/废止) + 一个读入口(按概念取已知)。 */
508
- ontology: ['RegisterTerm', 'RegisterPredicate', 'ReviseTerm', 'RevisePredicate', 'DeprecateTerm', 'DeprecatePredicate', 'QueryKnowledge'],
510
+ /**
511
+ * 领域语言:九个写入口(概念注册/修订/废止 · **实例登记** · **带出处的断言** · 跳级理由)
512
+ * + 一个读入口(按概念取已知)。「约定」与「观测」各走各的门:概念不需依据,实例与断言必须带出处。
513
+ */
514
+ ontology: ['RegisterTerm', 'RegisterPredicate', 'ReviseTerm', 'RevisePredicate', 'DeprecateTerm', 'DeprecatePredicate', 'RegisterInstance', 'Assert', 'ExplainLevelSkip', 'QueryKnowledge'],
509
515
  }
510
516
  const TOOL_CATALOG = new Set(Object.values(MECHANISM_TOOLS).flat())
511
517
 
@@ -661,6 +667,18 @@ export function apply(ctx, config = {}) {
661
667
  * 机制侧缺省关(= 断言始终是加法),preset 里写 true——与 `blockedThreshold` 同一个模式。
662
668
  */
663
669
  requireTypedPromotion: config.requireTypedPromotion === true,
670
+ /**
671
+ * 两道与它对称的门(机制缺省关,preset 里开):
672
+ * · `requireLandedEntities`:断言主体还没落到实体图上 ⇒ 结案被拒。
673
+ * 它挡的是「本体写得漂亮、实体图是空的」——那是把结论停在散文上的另一种形态。
674
+ * · `requireLevelReasons`:有等级被跳过而没写理由 ⇒ 结案被拒。
675
+ * 它挡的是「一路只在最贵的那一级交付」——便宜的检查从未被走过,却没人知道为什么。
676
+ * 两条出口都诚实:补齐(RegisterInstance / Assert / ExplainLevelSkip)或如实 abandoned。
677
+ */
678
+ /** 判据修订门:改「怎样算完成」要带一份独立裁决的 auditKey(机制缺省关,preset 里开)。 */
679
+ requireCriteriaVerdict: config.requireCriteriaVerdict === true,
680
+ requireLandedEntities: config.requireLandedEntities === true,
681
+ requireLevelReasons: config.requireLevelReasons === true,
664
682
  bashDenyRules: config.bashDenyRules !== false,
665
683
  auditProvider: config.auditProvider ?? 'spawn',
666
684
  auditTimeoutMs: config.auditTimeoutMs ?? 240000,
@@ -724,19 +742,78 @@ export function apply(ctx, config = {}) {
724
742
  /** 宿主读面。缺了它整件事不成立——所以每个工具都显式报错,不静默降级。 */
725
743
  const host = () => ctx.get('clearai')
726
744
 
745
+ /**
746
+ * ═══ 事实的独立落账通道(pendingFacts) ═══════════════════════════════════
747
+ *
748
+ * **要解决的问题**:`audit/dispatched` 原来只写在**工具结果的 `mutations` 数组**里。
749
+ * 子代理是异步的:工具进入 `await` 之后,进程可能被 abort、宿主服务可能瞬态不可得。
750
+ * 一旦这条路出问题,整批变更随栈帧一起消失——`turnDemand` 的「有裁决在飞 ⇒ hold」不触发,
751
+ * `sweepEndedAudits` 也看不见,而模型只会原样重试。代价是一次已经算完的评审
752
+ * (耗时以分钟计)从账本上不存在。
753
+ *
754
+ * **修法的第一性原理**:事实不能寄存在"工具调用成功返回"这个易失载体上。
755
+ * 所以「派发」这一类事实在 **`await` 之前**写进这里,由两个通道各自落账:
756
+ * · 同一个工具结果的 `mutations`(及时:这一拍就进投影、卡就能说);
757
+ * · 下一拍的 pre-step(兜底:工具抛错/被 abort 也丢不掉)。
758
+ * 两条通道同源同形,宿主那一侧只有一个折法。
759
+ *
760
+ * **幂等**:同一个 id 只落一次(下表记已入账的 id),免得两条通道同一条事实落两遍。
761
+ */
762
+ const pendingFacts = new Map()
763
+ const pendingFactIds = new Set()
764
+ /** 把一条事实推进独立落账通道(返回它自己,方便调用方同时塞进工具结果的 mutations)。 */
765
+ function landFact(sessionId, mutation) {
766
+ if (mutation === null || typeof mutation !== 'object') return mutation
767
+ const key = `${sessionId}:${String(mutation.t)}:${String(mutation.id ?? mutation.step ?? '')}`
768
+ if (pendingFactIds.has(key)) return mutation
769
+ pendingFactIds.add(key)
770
+ const list = pendingFacts.get(sessionId) ?? []
771
+ list.push(mutation)
772
+ pendingFacts.set(sessionId, list)
773
+ return mutation
774
+ }
775
+ /** 取出并清空这一拍的兜底事实(只有 pre-step 调;重复取到空数组是正常的)。 */
776
+ function drainPendingFacts(sessionId) {
777
+ const list = pendingFacts.get(sessionId) ?? []
778
+ pendingFacts.delete(sessionId)
779
+ return list
780
+ }
781
+
727
782
  function hasState(state) {
728
783
  return state.goal !== null || state.plans.length > 0 || state.evidence.length > 0 || state.materials.length > 0
729
784
  }
730
785
 
786
+ /**
787
+ * 会话工作目录。**拿不到就返回 `null`,绝不回退 `process.cwd()`**。
788
+ *
789
+ * 为什么删掉那条回退:会话服务瞬态不可得时,回退会把 `clear/` 下的读面写进
790
+ * **dsh 进程自己的目录**——评估卡落到进程目录、而工具随后抛错,读账的人再也找不到它。
791
+ * **"写不出去"与"写到别处"是两件事**:前者是诚实的降级,后者是悄悄改了账本的位置。
792
+ * 删掉回退之后,「错地方」在类型上不可表示。
793
+ *
794
+ * 调用方纪律:纯展示用 `sessionCwdLabel()`;要拼路径用 `sessionFile()`;
795
+ * 需要裸 cwd 的(快照 / 引导铺设)自己判 `null` 并如实少做一件事。
796
+ */
731
797
  function sessionCwd(sessionId) {
732
798
  try {
733
799
  const session = ctx.get('sessions')?.get?.(sessionId)
734
800
  const cwd = session?.header?.cwd ?? session?.cwd
735
801
  if (typeof cwd === 'string' && cwd !== '') return cwd
736
802
  } catch {
737
- /* 会话服务不可用时退回进程目录 */
803
+ /* 服务不可得:如实返回 null,由调用方决定少做哪件事 */
738
804
  }
739
- return process.cwd()
805
+ return null
806
+ }
807
+
808
+ /** 卡片 / 提示文案里那个"工作目录":拿不到就如实说拿不到,不写一个假路径。 */
809
+ function sessionCwdLabel(sessionId) {
810
+ return sessionCwd(sessionId) ?? '(会话工作目录这一刻不可得)'
811
+ }
812
+
813
+ /** 会话工作区下的一个绝对路径;拿不到会话目录时返回 `null`(调用方跳过这次写入)。 */
814
+ function sessionFile(sessionId, ...parts) {
815
+ const cwd = sessionCwd(sessionId)
816
+ return cwd === null ? null : join(cwd, ...parts)
740
817
  }
741
818
 
742
819
  /**
@@ -792,7 +869,7 @@ export function apply(ctx, config = {}) {
792
869
  /** 一次工具调用的收尾:预演变更 → 卡片 → 返回值。 */
793
870
  function finish(hostService, sessionId, mutations) {
794
871
  return (value) => {
795
- const preview = hostService.preview(sessionId, mutations)
872
+ const preview = previewOf(hostService, sessionId, mutations)
796
873
  const result = {
797
874
  ...value,
798
875
  mutations,
@@ -816,7 +893,7 @@ export function apply(ctx, config = {}) {
816
893
  // 判定树:l1 / l2 / no_anchor / invalid / needs_audit。
817
894
 
818
895
  function admission(cwd, step, artifactsOverride) {
819
- const result = { ok: false, verified_by: 'invalid', missing: [], empty: [], structural: [], confirmed: [], needs_audit: false, hint: '' }
896
+ const result = { ok: false, verified_by: 'invalid', missing: [], directories: [], empty: [], structural: [], confirmed: [], needs_audit: false, hint: '' }
820
897
  if (step === null || typeof step !== 'object') {
821
898
  result.missing.push('step')
822
899
  return result
@@ -830,6 +907,15 @@ export function apply(ctx, config = {}) {
830
907
  }
831
908
 
832
909
  for (const artifact of artifacts) {
910
+ /**
911
+ * **目录不可得 ⇒ 按「读不到」处理**,不拿 `null` 去 `resolve`:
912
+ * 准入回答的是"这份观测收不收",而"我看不到工作区"不是"这份产物不存在"。
913
+ * 这时候把它记为缺失、如实说清原因,比让工具崩掉诚实。
914
+ */
915
+ if (typeof cwd !== 'string' || cwd === '') {
916
+ result.missing.push(artifact)
917
+ continue
918
+ }
833
919
  const absolute = isAbsolute(artifact) ? artifact : resolvePath(cwd, artifact)
834
920
  let stat = null
835
921
  try {
@@ -842,7 +928,24 @@ export function apply(ctx, config = {}) {
842
928
  continue
843
929
  }
844
930
  if (stat.isDirectory()) {
845
- result.empty.push(`${artifact}(空目录)`)
931
+ let files = 0
932
+ let bytes = 0
933
+ const countContents = (directory) => {
934
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
935
+ const entryPath = join(directory, entry.name)
936
+ if (entry.isDirectory()) countContents(entryPath)
937
+ else if (entry.isFile()) {
938
+ files += 1
939
+ bytes += statSync(entryPath).size
940
+ }
941
+ }
942
+ }
943
+ try {
944
+ countContents(absolute)
945
+ } catch {
946
+ // 目录在读数期间变化时,仍如实说明它不能作为物证。
947
+ }
948
+ result.directories.push(`${artifact}(目录不是物证,含 ${files} 个文件、${bytes} 字节)`)
846
949
  continue
847
950
  }
848
951
  if (stat.size === 0) {
@@ -877,6 +980,11 @@ export function apply(ctx, config = {}) {
877
980
  result.hint = `声明的产物没落盘:${result.missing.join(', ')}。三条合法出路:①把产物做出来;②改声明(RefinePlan 改判据、AmendPlan 换产物);③带因作废(VoidPlanStep)。`
878
981
  return result
879
982
  }
983
+ if (result.directories.length > 0) {
984
+ result.verified_by = 'l1'
985
+ result.hint = `声明的产物是目录:${result.directories.join(', ')}。目录不是物证,请在 artifacts 声明具体文件。`
986
+ return result
987
+ }
880
988
  if (result.empty.length > 0) {
881
989
  result.verified_by = 'l1'
882
990
  result.hint = `产物存在但是空:${result.empty.join(', ')}。空文件不是观测。`
@@ -922,6 +1030,11 @@ export function apply(ctx, config = {}) {
922
1030
  '- 只核对不发挥:你的职责是对照标准验收,不是重做方案、不是提改进建议。',
923
1031
  '- 你没有写入权限:任何需要产出文件的事都不是你的事。',
924
1032
  '- 对假设的裁决只有三个词:support 表示观测满足判定标准且不满足推翻条件,refute 表示满足推翻条件,inconclusive 表示无法判定。推翻是有价值的结果——不要为了让步骤通过而写 support。',
1033
+ '',
1034
+ '**回包的形状就是你的动作空间(字段长度由 schema 校验,超了会被拒):**',
1035
+ '- `basis` 是**一句话结论**,≤1200 字。**不要在这里写论证**——论证放 `refs`:逐条 `{path, line}` 指到你实际读过的文件与行,让第三方照着就能复核。',
1036
+ '- `shortfalls` 每条**必须**写成三格:`{criterion, what, missing}`——`criterion` 指明**判据的哪一条**(引它的编号或原文前 20 字),`what` 是你实际读到的(带 `path:line`),`missing` 是还缺什么才算满足。**一段散文不算一条缺口**:读的人无法逐条对照。',
1037
+ '- 没有缺口就给空数组;有缺口却只写"整体不足"等于没写。',
925
1038
  ].join('\n')
926
1039
 
927
1040
  const WORLDLINE_EXECUTOR_PERSONA = `## Executor人格:世界线执行者 (Worldline Executor)
@@ -971,15 +1084,58 @@ export function apply(ctx, config = {}) {
971
1084
  - **收敛就答**:够回答任务就停,不要为完整性把整个仓库读一遍。回灌的是**结论**,不是过程
972
1085
  流水账——父任务的上下文正是你被派出来节省的东西。`
973
1086
 
1087
+ /**
1088
+ * **裁决卡的形状 = 预算**。
1089
+ *
1090
+ * 一篇数千字的 `basis` 会让评估者的单步生成吃掉一兩分钟,而这论证本该由父会话展开。
1091
+ * **耗时不是靠提示词劝下来的**,而是靠协议的形状:`basis` 是给一句话的,
1092
+ * 论证放 `refs` 逐条指到文件与行;每条缺口写成「哪条判据 / 你看到什么 / 还缺什么」三格。
1093
+ * 字段长度由 schema 校验,runtime 会拒绝越界产出——这才叫机制。
1094
+ */
974
1095
  const VERDICT_SCHEMA = {
975
1096
  type: 'object',
976
1097
  properties: {
977
1098
  verdict: { type: 'string', enum: ['support', 'refute', 'inconclusive'] },
978
- basis: { type: 'string' },
979
- shortfalls: { type: 'array', items: { type: 'string' } },
1099
+ basis: { type: 'string', maxLength: 1200, description: '一句话结论(≤1200 字);展开的论证放 refs,不要写在这里' },
1100
+ shortfalls: {
1101
+ type: 'array',
1102
+ description: '每条缺口一格:哪条判据、你看到什么、还缺什么。不要写散文。',
1103
+ items: {
1104
+ type: 'object',
1105
+ properties: {
1106
+ criterion: { type: 'string', maxLength: 200, description: '判据的哪一条(引它自己的编号或原文前 20 字)' },
1107
+ what: { type: 'string', maxLength: 400, description: '你实际读到的是什么(带 path:line)' },
1108
+ missing: { type: 'string', maxLength: 200, description: '还缺什么才算满足' },
1109
+ },
1110
+ required: ['criterion', 'what', 'missing'],
1111
+ additionalProperties: false,
1112
+ },
1113
+ },
1114
+ refs: {
1115
+ type: 'array',
1116
+ description: '逐条证据引用:文件与行号。裁决要能被第三方照着复核。',
1117
+ items: {
1118
+ type: 'object',
1119
+ properties: {
1120
+ path: { type: 'string', maxLength: 300 },
1121
+ line: { type: 'integer' },
1122
+ },
1123
+ required: ['path'],
1124
+ additionalProperties: false,
1125
+ },
1126
+ },
980
1127
  reading: { type: 'string', description: '按裁决指标报出的读数:整串必须就是一个数(如 62.1 或 12%)' },
981
1128
  validity: { type: 'string', enum: ['usable', 'unusable'], description: '这份读数可不可用;不可用的世界线不参赛' },
982
1129
  },
1130
+ /**
1131
+ * `refs` **声明但不强制**。
1132
+ *
1133
+ * 它是我们想要的形状(逐条可复核),但把它写进 `required` 会让一份**完全可用**的裁决
1134
+ * 因为少一个数组而被 runtime 丢掉——那时"有没有裁决"就变成了抛硬币。这不是假设:
1135
+ * 一次真跑的评估者在正文里写清了 `verdict: support`,而结构化通道被 schema 拒收,
1136
+ * 目标于是永远结不了案。**先保证裁决到得了,再要求它可复核**;没给 refs 时,
1137
+ * 下面的正文兜底会把裁决从 markdown 卡片里取回来(并且如实标注它是被救回来的)。
1138
+ */
983
1139
  required: ['verdict', 'basis'],
984
1140
  additionalProperties: false,
985
1141
  }
@@ -1003,7 +1159,7 @@ export function apply(ctx, config = {}) {
1003
1159
  ...(gate.confirmed.length === 0 ? ['- (无)'] : gate.confirmed.map((item) => `- ${item.ref} — ${item.bytes} 字节 — ${item.digest ?? 'digest 不可得'}`)),
1004
1160
  ...(Array.isArray(gate.extra) && gate.extra.length > 0 ? ['', ...gate.extra] : []),
1005
1161
  '',
1006
- `工作目录:${sessionCwd(sessionId)}`,
1162
+ `工作目录:${sessionCwdLabel(sessionId)}`,
1007
1163
  '',
1008
1164
  '请只读上述坐标与执行记录,拿**已登记的判定标准**对照观测,给出裁决。',
1009
1165
  '准入只核验了「坐标存在且非空」——齐备不等于这一步做完了;判据里的断言(数值、口径、一致性)必须由你逐条核对。',
@@ -1011,28 +1167,105 @@ export function apply(ctx, config = {}) {
1011
1167
  ].join('\n')
1012
1168
  }
1013
1169
 
1170
+ /**
1171
+ * 裁决归一化。**两种形状都认**:新形状是
1172
+ * `shortfalls[{criterion, what, missing}]`,旧形状是 `shortfalls: string[]`
1173
+ * (老评估者、老账本、以及某些 provider 不校验 schema 时都会给旧形状)。
1174
+ *
1175
+ * 为什么必须显式兼容:契约升级时最坏的做法是"新形状之外一律丢"——
1176
+ * 那会让一份合法但旧式的裁决在账上变成"没有缺口"。两种都在,各自如实。
1177
+ */
1014
1178
  function normalizeVerdict(value) {
1015
1179
  const verdict = typeof value?.verdict === 'string' ? value.verdict.toLowerCase() : 'inconclusive'
1180
+ const shortfalls = []
1181
+ for (const item of Array.isArray(value?.shortfalls) ? value.shortfalls : []) {
1182
+ if (typeof item === 'string') {
1183
+ const plain = item.trim()
1184
+ // 旧形状:一整段散文。**不丢**,但把它按"还没有结构"如实标注,而不是硬塞成三格。
1185
+ if (plain !== '') shortfalls.push({ criterion: '未结构化(旧式裁决)', what: plain.slice(0, 400), missing: '' })
1186
+ continue
1187
+ }
1188
+ if (item === null || typeof item !== 'object') continue
1189
+ const criterion = String(item.criterion ?? '').trim()
1190
+ const what = String(item.what ?? '').trim()
1191
+ const missing = String(item.missing ?? '').trim()
1192
+ if (criterion === '' && what === '' && missing === '') continue
1193
+ shortfalls.push({ criterion: criterion.slice(0, 200), what: what.slice(0, 400), missing: missing.slice(0, 200) })
1194
+ }
1195
+ const refs = []
1196
+ for (const item of Array.isArray(value?.refs) ? value.refs : []) {
1197
+ if (item === null || typeof item !== 'object') continue
1198
+ const path = String(item.path ?? '').trim()
1199
+ if (path === '') continue
1200
+ const line = Number.isInteger(item.line) ? item.line : null
1201
+ refs.push({ path: path.slice(0, 300), line })
1202
+ }
1203
+ const basis = typeof value?.basis === 'string' && value.basis.trim() !== '' ? value.basis.trim() : '评估者未给出依据'
1016
1204
  return {
1017
1205
  verdict: ['support', 'refute', 'inconclusive'].includes(verdict) ? verdict : 'inconclusive',
1018
- basis: typeof value?.basis === 'string' && value.basis.trim() !== '' ? value.basis.trim() : '评估者未给出依据',
1019
- shortfalls: Array.isArray(value?.shortfalls) ? value.shortfalls.filter((item) => typeof item === 'string') : [],
1206
+ // 截断是**兜底**:schema 已声明 maxLength,越界的产出本不该到这里;真到了也不能让账本吃下五千字。
1207
+ // 截断标记**算在预算内**:申报多少就必须是多少。
1208
+ basis: basis.length > 1200 ? `${basis.slice(0, 1170)}…(裁到 1200 字;完整论证应由 refs 指认)` : basis,
1209
+ shortfalls,
1210
+ refs,
1020
1211
  reading: typeof value?.reading === 'string' && value.reading.trim() !== '' ? value.reading.trim() : null,
1021
1212
  validity: value?.validity === 'usable' || value?.validity === 'unusable' ? value.validity : null,
1022
1213
  }
1023
1214
  }
1024
1215
 
1216
+ /**
1217
+ * 缺口的**一行话**。两种形状都认:新形状是 `{criterion, what, missing}` 三格,
1218
+ * 旧形状是纯字符串(老裁决/内核自己落的 code)。**一行一条**,不再把几段散文拼成一段。
1219
+ */
1220
+ function verdictText(shortfalls) {
1221
+ if (!Array.isArray(shortfalls)) return ''
1222
+ return shortfalls
1223
+ .map((item) => {
1224
+ if (typeof item === 'string') return item
1225
+ if (item === null || typeof item !== 'object') return ''
1226
+ const parts = [String(item.criterion ?? '').trim(), String(item.what ?? '').trim(), String(item.missing ?? '').trim()].filter((part) => part !== '')
1227
+ return parts.join(' · ')
1228
+ })
1229
+ .filter((line) => line !== '')
1230
+ .join('; ')
1231
+ }
1232
+
1025
1233
  function parseLooseJson(output) {
1026
1234
  const raw = (output ?? [])
1027
1235
  .filter((block) => block?.type === 'text')
1028
1236
  .map((block) => block.text)
1029
1237
  .join('\n')
1030
1238
  const match = raw.match(/\{[\s\S]*\}/)
1031
- if (match === null) return { verdict: 'inconclusive', basis: '评估者没有返回可解析的裁决', shortfalls: ['card_unparsable'] }
1032
- try {
1033
- return JSON.parse(match[0])
1034
- } catch {
1035
- return { verdict: 'inconclusive', basis: '评估者的裁决无法解析', shortfalls: ['card_unparsable'] }
1239
+ if (match !== null) {
1240
+ try {
1241
+ return JSON.parse(match[0])
1242
+ } catch {
1243
+ /* 不是 JSON:落到下面的正文卡片形态 */
1244
+ }
1245
+ }
1246
+ /**
1247
+ * **正文卡片兜底**。
1248
+ *
1249
+ * 为什么必须有:评估者经常不吐 JSON,而是写一张 markdown 评估卡
1250
+ * (`## 评估卡 · …` / `**verdict: support**` / `**basis**:…`)。那时结构化通道可能整个是空的
1251
+ * (模型没走结构化输出,或形状不合 schema),只认 JSON 就等于**把一份写得清清楚楚的裁决丢掉**
1252
+ * ——账上只剩下"无法判定",而真实原因是我们没读。这不是评估者没说,是我们没听。
1253
+ *
1254
+ * 取法刻意保守:只认 `verdict:` 后面紧跟的那三个词之一(允许 markdown 加粗),
1255
+ * `basis` 取它之后到下一个标题/表格前的一段(有长度上限)。取不到就如实说取不到——
1256
+ * 猜一份 support 比丢掉一份 refute 坏得多。
1257
+ */
1258
+ const verdictMatch = raw.match(/verdict\s*[::]\s*\**\s*(support|refute|inconclusive)\b/i)
1259
+ if (verdictMatch === null) return { verdict: 'inconclusive', basis: '评估者没有返回可解析的裁决', shortfalls: ['card_unparsable'] }
1260
+ const after = raw.slice(verdictMatch.index + verdictMatch[0].length)
1261
+ const basisMatch = after.match(/basis\s*\*{0,2}\s*[::]\s*\**\s*([\s\S]{4,1200}?)(?=\n\s*\n|\n#{1,6}\s|\n\|)/i)
1262
+ const basis = (basisMatch === null ? after.slice(0, 400) : basisMatch[1]).replace(/\s+/g, ' ').trim()
1263
+ return {
1264
+ verdict: verdictMatch[1].toLowerCase(),
1265
+ basis: basis === '' ? '评估者给了裁决但没写依据(正文卡片里没有可取的 basis)' : basis,
1266
+ shortfalls: [],
1267
+ /** 读的人要能分辨:这条裁决是从正文里救回来的,不是结构化通道给的。 */
1268
+ salvaged_from_text: true,
1036
1269
  }
1037
1270
  }
1038
1271
 
@@ -1625,16 +1858,16 @@ export function apply(ctx, config = {}) {
1625
1858
  if (recovered !== null && recovered.ok === true) {
1626
1859
  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() }
1627
1860
  const cardPath = writeAuditCard(sessionId, audit.step, card)
1628
- 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 })
1861
+ 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, digest: audit.digest ?? null })
1629
1862
  lines.push(`${audit.step}:裁决从子会话日志取回(${recovered.verdict.verdict})`)
1630
1863
  continue
1631
1864
  }
1632
1865
  if (recovered !== null && recovered.ok !== true) {
1633
- mutations.push({ t: 'audit/settled', id: audit.id, step: audit.step, verdict: 'unknown', basis: `评估者已结束,但未正常完成(${recovered.stopReason})。`, shortfalls: ['audit_incomplete'], card_path: null })
1866
+ mutations.push({ t: 'audit/settled', id: audit.id, step: audit.step, verdict: 'unknown', basis: `评估者已结束,但未正常完成(${recovered.stopReason})。`, shortfalls: ['audit_incomplete'], card_path: null, digest: audit.digest ?? null })
1634
1867
  lines.push(`${audit.step}:评估者已结束、未正常完成(记为 unknown)`)
1635
1868
  continue
1636
1869
  }
1637
- mutations.push({ t: 'audit/settled', id: audit.id, step: audit.step, verdict: 'unknown', basis: '评估者已结束(宿主目录报告),但其结论未能从子会话日志取回。', shortfalls: ['auditor_ended_uncollected'], card_path: null })
1870
+ mutations.push({ t: 'audit/settled', id: audit.id, step: audit.step, verdict: 'unknown', basis: '评估者已结束(宿主目录报告),但其结论未能从子会话日志取回。', shortfalls: ['auditor_ended_uncollected'], card_path: null, digest: audit.digest ?? null })
1638
1871
  lines.push(`${audit.step}:评估者已结束、结论未取回(记为 unknown)`)
1639
1872
  }
1640
1873
  return { mutations, settled: mutations.length, lines }
@@ -1740,6 +1973,88 @@ export function apply(ctx, config = {}) {
1740
1973
  return null
1741
1974
  }
1742
1975
 
1976
+ /**
1977
+ * ═══ 裁决复用:同态不重派 ═════════════════════════════════════════════════
1978
+ *
1979
+ * **代价**:同一个目标在零工具调用、状态逐字未变的情况下可以被反复结案,
1980
+ * 每次都从头派一个评估者、各烧掉一两分钟;而上一次的结论被平台错误丢掉之后,
1981
+ * 模型只会原样再烧一遍。**没有算术依据的重派,就是把等待当成进展。**
1982
+ *
1983
+ * **修法**:复用判据是**状态内容**,不是"模型又喊了一次结案"。
1984
+ * digest 覆盖:裁决种类、被裁决的步、目标修订号、准入坐标、证据集合。
1985
+ * 只要这五项一字不变,无论 CloseGoal 喊多少次都只评审一次;证据一变 digest 就变,
1986
+ * **必然**重派。两种语义都由算术决定,不靠模型自觉。
1987
+ *
1988
+ * 只有**落定过的裁决**才可复用(`verdict` 非 null 且不是 unknown):
1989
+ * `unknown` 不是裁决,它只说明"那一次没成",那正是应该重派的理由。
1990
+ */
1991
+ function auditDigest(kind, step, plan, state, gate) {
1992
+ /**
1993
+ * **digest 只盖「材料」,不盖「上一次裁决留下的东西」。**
1994
+ *
1995
+ * 为什么这一条是这套复用能不能用的分水岭:一次不确定的结案自己会落一条
1996
+ * `evidence/recorded`(`anchor:'auditor'`)。原来 digest 把证据集合整个算进去,
1997
+ * 于是**每重试一次 digest 就变一次**,复用永远命中不了——模型每喊一次结案就再烧两三分钟,
1998
+ * 而两次之间它什么都没改。这不是"新证据",是同一条评审自己的回声。
1999
+ *
2000
+ * 所以这里只取**可能改变结论的材料**:
2001
+ * · 目标修订号(判据/假设换了内容才会变);
2002
+ * · 计划的步与产物(交付了什么);
2003
+ * · 观测(state.materials);
2004
+ * · 原始假设(claim / status / 断言)——**刻意不用派生读数**:
2005
+ * `supportedLevel` / `refutations` / `inconclusive` 都是证据算出来的,
2006
+ * 而审计留下的那条证据会把它们改掉,用它就等于把回声又算进来一次;
2007
+ * · 已升格事实;
2008
+ * · **非审计来源**的证据(自判的 L0–L2 是真材料,保留)。
2009
+ *
2010
+ * 于是语义变成:材料变了 ⇒ 必然重审;材料没变 ⇒ 复用上次裁决,并把这件事说明白。
2011
+ */
2012
+ const material = (Array.isArray(state?.evidence) ? state.evidence : [])
2013
+ .filter((item) => String(item?.anchor ?? '') !== 'auditor')
2014
+ .map((item) => `${String(item?.id ?? '')}:${String(item?.verdict ?? '')}:${String(item?.level ?? '')}`)
2015
+ const hypotheses = (Array.isArray(state?.hypotheses) ? state.hypotheses : []).map((item) =>
2016
+ [String(item?.id ?? ''), String(item?.status ?? ''), String(item?.claim ?? '').replace(/\s+/g, ' '), JSON.stringify(item?.assertions ?? null)].join(':'),
2017
+ )
2018
+ const materials = (Array.isArray(state?.materials) ? state.materials : []).map((item) => `${String(item?.id ?? '')}:${String(item?.digest ?? '')}`)
2019
+ const facts = (Array.isArray(state?.facts) ? state.facts : []).map((item) => `${String(item?.id ?? '')}:${String(item?.level ?? '')}:${JSON.stringify(item?.assertions ?? null)}`)
2020
+ /**
2021
+ * 步的**判据**与产物一起算材料:判据一变,"这一步算不算做完"就是另一个问题
2022
+ * (`evaluatorPrompt` 会把判据逐字交给评估者)——不把它算进来会出现
2023
+ * "改了判据却复用旧裁决"这种明显错的复用。
2024
+ */
2025
+ const steps = (Array.isArray(plan?.steps) ? plan.steps : []).map((item) => `${String(item?.id ?? '')}:${String(item?.status ?? '')}:${String(item?.done_criteria ?? '')}:${(Array.isArray(item?.artifacts) ? item.artifacts : []).join('|')}`)
2026
+ const goal = `${String(state?.goal?.id ?? '')}:${Number(state?.goal?.revision ?? 0)}`
2027
+ /**
2028
+ * **准入坐标里只有"产物"算材料**。
2029
+ *
2030
+ * `gate.confirmed` 在两条路上形状不同:证据审计那一侧是**产物路径 + 字节数 + 内容摘要**
2031
+ * (文件内容一变,digest 就变——这是"交付的东西真的改了吗"的唯一硬信号);
2032
+ * 目标审计那一侧是 `evidence:` / `hypothesis:` 两类引用(证据集合与派生读数)——
2033
+ * 把它们算进来,就等于又把上一次评审的回声算进来一次。
2034
+ * 所以:留下产物,去掉回声。
2035
+ */
2036
+ const artifacts = (Array.isArray(gate?.confirmed) ? gate.confirmed : [])
2037
+ .filter((item) => {
2038
+ const ref = String(item?.ref ?? '')
2039
+ return !ref.startsWith('evidence:') && !ref.startsWith('hypothesis:')
2040
+ })
2041
+ .map((item) => `${String(item?.ref ?? '')}:${String(item?.bytes ?? '')}:${String(item?.digest ?? '')}`)
2042
+ const root = createHash('sha256').update(JSON.stringify([kind, step.id, goal, steps, materials, hypotheses, facts, material, artifacts])).digest('hex')
2043
+ return root.slice(0, 16)
2044
+ }
2045
+
2046
+ /** 投影里最近一条与该 digest 相同、且**真的给出了裁决**的结算事实。 */
2047
+ function reuseAudit(state, stepId, digest) {
2048
+ const matched = (state?.audits ?? []).filter((audit) => String(audit?.step ?? '') === String(stepId) && String(audit?.digest ?? '') === digest)
2049
+ for (let index = matched.length - 1; index >= 0; index -= 1) {
2050
+ const audit = matched[index]
2051
+ const verdict = String(audit?.verdict ?? '')
2052
+ if (!['support', 'refute', 'inconclusive'].includes(verdict)) continue
2053
+ return audit
2054
+ }
2055
+ return null
2056
+ }
2057
+
1743
2058
  /** 侦察的子 run:只读、fresh context、无人盯(与评估者共用同一条派遣原语)。 */
1744
2059
  async function runScout(sessionId, agent, plan, step, brief, trigger, signal) {
1745
2060
  const mutations = []
@@ -1749,7 +2064,12 @@ export function apply(ctx, config = {}) {
1749
2064
  if (reusedFrom !== null) {
1750
2065
  return { ok: true, conclusion: reusedFrom.conclusion, reused: true, digest, from: reusedFrom.id, mutations }
1751
2066
  }
1752
- const scoutId = `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
2067
+ /**
2068
+ * **派发事实在 `await` 之前落账**。`id` 与任务摘要绑定:同一件事重复派遣得到同一个键,
2069
+ * 于是下面那条带上子会话 id 的完整事实按 id **覆盖**它,账上不会留两条。
2070
+ */
2071
+ const scoutId = `scout:pending:${step.id}:${digest}`
2072
+ landFact(sessionId, { t: 'scout/dispatched', id: scoutId, step: step.id, plan: plan?.id ?? null, goal: step.goal ?? null, trigger, child: null, capability: null, digest, degraded_reason: null, status: 'dispatching' })
1753
2073
  const dispatched = await dispatchSubRun({
1754
2074
  label: `侦察 · ${trigger} · ${step.id}`,
1755
2075
  persona: SCOUT_PERSONA,
@@ -1765,7 +2085,7 @@ export function apply(ctx, config = {}) {
1765
2085
  '# 要你去查的缺口',
1766
2086
  brief === '' ? '(评估者没给具体缺口:请找出这一步还差哪些一手证据)' : brief,
1767
2087
  '',
1768
- `工作目录:${sessionCwd(sessionId)}`,
2088
+ `工作目录:${sessionCwdLabel(sessionId)}`,
1769
2089
  '',
1770
2090
  '只读上述范围,把**结论**作为最终答复回灌(不是过程流水账)。查不到就如实说查过哪里。',
1771
2091
  ].join('\n'),
@@ -1779,7 +2099,7 @@ export function apply(ctx, config = {}) {
1779
2099
  return { ok: false, note: `侦察派不出去(${dispatched.reason})`, mutations }
1780
2100
  }
1781
2101
  const childId = String(dispatched.run.id)
1782
- 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 })
2102
+ 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, status: 'dispatched' })
1783
2103
  /**
1784
2104
  * **派出去就返回**(与「世界线执行者」同一个病,同一个修法)。
1785
2105
  *
@@ -1818,8 +2138,68 @@ export function apply(ctx, config = {}) {
1818
2138
  const mutations = []
1819
2139
  const auditKey = `a-${step.id}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
1820
2140
  const key = `${sessionId}:${kind}:${step.id}`
2141
+ const digest = auditDigest(kind, step, plan, stateOf(sessionId), gate)
1821
2142
  let entry = pendingAudits.get(key)
2143
+ /**
2144
+ * **同态复用**:先看在飞的(pendingAudits),再看**已经落定的**(投影里 digest 相同且给出了裁决的那一条)。
2145
+ * 顺序不能反:在飞的那一次还没结论,复用一条更早的裁决会让"刚派出去的"变成孤儿。
2146
+ */
2147
+ /**
2148
+ * **三类裁决都能复用**——判据是"材料变没变",与"谁在问"无关。
2149
+ *
2150
+ * 交付那一步(`evidence_audit`)原来被排除在外,理由是"每次交付都该留一条自己的裁决行"。
2151
+ * 那条理由只对了一半:该留的是**这次交付发生过**,而不是"又烧了一次评估者"。
2152
+ * 所以复用照样说话——落一条 `audit/reused`(谁复用了谁的裁决、凭哪个 digest),
2153
+ * 而"重复交付"这件事由**连拦计数**接着管(见 `AdvancePlan` 里那条 `audit_reused` 分支):
2154
+ * 材料没变就重来 ⇒ 计数 +1,达阈值把计划置 blocked 停下等人。
2155
+ * 于是"每次交付都被记下来"与"不重复花钱"两件事同时成立。
2156
+ */
1822
2157
  if (entry === undefined) {
2158
+ const reused = reuseAudit(stateOf(sessionId), step.id, digest)
2159
+ if (reused !== null) {
2160
+ /**
2161
+ * 落一条 `audit/reused` 而不是静默返回:**"这次没花钱"也要是账上的事实**,
2162
+ * 否则读账的人分不清"复用了一次裁决"和"这次根本没派"。
2163
+ */
2164
+ mutations.push({
2165
+ t: 'audit/reused',
2166
+ id: auditKey,
2167
+ step: step.id,
2168
+ plan: plan?.id ?? 'goal',
2169
+ kind,
2170
+ digest,
2171
+ by: String(reused.id ?? ''),
2172
+ })
2173
+ return {
2174
+ verdict: String(reused.verdict ?? 'inconclusive'),
2175
+ basis: String(reused.basis ?? ''),
2176
+ shortfalls: Array.isArray(reused.shortfalls) ? reused.shortfalls : [],
2177
+ cardPath: reused.card_path ?? null,
2178
+ /**
2179
+ * **出处不因复用而消失**:这份裁决当初是哪张卡、哪个评估者会话写的,
2180
+ * 照旧带出来——否则复用会让证据变成一个点不开的东西,而"可复核"正是它的全部价值。
2181
+ */
2182
+ evaluatorSession: reused.child ?? null,
2183
+ reusedFrom: String(reused.id ?? ''),
2184
+ reused: true,
2185
+ digest,
2186
+ mutations,
2187
+ }
2188
+ }
2189
+ }
2190
+ if (entry === undefined) {
2191
+ /**
2192
+ * **派发事实在 `await` 之前就落账**——这是这条通道存在的全部理由。
2193
+ *
2194
+ * 工具在 `await` 期间可能被 abort、宿主 fiber 可能瞬态掉线;那时结果永远不回来,
2195
+ * 而"我派过一个评估者"是**已经发生的事实**。把它写在 await 之后,等于把事实寄存在
2196
+ * 一个会被撤销的栈帧里:`hold` 不触发、`sweepEndedAudits` 看不见,模型只会原样重试。
2197
+ *
2198
+ * `id` 取一个与裁决 digest 绑定的**稳定键**:同一个状态反复结案得到同一个键,
2199
+ * 于是下面那次"带上子会话 id 的完整事实"按 id 覆盖它,而不是在账上留两条。
2200
+ */
2201
+ const pendingId = `audit:pending:${kind}:${step.id}:${digest}`
2202
+ landFact(sessionId, { t: 'audit/dispatched', id: pendingId, step: step.id, plan: plan?.id ?? 'goal', kind, digest, capability: null, evaluator_session: null, status: 'dispatching' })
1823
2203
  const dispatched = await dispatchSubRun({
1824
2204
  label: `${kind === 'goal_audit' ? '目标评估者' : kind === 'worldline_audit' ? '世界线评估者' : '评估者'} · ${step.id}`,
1825
2205
  persona: EVALUATOR_DISCIPLINE,
@@ -1830,10 +2210,16 @@ export function apply(ctx, config = {}) {
1830
2210
  signal,
1831
2211
  })
1832
2212
  if (dispatched.ok !== true) {
2213
+ /**
2214
+ * 派不出去:把那条"正在派"如实结掉。结算与派发**同 id**,且上面那条 pending
2215
+ * 走的是独立落账通道、在 `withPendingFacts` 的合并结果里排在前面,所以折法先建记录、
2216
+ * 再结它——账上不会留一条永远 `verdict=null` 的悬空派发。
2217
+ */
2218
+ mutations.push({ t: 'audit/settled', id: pendingId, step: step.id, verdict: 'unknown', basis: `独立评估者无法派遣(${dispatched.reason})`, shortfalls: ['audit_dispatch_failed'], card_path: null, digest })
1833
2219
  return { verdict: 'unknown', basis: `独立评估者无法派遣(${dispatched.reason})`, shortfalls: ['audit_dispatch_failed'], cardPath: null, mutations }
1834
2220
  }
1835
- mutations.push({ t: 'audit/dispatched', id: auditKey, step: step.id, plan: plan?.id ?? 'goal', kind, evaluator_session: String(dispatched.run.id), capability: dispatched.capability })
1836
- entry = { sessionId, run: dispatched.run, capability: dispatched.capability, auditKey, step: step.id, plan: plan?.id ?? 'goal', kind, settled: undefined }
2221
+ mutations.push({ t: 'audit/dispatched', id: pendingId, step: step.id, plan: plan?.id ?? 'goal', kind, evaluator_session: String(dispatched.run.id), capability: dispatched.capability, digest, status: 'dispatched' })
2222
+ entry = { sessionId, run: dispatched.run, capability: dispatched.capability, auditKey: pendingId, step: step.id, plan: plan?.id ?? 'goal', kind, settled: undefined }
1837
2223
  pendingAudits.set(key, entry)
1838
2224
  entry.settled = dispatched.run.result.then(
1839
2225
  (value) => ({ ok: true, value }),
@@ -1865,8 +2251,8 @@ export function apply(ctx, config = {}) {
1865
2251
  * 而"评估者没有悬空"这条不变量也只能红着,连解释都拿不出证据。
1866
2252
  */
1867
2253
  const settleUnknown = (basis, shortfalls) => {
1868
- mutations.push({ t: 'audit/settled', id: entry.auditKey, step: step.id, verdict: 'unknown', basis, shortfalls, card_path: null })
1869
- return { verdict: 'unknown', basis, shortfalls, cardPath: null, mutations }
2254
+ mutations.push({ t: 'audit/settled', id: entry.auditKey, step: step.id, verdict: 'unknown', basis, shortfalls, card_path: null, digest })
2255
+ return { verdict: 'unknown', basis, shortfalls, cardPath: null, digest, mutations }
1870
2256
  }
1871
2257
  const settled = outcome.value
1872
2258
  const stopReason = String(settled?.stopReason ?? 'completed')
@@ -1884,13 +2270,14 @@ export function apply(ctx, config = {}) {
1884
2270
  if (cardPath === null) {
1885
2271
  return settleUnknown('评估卡落盘失败:裁决降级', ['card_persist_failed'])
1886
2272
  }
1887
- mutations.push({ t: 'audit/settled', id: entry.auditKey, step: step.id, verdict: verdict.verdict, basis: verdict.basis, shortfalls: verdict.shortfalls, card_path: cardPath })
1888
- return { ...verdict, cardPath, mutations }
2273
+ mutations.push({ t: 'audit/settled', id: entry.auditKey, step: step.id, verdict: verdict.verdict, basis: verdict.basis, shortfalls: verdict.shortfalls, card_path: cardPath, digest })
2274
+ return { ...verdict, cardPath, digest, mutations }
1889
2275
  }
1890
2276
 
1891
2277
  /** 评估卡落盘(系统的面)。写不进 → 返回 null,调用方 fail-closed。 */
1892
2278
  function writeAuditCard(sessionId, stepId, card) {
1893
- const file = join(sessionCwd(sessionId), 'clear', 'evidence', 'audits', String(stepId), `${String(card.auditor_run_id)}.json`)
2279
+ const file = sessionFile(sessionId, 'clear', 'evidence', 'audits', String(stepId), `${String(card.auditor_run_id)}.json`)
2280
+ if (file === null) return null
1894
2281
  try {
1895
2282
  writeTextFile(file, `${JSON.stringify(card, null, 2)}\n`)
1896
2283
  return file
@@ -2080,9 +2467,47 @@ export function apply(ctx, config = {}) {
2080
2467
  return { ok: false, response: fail('host_missing', '宿主包 clearai-dsh 没有挂载:状态机不在(它是会话日志的投影)。先装上它,再谈工具。') }
2081
2468
  }
2082
2469
  const sessionId = String(exec.agent?.id ?? 'unknown')
2470
+ /**
2471
+ * **入口检查宿主的两件纯读面**(`state` / `derive`)。宿主 fiber 可以在一次调用的
2472
+ * `await` 期间瞬态掉出 ACTIVE:`derive` 在派发前成功、评估者跑完之后再读宿主就抛,
2473
+ * 工具抛错时 `mutations` 里那条 `audit/dispatched` 随栈帧一起没了。
2474
+ *
2475
+ * 宿主半的属性式访问已改成降级(见 `ui/lib/index.js`),但**这一侧不能赌别人修好了**:
2476
+ * 不通就当场把已经落账的事实交出去(而不是抛),`withPendingFacts` 会把独立落账通道里的事实
2477
+ * 并进这个失败结果,所以"派过"这件事不丢。
2478
+ *
2479
+ * **刻意不摸 `preview`**:它是"假定这批变更已落账会怎样"的读面,测试的宿主桩里它会把状态推进一次
2480
+ * (`applyMutations`),拿它当体检会把状态推进一次。真正需要它的那几处(结案裁决)自己带兜底。
2481
+ */
2482
+ try {
2483
+ hostService.state(sessionId)
2484
+ hostService.derive(sessionId)
2485
+ } catch (error) {
2486
+ return {
2487
+ ok: false,
2488
+ response: fail(
2489
+ 'host_unavailable',
2490
+ `宿主读面这一刻不可用(${String(error?.message ?? error).slice(0, 200)})。**已经发生的事实照旧落账**(派发记录不丢);现在先看当前账本,再谈重试——不要重做一遍已经做过的事。`,
2491
+ { mutations: [] },
2492
+ ),
2493
+ }
2494
+ }
2083
2495
  return { ok: true, hostService, sessionId, state: hostService.state(sessionId), mutations: [], done: null }
2084
2496
  }
2085
2497
 
2498
+ /**
2499
+ * **读面兜底**:`preview` 在真实运行里是"宿主 fiber 已经掉线"时的抛出点(见 `open` 的注释)。
2500
+ * 拿不到就返回 null,调用方如实降级——**绝不把整批已经落账的变更丢掉**。
2501
+ */
2502
+ function previewOf(hostService, sessionId, mutations) {
2503
+ try {
2504
+ return hostService.preview(sessionId, mutations)
2505
+ } catch (error) {
2506
+ ctx.logger?.warn?.(`clearai kernel: 读面不可用 ${String(error?.message ?? error).slice(0, 160)}`)
2507
+ return null
2508
+ }
2509
+ }
2510
+
2086
2511
  // ═══ 无人值守续跑窗口(autonomy=unattended) ═══════════════════════════════
2087
2512
  //
2088
2513
  // 事实与驱动的分工:P3 说「事实只能由系统算出来」。宿主的 `goals` 服务**不是**第二本目标账——
@@ -2093,7 +2518,7 @@ export function apply(ctx, config = {}) {
2093
2518
  // 为什么必须是**机制**而不是嘱咐:ClearAI 的无人值守档靠「系统自己开下一阶段」活着,
2094
2519
  // 而 DSH 里一个回合结束后想让会话继续,只有宿主的回合驱动能做到。模型自己说"我继续"是无力的。
2095
2520
 
2096
- const CONTINUATION_CODES = { stalled: 'clearai_loop_stalled', abandoned: 'clearai_loop_abandoned' }
2521
+ const CONTINUATION_CODES = { stalled: 'clearai-loop-stalled', abandoned: 'clearai-loop-abandoned' }
2097
2522
 
2098
2523
  /**
2099
2524
  * 计划审阅的两个标签:它们是**机制**定义的措辞,不是模型的即兴表达。
@@ -2653,7 +3078,8 @@ export function apply(ctx, config = {}) {
2653
3078
  const rows = host()?.derive?.(sessionId)?.factRows
2654
3079
  const body = renderFactsIndex(Array.isArray(rows) ? { ...state, facts: rows } : state)
2655
3080
  if (body === null) return null
2656
- const file = join(sessionCwd(sessionId), 'clear', 'knowledge', 'facts', 'INDEX.md')
3081
+ const file = sessionFile(sessionId, 'clear', 'knowledge', 'facts', 'INDEX.md')
3082
+ if (file === null) return null
2657
3083
  try {
2658
3084
  if (existsSync(file) && readFileSync(file, 'utf8') === body) return null
2659
3085
  writeTextFile(file, body)
@@ -2692,7 +3118,8 @@ export function apply(ctx, config = {}) {
2692
3118
  if (isSpawnedChild(sessionId)) return ''
2693
3119
  try {
2694
3120
  const body = hostService.domain.renderShelf(sessionId, Array.isArray(mutations) ? mutations : [])
2695
- const file = join(sessionCwd(sessionId), 'clear', 'ontology', 'domain.md')
3121
+ const file = sessionFile(sessionId, 'clear', 'ontology', 'domain.md')
3122
+ if (file === null) return null
2696
3123
  if (existsSync(file) && readFileSync(file, 'utf8') === body) return ''
2697
3124
  writeTextFile(file, body)
2698
3125
  return `\n词汇货架已更新:${join('clear', 'ontology', 'domain.md')}(概念 / 谓词 / 图 / 引用)。**它是读面,不是权威**——要改词汇就调注册 / 修订 / 废止动词。`
@@ -2702,6 +3129,60 @@ export function apply(ctx, config = {}) {
2702
3129
  }
2703
3130
  }
2704
3131
 
3132
+ /**
3133
+ * **目标文档**:把当前目标(一句话、判据全文、假设、修订留痕)落成
3134
+ * `clear/goals/{goalId}.md`——本体声明里 `goal.persistence` 早就写了这个落点,
3135
+ * 只是从前没有人写它。
3136
+ *
3137
+ * 为什么需要它:判据全文是每一拍都要用的东西,但**不该每一拍都进上下文**——
3138
+ * 卡里给压缩版 + 一个"全文在哪"的指针,需要逐字核对的场合(评估者、人复核、模型自己重读)
3139
+ * 去读这份文件。这样"卡瘦了"不会变成"判据丢了"。
3140
+ *
3141
+ * 幂等:内容没变就不重写(与两份货架同一条纪律)。
3142
+ */
3143
+ function ensureGoalDoc(sessionId, state, derived) {
3144
+ const goal = state?.goal ?? null
3145
+ if (goal === null) return ''
3146
+ if (isSpawnedChild(sessionId)) return ''
3147
+ try {
3148
+ const hypotheses = Array.isArray(state?.hypotheses) ? state.hypotheses : []
3149
+ const history = Array.isArray(goal.criteriaHistory) ? goal.criteriaHistory : []
3150
+ const lines = [
3151
+ `# 目标 ${goal.id}(rev${goal.revision} · ${goal.status})`,
3152
+ '',
3153
+ `- **一句话**:${goal.headline ?? '(未写)'}`,
3154
+ `- **主张**:${goal.claim}`,
3155
+ `- **升格门槛**:${goal.promote_at_level ?? 'L3'}`,
3156
+ `- **判据(全文)**${Array.isArray(goal.criteria) && goal.criteria.length > 0 ? '' : '(未逐条拆分)'}:`,
3157
+ ...(Array.isArray(goal.criteria) && goal.criteria.length > 0 ? goal.criteria.map((item, index) => ` ${index + 1}. ${item}`) : [` ${goal.done_criteria}`]),
3158
+ goal.criteria_note === null || goal.criteria_note === undefined ? '' : `- **判据背景**(不参与判定):${goal.criteria_note}`,
3159
+ '',
3160
+ '> 这份文件由系统按账本落盘(投影产物);权威是账本里的 `goal/set` 与 `criteria/revised`。',
3161
+ '',
3162
+ `## 假设(${hypotheses.length})`,
3163
+ '',
3164
+ ]
3165
+ for (const hypothesis of hypotheses) {
3166
+ lines.push(`- \`${hypothesis.id}\` [${hypothesis.status}] ${hypothesis.claim}`)
3167
+ lines.push(` - 推翻条件:${hypothesis.refute_when}`)
3168
+ if (Array.isArray(hypothesis.assertions) && hypothesis.assertions.length > 0) lines.push(` - 断言:${hypothesis.assertions.length} 条`)
3169
+ }
3170
+ if (history.length > 0) {
3171
+ lines.push('', `## 判据修订留痕(${history.length})`, '')
3172
+ for (const item of history) lines.push(`- rev${item.revision}:${item.reason ?? '(未写缘由)'}(独立裁决 ${item.audit ?? '—'})`)
3173
+ }
3174
+ const body = `${lines.filter((line) => line !== '').join('\n')}\n`
3175
+ const file = sessionFile(sessionId, 'clear', 'goals', `${goal.id}.md`)
3176
+ if (file === null) return ''
3177
+ if (existsSync(file) && readFileSync(file, 'utf8') === body) return ''
3178
+ writeTextFile(file, body)
3179
+ return join('clear', 'goals', `${goal.id}.md`)
3180
+ } catch (error) {
3181
+ ctx.logger?.warn?.(`clearai goal doc: 写入失败 ${String(error?.message ?? error).slice(0, 160)}`)
3182
+ return ''
3183
+ }
3184
+ }
3185
+
2705
3186
  /**
2706
3187
  * 领域判据的宿主入口。**拿不到就明确拒,不抛**:预设与宿主半同包同版本,
2707
3188
  * 但一个缺了这道门的宿主(旧包、裁剪过的部署、测试桩)不该让工具在 `undefined` 上崩——
@@ -2742,6 +3223,7 @@ export function apply(ctx, config = {}) {
2742
3223
  * 幂等:内容一样就不重写(与模板技能同步同一条纪律——文件时间戳是给人的读数)。
2743
3224
  */
2744
3225
  function ensureOntologyShelf(cwd) {
3226
+ if (typeof cwd !== 'string' || cwd === '') return null
2745
3227
  if (CONTRIB.ontology === null || CONTRIB.ontology === undefined) return null
2746
3228
  const file = join(cwd, 'clear', 'ontology', `${CONTRIB.ontology.id}.md`)
2747
3229
  const body = describeOntology(CONTRIB.ontology)
@@ -2798,6 +3280,36 @@ export function apply(ctx, config = {}) {
2798
3280
  TOOL_DEFS.set(definition.name, definition)
2799
3281
  }
2800
3282
 
3283
+ /**
3284
+ * **工具结果的统一出口**:把独立落账通道里这一拍的事实并进结果。
3285
+ *
3286
+ * 顺序是刻意的——`pendingFacts` 里的是**已经发生的事实**(派发在 `await` 之前就落了),
3287
+ * 而工具自己的 `mutations` 是这一拍的结算。两者同形、同一个折法,
3288
+ * 所以并起来交给宿主不会多一条通道,只是让"事实比工具结果活得更久"。
3289
+ */
3290
+ function withPendingFacts(exec, value) {
3291
+ const sessionId = String(exec?.agent?.id ?? 'unknown')
3292
+ const pending = drainPendingFacts(sessionId)
3293
+ if (pending.length === 0) return value
3294
+ if (value === null || typeof value !== 'object') return value
3295
+ const own = Array.isArray(value.mutations) ? value.mutations : []
3296
+ /**
3297
+ * **顺序有意义:`pending` 在前,工具自己那批在后。**
3298
+ *
3299
+ * 独立落账通道里的第一条永远是"派发/派遣发生"(那件事先发生),工具自己的那批里则是
3300
+ * "这次调用怎么了结的"(可能带同一条 id 的覆盖,或一条 `audit/settled`)。
3301
+ * 折法是**按顺序**吃的:先有那条 `audit/dispatched`,后面的 `audit/settled` 才找得到它
3302
+ * 要结的那条记录。反过来放,结算会落在一条还不存在的记录上,变成一次 no-op——
3303
+ * 账上就留下一条永远 verdict=null 的悬空派发,派生阶段也跟着永远停在"等裁决"。
3304
+ *
3305
+ * 去重只挡**同一条事实被两条通道各送一次**;顺序不因此改变。
3306
+ */
3307
+ const key = (mutation) => `${String(mutation?.t)}:${String(mutation?.id ?? mutation?.step ?? '')}`
3308
+ const seen = new Set(own.filter((mutation) => mutation !== null && typeof mutation === 'object').map(key))
3309
+ const merged = [...pending.filter((mutation) => !seen.has(key(mutation))), ...own]
3310
+ return { ...value, mutations: merged }
3311
+ }
3312
+
2801
3313
  // ── SetGoal ────────────────────────────────────────────────────────────
2802
3314
 
2803
3315
  defineTool({
@@ -2807,8 +3319,20 @@ export function apply(ctx, config = {}) {
2807
3319
  parameters: {
2808
3320
  type: 'object',
2809
3321
  properties: {
2810
- claim: { type: 'string', description: '目标:项目要回答的问题' },
2811
- done_criteria: { type: 'string', description: '怎样算回答了——必须是可核对的判据' },
3322
+ headline: { type: 'string', maxLength: 120, description: '一句话目标(≤120 字):卡上 / 面板 / 续跑文案反复出现的那一句。首次立约必填' },
3323
+ claim: { type: 'string', description: '目标:项目要回答的问题(可以长;身份与判据的落点)' },
3324
+ done_criteria: { type: 'string', description: '怎样算回答了——必须是可核对的判据,至少含一处能清点的形态(数字 / 条数 / "存在一份文件")' },
3325
+ legacy: { type: 'boolean', description: '旧会话迁移:一次性放行长文本与缺 headline(新目标不要用)' },
3326
+ criteria: {
3327
+ type: 'array',
3328
+ items: { type: 'string' },
3329
+ description: '判据逐条写(每条一句话,含可清点数或"存在一份文件"这类能核的形态)。给了它就按条记,不给则用 done_criteria 的全文',
3330
+ },
3331
+ criteria_note: { type: 'string', description: '判据的背景说明(不参与判定,只解释为什么这么定)' },
3332
+ criteria_verdict: {
3333
+ type: 'string',
3334
+ description: '改判据文本时要带的独立裁决 auditKey:改「怎样算完成」不能被顺手做掉',
3335
+ },
2812
3336
  promote_at_level: { type: 'string', enum: LEVELS, description: '升格门槛(默认 L3)' },
2813
3337
  hypotheses: {
2814
3338
  type: 'array',
@@ -2861,6 +3385,10 @@ export function apply(ctx, config = {}) {
2861
3385
  const selfRef = SELF_REFERENCE.find(([pattern]) => pattern.test(criteria))
2862
3386
  if (selfRef !== undefined) return fail('criteria_self_reference', selfRef[1])
2863
3387
  if (typeof args.claim !== 'string' || args.claim.trim() === '') return fail('claim_required', '目标要有主张。')
3388
+ // 迁移开关要在假设校验**之前**就有值:下面那条「主体必须可指认」对它放行。
3389
+ const legacy = args.legacy === true
3390
+ const criteriaList = (Array.isArray(args.criteria) ? args.criteria : []).map((item) => String(item ?? '').trim()).filter((item) => item !== '')
3391
+ const criteriaNote = typeof args.criteria_note === 'string' && args.criteria_note.trim() !== '' ? args.criteria_note.trim() : null
2864
3392
  const hypotheses = Array.isArray(args.hypotheses) ? args.hypotheses : []
2865
3393
  for (const hypothesis of hypotheses) {
2866
3394
  if (typeof hypothesis?.claim !== 'string' || hypothesis.claim.trim() === '') return fail('hypothesis_claim_required', '每条假设要有一句话主张。')
@@ -2873,7 +3401,9 @@ export function apply(ctx, config = {}) {
2873
3401
  if (hypothesis.assertions !== undefined && hypothesis.assertions !== null) {
2874
3402
  const judge = domainJudge(hostService)
2875
3403
  if (judge === null) return fail('domain_unavailable', '这一层的宿主没有提供领域判据(domain facade):无法校验断言。请检查宿主半与预设是否同版本。')
2876
- const problems = judge.validateAssertions(sessionId, hypothesis.assertions)
3404
+ // `legacy`:迁移期一次性放行「主体还没登记」这条(`SetGoal` 的 `legacy:true`)——
3405
+ // 旧会话的断言主体在登记实例这条路存在之前就写下了,不该因为补上了机制而追溯失败。
3406
+ const problems = judge.validateAssertions(sessionId, hypothesis.assertions, { legacy })
2877
3407
  if (problems.length > 0) {
2878
3408
  return fail('assertions_rejected', `这条假设的断言不能成立(先注册词汇,或改断言):\n${problems.map((item) => `- ${item}`).join('\n')}`)
2879
3409
  }
@@ -2888,8 +3418,74 @@ export function apply(ctx, config = {}) {
2888
3418
  if (isRevision && (typeof args.reason !== 'string' || args.reason.trim() === '')) {
2889
3419
  return fail('reason_required', '修订目标必须带一句原因:改了什么、为什么改。旧版本会留在日志里。')
2890
3420
  }
3421
+ /**
3422
+ * **标识先算,再谈改什么**:`goalId` / `revision` 既要在判据修订那条变更里用,
3423
+ * 也要在下面的 `goal/set` 里用。**声明必须在使用之前**——JS 的时间死区,
3424
+ * 顺序写反了,门的**成功路径**会在跑起来那一刻抛 ReferenceError
3425
+ * (失败路径永远不碰它们,所以单测很容易漏过去)。
3426
+ */
2891
3427
  const goalId = isRevision ? state.goal.id : uniqueId('g')
2892
3428
  const revision = isRevision ? state.goal.revision + 1 : 1
3429
+ /**
3430
+ * **改判据文本要有一份独立裁决**(`criteria_verdict` = 一个 auditKey)。
3431
+ *
3432
+ * 判据是"怎样算完成"——它一变,前面所有工作的验收含义跟着变。允许在同一次
3433
+ * `SetGoal` 里顺手改掉,等于允许把"做不到"重新定义成"做到了"。补的正是那一"眼"外部裁决。
3434
+ * 出口两条:拿到一份落定的独立裁决再改,或如实 `CloseGoal(outcome="abandoned")`。
3435
+ * 迁移期用 `legacy:true` 放行(旧会话没有这条路)。
3436
+ */
3437
+ if (CFG.requireCriteriaVerdict && isRevision && !legacy && criteria !== String(state.goal.done_criteria ?? '').trim()) {
3438
+ const wanted = String(args.criteria_verdict ?? '').trim()
3439
+ if (wanted === '') {
3440
+ return fail(
3441
+ 'criteria_verdict_required',
3442
+ '改判据文本要带一份独立裁决的 `criteria_verdict`(auditKey)。\n为什么:判据是"怎样算完成";它一变,前面所有工作的验收含义跟着变。允许在同一次调用里顺手改掉,等于允许把"做不到"重新定义成"做到了"。\n两条出口:先派一次独立评估拿到裁决再改,或如实 `CloseGoal(outcome="abandoned")`(放弃不需要动判据)。',
3443
+ )
3444
+ }
3445
+ const settled = (state.audits ?? []).filter((audit) => String(audit.id) === wanted)
3446
+ const usable = settled.find((audit) => ['support', 'refute', 'inconclusive'].includes(String(audit.verdict)))
3447
+ if (usable === undefined) {
3448
+ const inFlight = settled.some((audit) => audit.verdict === null)
3449
+ return fail(
3450
+ 'criteria_verdict_unknown',
3451
+ inFlight
3452
+ ? `那份裁决(${wanted})还在飞:等它落定再改判据。`
3453
+ : `账上找不到 ${wanted} 这份**已落定**的独立裁决。最近几条:${(state.audits ?? []).slice(-5).map((audit) => `${audit.id}(${audit.verdict ?? '在飞'})`).join('、') || '(当前没有裁决)'}。`,
3454
+ )
3455
+ }
3456
+ mutations.push({ t: 'criteria/revised', goal: goalId, revision, from: String(state.goal.done_criteria ?? ''), to: criteria, reason: String(args.reason ?? '').trim(), audit: wanted })
3457
+ }
3458
+ /**
3459
+ * **一句话的目标**(`headline`)与**可清点的判据**。
3460
+ *
3461
+ * 为什么单独立这个字段:目标与判据是每一拍都进上下文的那两句,而它们此前是**一整段散文**——
3462
+ * 长到卡里占几百字、长到"这一版改了哪一条"没法逐条对。人读不动,机器也没法清点。
3463
+ *
3464
+ * 纪律落在这里而不是提示词里:
3465
+ * · `headline` ≤120 字:它才是卡上、面板上、续跑文案里反复出现的那一句。
3466
+ */
3467
+ /**
3468
+ * `headline` 可以省略——**省略时由 `claim` 的第一句现算**,所以老调用方照旧可用。
3469
+ * 但现算出来的那一句**必须**在 120 字以内:超了就是"你的目标一句话说不完",
3470
+ * 那时要么自己给一个 `headline`,要么把问题收窄。这样"一句话的目标"是硬的,
3471
+ * 而"必须多传一个字段"不是——机制挡的是长文,不是调用方的记性。
3472
+ */
3473
+ const firstSentence = (raw) => {
3474
+ const text = String(raw ?? '').trim()
3475
+ if (text === '') return ''
3476
+ const cut = text.search(/[。!?;;\n]/)
3477
+ return (cut === -1 ? text : text.slice(0, cut)).trim()
3478
+ }
3479
+ const headline = String(args.headline ?? '').trim() === '' ? firstSentence(args.claim) : String(args.headline).trim()
3480
+ if (!legacy) {
3481
+ if (headline === '') return fail('headline_required', '目标要有一句话的说法(`headline`,≤120 字):它是卡上、面板上、续跑文案里反复出现的那一句。')
3482
+ if (headline.length > 120) {
3483
+ return fail(
3484
+ 'headline_too_long',
3485
+ `目标的一句话有 ${headline.length} 字,超过 120 字上限。\n一句话说不完的问题,通常是把两三个问题捆在了一起:要么显式给一个 ≤120 字的 \`headline\`,要么把问题收窄到能一句话说清的那一个。长的主张照旧放 \`claim\`。`,
3486
+ )
3487
+ }
3488
+ }
2893
3489
  const promoteAtLevel = LEVELS.includes(args.promote_at_level) ? args.promote_at_level : 'L3'
2894
3490
  /**
2895
3491
  * **修订不许给同一句话发新身份。**
@@ -2923,8 +3519,12 @@ export function apply(ctx, config = {}) {
2923
3519
  mutations.push({
2924
3520
  t: 'goal/set',
2925
3521
  id: goalId,
3522
+ headline: headline === '' ? null : headline,
3523
+ legacy,
2926
3524
  claim: args.claim.trim(),
2927
3525
  done_criteria: criteria,
3526
+ criteria: criteriaList,
3527
+ criteria_note: criteriaNote,
2928
3528
  promote_at_level: promoteAtLevel,
2929
3529
  revision,
2930
3530
  reason: isRevision ? String(args.reason).trim() : null,
@@ -2937,7 +3537,9 @@ export function apply(ctx, config = {}) {
2937
3537
  mutations.push({ t: 'hypothesis/superseded', goal: goalId, id: dropped.id, claim: dropped.claim, by: `rev${revision}` })
2938
3538
  }
2939
3539
  let scoutNote = ''
2940
- if (!isRevision && CFG.precommitRecon) {
3540
+ // **取不到会话目录就不做立约前侦察**:那是「这一刻读不到」,不是「没有材料」——
3541
+ // 诚实少做一件事,而不是拿一个假路径去 join(null 会让整个立约炸掉)。
3542
+ if (!isRevision && CFG.precommitRecon && typeof cwd === 'string' && cwd !== '') {
2941
3543
  // 立约前侦察:harness 发起(不是模型请求),一生一次,且只在真的有人给过材料时做
2942
3544
  // ——`input/` 是空的就没什么可侦察的,白花一次子 run。
2943
3545
  const inputDir = join(cwd, 'input')
@@ -2968,7 +3570,7 @@ export function apply(ctx, config = {}) {
2968
3570
  applyContinuationPolicy(exec.agent, state, hostService.derive(sessionId), {
2969
3571
  goalOpen: true,
2970
3572
  // 平台上那句话(给人看)用**目标的主张**说,不写 id;身份与额度由账上那枚窗口负责。
2971
- target: `继续做完:${clip(String(args.claim ?? ''), 26)}`,
3573
+ target: `继续做完:${clip(headline === '' ? String(args.claim ?? '') : headline, 26)}`,
2972
3574
  mutations,
2973
3575
  }),
2974
3576
  })
@@ -3048,6 +3650,37 @@ export function apply(ctx, config = {}) {
3048
3650
  * 是两条不同的立场,所以不共用一个键。另外它**只在知识模式下生效**——
3049
3651
  * 没有登记的命题就没有「形态」可谈,那时拦下来的只是一句空话。
3050
3652
  */
3653
+ /**
3654
+ * **结构缺口的两道门**(与上面的知识门同一族、同一条顺序纪律:先拦便宜能补的,
3655
+ * 再花钱请人裁决)。
3656
+ *
3657
+ * 两道都是「可清点的整数 + 两条诚实出口」,判据来自投影的 `gaps`,不另算一套:
3658
+ * · `entities_unlanded`:N 个断言主体还没落到实体图。补法是 `RegisterInstance`
3659
+ * (登记节点)或 `Assert`(连出处把边也落下来);不值当就如实 abandoned。
3660
+ * · `levels_skipped`:N 处跳级没有理由。补法是 `ExplainLevelSkip`。
3661
+ * 提示词只会被读成建议;这两道进的是完成函数。
3662
+ */
3663
+ const gapOf = (code) => (derived.knowledge.gaps ?? []).find((gap) => gap.code === code) ?? null
3664
+ if (CFG.requireLandedEntities && derived.knowledge.mode === 'knowledge') {
3665
+ const gap = gapOf('entities_unlanded')
3666
+ if (gap !== null) {
3667
+ return fail(
3668
+ 'entities_unlanded',
3669
+ `${gap.detail}\n${gap.nextAction}\n为什么不让跳过:断言停在命题上时,图是空的——而"查到的实体"没有落成图,等于这一轮没有留下可复用的东西。两条出口:补登记,或如实 \`CloseGoal(outcome="abandoned")\`。`,
3670
+ { mutations },
3671
+ )
3672
+ }
3673
+ }
3674
+ if (CFG.requireLevelReasons && derived.knowledge.mode === 'knowledge') {
3675
+ const gap = gapOf('levels_skipped')
3676
+ if (gap !== null) {
3677
+ return fail(
3678
+ 'levels_skipped',
3679
+ `${gap.detail}\n${gap.nextAction}\n为什么不让跳过:等级是"这条结论多大程度只能靠信任做的人";跳过便宜的那几级本身不违规,但**没有理由**的跳级等于没人知道为什么。两条出口:补理由,或如实 \`CloseGoal(outcome="abandoned")\`。`,
3680
+ { mutations },
3681
+ )
3682
+ }
3683
+ }
3051
3684
  if (CFG.requireTypedPromotion && derived.knowledge.mode === 'knowledge') {
3052
3685
  const threshold = levelIndexOf(goal.promote_at_level)
3053
3686
  /** 与下面那段升格循环**逐字同一套谓词**:将要升格的就是这几条,一条不多一条不少。 */
@@ -3077,8 +3710,23 @@ export function apply(ctx, config = {}) {
3077
3710
  }
3078
3711
  const audit = await runEvaluator(sessionId, exec.agent, plan, syntheticStep, gate, 'goal_audit', exec.signal)
3079
3712
  mutations.push(...audit.mutations)
3080
- if (audit.verdict === 'pending') return fail('audit_pending', `目标评估者仍在跑:${audit.basis}。先观察当前事实,再谈重试。`)
3713
+ if (audit.verdict === 'pending') return fail('audit_pending', `目标评估者仍在跑:${audit.basis}。先观察当前事实,再谈重试。`, { mutations: audit.mutations })
3081
3714
  if (audit.verdict !== 'support') {
3715
+ /**
3716
+ * **复用来的裁决不落第二条证据**。
3717
+ *
3718
+ * 复用意味着"这一次没有新的判断发生"——它只是同一条评审对同一批材料再说了一遍。
3719
+ * 再落一条 `evidence/recorded` 会有两个坏处:账上多一条同义行,而且它会进下一次
3720
+ * digest 的输入面(那条正是"回声"本身)。所以复用只如实说清:裁决是什么、为什么复用、
3721
+ * 要改什么才能得到新判断。真正的结案事实(`audit/reused`)已经在 `audit.mutations` 里。
3722
+ */
3723
+ if (audit.reused === true) {
3724
+ return fail(
3725
+ 'goal_not_achieved',
3726
+ `目标未达成,保持开放。**这一步与上一次是同一份材料,所以复用了上一条独立裁决**(不再重复花钱请人):裁决 ${audit.verdict}。依据:${audit.basis}\n要拿到新判断,先改材料:补观测 / 交付产物 / 修订假设或判据;只是再喊一次结案不会产生新判断。\n未落定步骤:${unfinished.length === 0 ? '无' : unfinished.map((step) => step.id).join(', ')}`,
3727
+ { mutations },
3728
+ )
3729
+ }
3082
3730
  /**
3083
3731
  * 目标级裁决也要带得出出处:那条审计自己写了一张卡、也有它的评估者会话。
3084
3732
  * 这一处原先 `refs: []` ⇒ 面板上这条证据一个可点的东西都没有 ✗。
@@ -3105,10 +3753,14 @@ export function apply(ctx, config = {}) {
3105
3753
  anchor: 'auditor',
3106
3754
  basis_reviewable: true,
3107
3755
  })
3108
- const preview = hostService.preview(sessionId, mutations)
3756
+ /**
3757
+ * 读面兜底:`preview` 是本次真实运行里"宿主 fiber 掉线"的抛出点。
3758
+ * 拿不到就**不带卡**返回,而不是把这一批事实(含 `audit/dispatched`/`audit/settled`)丢掉。
3759
+ */
3760
+ const preview = previewOf(hostService, sessionId, mutations)
3109
3761
  return fail(
3110
3762
  'goal_not_achieved',
3111
- `目标未达成,保持开放。评估者裁决:${audit.verdict}。依据:${audit.basis}${audit.shortfalls.length > 0 ? `\n缺口:${audit.shortfalls.join('; ')}` : ''}\n未落定步骤:${unfinished.length === 0 ? '无' : unfinished.map((step) => step.id).join(', ')}\n\n${preview.card}`,
3763
+ `目标未达成,保持开放。评估者裁决:${audit.verdict}。依据:${audit.basis}${audit.shortfalls.length > 0 ? `\n缺口(逐条):\n${audit.shortfalls.map((item) => `- ${verdictText([item])}`).join('\n')}` : ''}\n未落定步骤:${unfinished.length === 0 ? '无' : unfinished.map((step) => step.id).join(', ')}${preview === null ? '\n(运行态卡这一刻取不到:宿主读面不可用。已经发生的事实照旧落账;先看当前账本再谈重试。)' : `\n\n${preview.card}`}`,
3112
3764
  { mutations },
3113
3765
  )
3114
3766
  }
@@ -3180,7 +3832,8 @@ export function apply(ctx, config = {}) {
3180
3832
  * 失败只 warn(照 `persistFact` 的做法):投递仍走消息,只是少了那份可读副本。
3181
3833
  */
3182
3834
  function persistMaterial(sessionId, scoutId, meta, conclusion) {
3183
- const file = join(sessionCwd(sessionId), 'clear', 'knowledge', 'materials', `${scoutId}.md`)
3835
+ const file = sessionFile(sessionId, 'clear', 'knowledge', 'materials', `${scoutId}.md`)
3836
+ if (file === null) return null
3184
3837
  try {
3185
3838
  writeTextFile(
3186
3839
  file,
@@ -3209,7 +3862,8 @@ export function apply(ctx, config = {}) {
3209
3862
  }
3210
3863
 
3211
3864
  function persistFact(sessionId, goal, hypothesis, factId) {
3212
- const file = join(sessionCwd(sessionId), 'clear', 'knowledge', 'facts', `${goal.id}.md`)
3865
+ const file = sessionFile(sessionId, 'clear', 'knowledge', 'facts', `${goal.id}.md`)
3866
+ if (file === null) return null
3213
3867
  try {
3214
3868
  appendTextFile(
3215
3869
  file,
@@ -3229,7 +3883,7 @@ export function apply(ctx, config = {}) {
3229
3883
  properties: {
3230
3884
  id: { type: 'string', description: '稳定 id(字母/数字/下划线/短横)' },
3231
3885
  do: { type: 'string', description: '这一步做什么' },
3232
- artifacts: { type: 'array', items: { type: 'string' }, description: '以何物为证:相对 workspace 的产物路径' },
3886
+ artifacts: { type: 'array', items: { type: 'string' }, description: '以何物为证:相对 workspace 的具体产物文件路径(目录不是物证)' },
3233
3887
  done_criteria: { type: 'string', description: '判定标准:在结果出现之前写下,必须可核对' },
3234
3888
  tests: {
3235
3889
  type: 'object',
@@ -3540,6 +4194,232 @@ export function apply(ctx, config = {}) {
3540
4194
  },
3541
4195
  })
3542
4196
 
4197
+ /**
4198
+ * ── 实体两件:实例与关于它的断言 ──────────────────────────────────────────
4199
+ *
4200
+ * **为什么要单独立这两件**:原来实体层的节点与边**只**来自
4201
+ * 已升格事实,而事实是"目标级独立裁决判 support"之后才发的奖励。于是一条观察要变成实体,
4202
+ * 必须同时满足「命题登记了 + 断言类型合法 + 证据够门槛 + 无推翻 + **整条目标的四条散文判据
4203
+ * 都被评估者认可**」——最后那一条与这条观察毫无关系,却握着实体层的存在性。
4204
+ * 失效模式是**比例失衡**:本体层可以堆出几十个词(登记是约定,不花代价),而实体图可能
4205
+ * 一个节点都没有——账面上"本体建好了",实际上一条可复核的观测都没留下来。
4206
+ *
4207
+ * 修法是把「约定」与「观测」分开,各给一个写入口:
4208
+ * · `RegisterTerm` = 约定(概念,不需要依据);
4209
+ * · `RegisterInstance` = 观测(实例,**必须**带依据与出处);
4210
+ * · `Assert` = 关于某个实例的一句话(**必须**带出处),它**在落账那一刻就进实体图**。
4211
+ * 事实层照旧:独立裁决过的结论仍然升格成事实,实体图因此有两类边
4212
+ * (带等级的 `promoted` 与带出处的 `asserted`),两条都看得见。
4213
+ */
4214
+ defineTool({
4215
+ name: 'RegisterInstance',
4216
+ description:
4217
+ '登记一个**实例**(实体图上的节点):某个具体的人 / 作品 / 事件 / 样本。与 `RegisterTerm` 的分工是硬的——概念是**约定**(不需要依据),实例是**观测**(`basis` 与 `provenance` 必填)。`type` 必须是已登记的概念。实例自己不带关系;要让它连上别的节点就用 `Assert`。',
4218
+ parameters: {
4219
+ type: 'object',
4220
+ properties: {
4221
+ id: { type: 'string', description: '实例 id:小写 slug 或原文名(字母/数字/下划线/短横,≤60)' },
4222
+ type: { type: 'string', description: '它是什么概念的实例(已登记的 term id)' },
4223
+ label: { type: 'string', description: '给人看的名字' },
4224
+ basis: { type: 'string', description: '依据:哪份材料 / 哪条观测让这个实例成立' },
4225
+ provenance: {
4226
+ type: 'object',
4227
+ description: '出处:能指认到的东西。url = 可打开的链接;named = 具名文献 / 条目;backref = 工作区里已有的文件或条目 id',
4228
+ properties: {
4229
+ kind: { type: 'string', enum: ['url', 'named', 'backref'] },
4230
+ ref: { type: 'string', description: '链接、文献名或文件路径' },
4231
+ },
4232
+ required: ['kind', 'ref'],
4233
+ additionalProperties: false,
4234
+ },
4235
+ },
4236
+ required: ['id', 'type', 'label', 'basis', 'provenance'],
4237
+ additionalProperties: false,
4238
+ },
4239
+ output: CARD_OUTPUT,
4240
+ async execute(args, exec) {
4241
+ const call = open(exec)
4242
+ if (call.ok !== true) return call.response
4243
+ const { hostService, sessionId, state, mutations } = call
4244
+ const done = finish(hostService, sessionId, mutations)
4245
+ const id = String(args.id ?? '').trim()
4246
+ const type = String(args.type ?? '').trim()
4247
+ const label = String(args.label ?? '').trim()
4248
+ const basis = String(args.basis ?? '').trim()
4249
+ const kind = String(args.provenance?.kind ?? '').trim()
4250
+ const ref = String(args.provenance?.ref ?? '').trim()
4251
+ if (!/^[A-Za-z0-9_-]{1,60}$/.test(id)) return fail('instance_id_invalid', 'id 只能用字母/数字/下划线/短横(≤60):它是图上的稳定键,不能含空格与标点。')
4252
+ if (label === '') return fail('instance_label_required', '实例要有一个人能读的名字。')
4253
+ if (basis === '') return fail('instance_basis_required', '实例是**观测**不是约定:写清哪份材料让它可以被指认。')
4254
+ if (!['url', 'named', 'backref'].includes(kind) || ref === '') {
4255
+ return fail('instance_provenance_required', '出处必填:`{kind:"url"|"named"|"backref", ref:"…"}`。没有出处的实例进不了实体图——那是它与概念的区别。')
4256
+ }
4257
+ const terms = Array.isArray(stateOf(sessionId)?.lexicon?.terms) ? stateOf(sessionId).lexicon.terms : []
4258
+ const known = terms.find((term) => String(term.id) === type)
4259
+ if (known === undefined) return fail('instance_type_unknown', `type ${type} 不是已登记的概念。先 RegisterTerm 立这个概念(它才是约定那一侧),再登记实例。`)
4260
+ if (String(known.status ?? 'admitted') === 'deprecated') return fail('instance_type_deprecated', `概念 ${type} 已废止:新断言不许再引用它。`)
4261
+ mutations.push({ t: 'entity/registered', id, type, label, basis, provenance: { kind, ref } })
4262
+ const note = ensureDomainShelf(hostService, sessionId, mutations)
4263
+ return done({
4264
+ ok: true,
4265
+ code: 'instance_registered',
4266
+ message: `实例 ${id} 已登记为 ${type} 的实例(出处:${kind} · ${ref})。它现在是实体图上的一个节点——**还没有边**:要让它连上别的节点,用 \`Assert\` 写一句带出处的话。${note}`,
4267
+ })
4268
+ },
4269
+ })
4270
+
4271
+ defineTool({
4272
+ name: 'Assert',
4273
+ description:
4274
+ '说一句关于某个**已登记实例**的话(主词–谓词–宾语),并带上出处。它**在落账那一刻就进实体图**:不需要等目标级独立裁决。这是把"查到的实体"变成"实体图谱"的那条路。带等级的结论仍走假设 → 证据 → 升格那条路(那才叫事实);`Assert` 记的是**观测**,图上的边会标成 `asserted` 与事实边区分。',
4275
+ parameters: {
4276
+ type: 'object',
4277
+ properties: {
4278
+ subject: {
4279
+ type: 'object',
4280
+ properties: { id: { type: 'string' }, type: { type: 'string' } },
4281
+ required: ['id', 'type'],
4282
+ additionalProperties: false,
4283
+ },
4284
+ predicate: { type: 'string', description: '已登记的谓词 id' },
4285
+ object: {
4286
+ type: 'object',
4287
+ properties: {
4288
+ kind: { type: 'string', enum: ['instance', 'statement', 'quantity', 'formula', 'code', 'reference'] },
4289
+ value: {},
4290
+ type: { type: 'string', description: 'kind=instance 时,宾语所属概念 id' },
4291
+ unit: { type: 'string' },
4292
+ },
4293
+ required: ['kind'],
4294
+ additionalProperties: false,
4295
+ },
4296
+ evidence: {
4297
+ type: 'object',
4298
+ properties: { kind: { type: 'string', enum: ['url', 'named', 'backref'] }, ref: { type: 'string' } },
4299
+ required: ['kind', 'ref'],
4300
+ additionalProperties: false,
4301
+ },
4302
+ },
4303
+ required: ['subject', 'predicate', 'object', 'evidence'],
4304
+ additionalProperties: false,
4305
+ },
4306
+ output: CARD_OUTPUT,
4307
+ async execute(args, exec) {
4308
+ const call = open(exec)
4309
+ if (call.ok !== true) return call.response
4310
+ const { hostService, sessionId, state, mutations } = call
4311
+ const done = finish(hostService, sessionId, mutations)
4312
+ const judge = domainJudge(hostService)
4313
+ if (judge === null) return fail('domain_unavailable', '这一层的宿主没有提供领域判据(domain facade):无法校验断言。请检查宿主半与预设是否同版本。')
4314
+ const subjectId = String(args.subject?.id ?? '').trim()
4315
+ const subjectType = String(args.subject?.type ?? '').trim()
4316
+ const predicateId = String(args.predicate ?? '').trim()
4317
+ const kind = String(args.evidence?.kind ?? '').trim()
4318
+ const ref = String(args.evidence?.ref ?? '').trim()
4319
+ if (subjectId === '' || subjectType === '') return fail('assert_subject_required', '主词要同时给 `id` 与 `type`(type 是它所属的概念)。')
4320
+ if (predicateId === '') return fail('assert_predicate_required', '谓词必填:先 `RegisterPredicate` 立一条关系,再说这句话。')
4321
+ if (!['url', 'named', 'backref'].includes(kind) || ref === '') return fail('assert_evidence_required', '出处必填:`{kind:"url"|"named"|"backref", ref:"…"}`。**没有出处的话是意见,不是观测**——它不进实体图。')
4322
+ const projection = hostService.domain.graph?.(sessionId) ?? null
4323
+ const registered = Array.isArray(stateOf(sessionId)?.entities) ? stateOf(sessionId).entities : []
4324
+ if (!registered.some((entity) => String(entity.id) === subjectId && String(entity.type) === subjectType)) {
4325
+ return fail('assert_subject_not_registered', `主词 ${subjectType}|${subjectId} 还不是实体图上的节点。先 \`RegisterInstance\` 把它连出处登记下来,再说关于它的话——**主词可指认**是这句话能被复核的前提。`)
4326
+ }
4327
+ if (projection !== null && Array.isArray(projection.nodes)) {
4328
+ const types = new Set(projection.nodes.filter((node) => node?.kind === 'concept').map((node) => String(node.ref)))
4329
+ if (!types.has(subjectType)) return fail('assert_subject_type_unknown', `主词的类型 ${subjectType} 不是已登记的概念。`)
4330
+ const objectType = String(args.object?.type ?? '').trim()
4331
+ if (String(args.object?.kind) === 'instance' && objectType !== '' && !types.has(objectType)) return fail('assert_object_type_unknown', `宾语的类型 ${objectType} 不是已登记的概念。`)
4332
+ }
4333
+ const problems = judge.validateAssertions(sessionId, [{ predicate: predicateId, subject: { id: subjectId, type: subjectType }, object: args.object }])
4334
+ if (problems.length > 0) return fail('assertion_rejected', `这句话不能成立:\n${problems.map((item) => `- ${item}`).join('\n')}`)
4335
+ const assertionId = `as-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
4336
+ mutations.push({
4337
+ t: 'entity/asserted',
4338
+ id: assertionId,
4339
+ subject: { id: subjectId, type: subjectType },
4340
+ predicate: predicateId,
4341
+ object: args.object,
4342
+ evidence: { kind, ref },
4343
+ })
4344
+ const note = ensureDomainShelf(hostService, sessionId, mutations)
4345
+ return done({
4346
+ ok: true,
4347
+ code: 'entity_asserted',
4348
+ message: `${subjectType}|${subjectId} —${predicateId}→ 已落账(出处:${kind} · ${ref})。它现在**在实体图上有一条边**;这条边走的是"带出处的观测",与升格事实那条"带等级的结论"分开标注。${note}`,
4349
+ })
4350
+ },
4351
+ })
4352
+
4353
+ /**
4354
+ * ── ExplainLevelSkip:跳级要记账 ─────────────────────────────────────────
4355
+ *
4356
+ * 等级衡量的是「这条结论在多大程度上只能靠信任做的人」。便宜的那几级(L0 自洽检查、
4357
+ * L1 已有知识、L2 已有数据)不是形式:它们能在花掉一次独立裁决之前先把问题问清。
4358
+ * 但 `supportedLevel` 只是 support 证据的最大值,**跳级不违规、也没有任何代价**——
4359
+ * 于是"一路只在最贵的那一级交付"成了最优策略:结论全部停在 L3,而 L0 证据一条都没有。
4360
+ *
4361
+ * 不逼模型补读数(首次测量确实可能没有廉价路),但**跳级必须留下理由**:
4362
+ * 理由是「这一级在本项目里为什么不适用」,不是「时间不够」。
4363
+ */
4364
+ defineTool({
4365
+ name: 'ExplainLevelSkip',
4366
+ description:
4367
+ '为**没走过的验证等级**留下理由。`levels` 必须是这条命题当前"未走过"的等级(卡上会列出来);`reason` 要写成"这一级在本项目里为什么不适用",并**点到该等级要检查的对象名**——写"时间不够"不算理由。它不改等级、也不替代读数:它只让"跳过"从默许变成账上的一条事实。',
4368
+ parameters: {
4369
+ type: 'object',
4370
+ properties: {
4371
+ hypothesis: { type: 'string', description: '命题 id(也认原文与唯一前缀)' },
4372
+ levels: { type: 'array', items: { type: 'string', enum: LEVELS }, description: '未走过的等级' },
4373
+ reason: { type: 'string', description: '为什么这一级在本项目里不适用(必须点到该等级要检查的对象名)' },
4374
+ },
4375
+ required: ['hypothesis', 'levels', 'reason'],
4376
+ additionalProperties: false,
4377
+ },
4378
+ output: CARD_OUTPUT,
4379
+ async execute(args, exec) {
4380
+ const call = open(exec)
4381
+ if (call.ok !== true) return call.response
4382
+ const { hostService, sessionId, state, mutations } = call
4383
+ const done = finish(hostService, sessionId, mutations)
4384
+ const derived = hostService.derive(sessionId)
4385
+ const hypothesis = matchHypothesis(derived.hypotheses, args.hypothesis)
4386
+ if (hypothesis === null) return fail('hypothesis_unknown', `认不出这条命题:${String(args.hypothesis)}。有效 id:${derived.hypotheses.map((item) => item.id).join('、') || '(当前没有命题)'}。`)
4387
+ const levels = (Array.isArray(args.levels) ? args.levels : []).map((level) => String(level)).filter((level) => LEVELS.includes(level))
4388
+ if (levels.length === 0) return fail('levels_required', `levels 必填,取值 ${LEVELS.join('/')}。`)
4389
+ const untouched = Array.isArray(hypothesis.untouchedLevels) ? hypothesis.untouchedLevels : []
4390
+ const notUntouched = levels.filter((level) => !untouched.includes(level))
4391
+ if (notUntouched.length > 0) {
4392
+ return fail(
4393
+ 'levels_not_untouched',
4394
+ `${notUntouched.join('/')} 不是"未走过"的等级,不能给它写跳过理由(${hypothesis.id} 当前未走过:${untouched.join('/') || '无'})。见卡上那行读数。`,
4395
+ )
4396
+ }
4397
+ const reason = String(args.reason ?? '').trim()
4398
+ if (reason.length < 24) return fail('skip_reason_too_short', '理由太短:写明"这一级要检查什么、为什么在本项目里不适用"。')
4399
+ /**
4400
+ * **可清点的理由判据**:理由里必须出现该等级要检查的对象名(取自这条命题自己的断言主体)。
4401
+ * 这不是文字游戏——它挡住的是"随便写一句「不适用」就把门过了"这条捷径。
4402
+ */
4403
+ const subjects = (Array.isArray(hypothesis.assertions) ? hypothesis.assertions : [])
4404
+ .map((assertion) => String(assertion?.object?.value ?? '').trim())
4405
+ .concat((Array.isArray(hypothesis.assertions) ? hypothesis.assertions : []).map((assertion) => String(assertion?.subject?.id ?? '').trim()))
4406
+ .filter((token) => token !== '')
4407
+ if (subjects.length > 0 && !subjects.some((token) => reason.includes(token))) {
4408
+ return fail(
4409
+ 'skip_reason_missing_object',
4410
+ `理由里必须点到这一级要检查的对象名:${subjects.slice(0, 6).join('、')}。\n为什么要求这个:一句"不适用"谁都会写,而写清"看的是哪个对象、为什么不适用于它"才是一次可复核的判断。`,
4411
+ )
4412
+ }
4413
+ mutations.push({ t: 'level/skipped', goal: state.goal?.id ?? null, hypothesis: hypothesis.id, levels, reason })
4414
+ const left = untouched.filter((level) => !levels.includes(level))
4415
+ return done({
4416
+ ok: true,
4417
+ code: 'level_skip_recorded',
4418
+ message: `${hypothesis.id} 的 ${levels.join('/')} 已记下跳过理由。${left.length === 0 ? '这条命题的跳级现在都有理由了。' : `还剩 ${left.join('/')} 没有理由——卡上会继续报。`}`,
4419
+ })
4420
+ },
4421
+ })
4422
+
3543
4423
  defineTool({
3544
4424
  name: 'CreatePlan',
3545
4425
  description:
@@ -3650,6 +4530,7 @@ export function apply(ctx, config = {}) {
3650
4530
  if (plan.steps.some((step) => step.id === args.step.id)) return fail('duplicate_step', `步骤 id 已存在:${args.step.id}`)
3651
4531
  const amended = { id: args.step.id, do: args.step.do, artifacts: args.step.artifacts ?? [], done_criteria: args.step.done_criteria, tests: args.step.tests ?? null }
3652
4532
  mutations.push({ t: 'plan/amended', plan: plan.id, step: amended })
4533
+ if (plan.blocked !== undefined) mutations.push({ t: 'block/cleared', plan: plan.id, step: plan.blocked.step })
3653
4534
  /**
3654
4535
  * **没授权的计划:改完再呈一次**(审阅卡上承诺的就是这句)。
3655
4536
  * 已经授权的计划不再打扰人 —— 补一步不是重新立约。
@@ -3684,6 +4565,7 @@ export function apply(ctx, config = {}) {
3684
4565
  const selfRef = SELF_REFERENCE.find(([pattern]) => pattern.test(criteria))
3685
4566
  if (selfRef !== undefined) return fail('criteria_self_reference', selfRef[1])
3686
4567
  mutations.push({ t: 'plan/refined', plan: plan.id, step: step.id, old_criteria: step.done_criteria, new_criteria: criteria, reason: args.reason ?? null })
4568
+ if (plan.blocked !== undefined) mutations.push({ t: 'block/cleared', plan: plan.id, step: plan.blocked.step })
3687
4569
  const refinedSteps = plan.steps.map((item) => (item.id === step.id ? { ...item, done_criteria: criteria } : item))
3688
4570
  const againRefined = plan.confirmed_at === null ? await reviewExistingPlan(plan, exec, mutations, refinedSteps) : { note: '' }
3689
4571
  return done({ ok: true, code: 'plan_refined', progress_changed: false, message: `步骤 ${step.id} 的判据已精化(进度不变,旧判据留痕)。${againRefined.note}` })
@@ -3733,6 +4615,7 @@ export function apply(ctx, config = {}) {
3733
4615
  if (step.status === 'advanced') return fail('step_settled', `步骤 ${step.id} 已交付,不能作废(已交付的事实不会被撤销)。`)
3734
4616
  if (typeof args.reason !== 'string' || args.reason.trim() === '') return fail('reason_required', '作废必须带原因。')
3735
4617
  mutations.push({ t: 'plan/voided', plan: plan.id, step: step.id, reason: args.reason.trim() })
4618
+ if (plan.blocked?.step === step.id) mutations.push({ t: 'block/cleared', plan: plan.id, step: step.id })
3736
4619
  /**
3737
4620
  * 作废**不动**分叉:作废是承诺层的权威动作,它不改变尝试层已经发生的事实
3738
4621
  * ——那些世界线探索过、有的还出了读数。把它们改写成「已放弃」就是改写历史。
@@ -3784,7 +4667,8 @@ export function apply(ctx, config = {}) {
3784
4667
 
3785
4668
  /** 归档路径取实现事实 `clear/goals/plans/{plan_id}.md`。 */
3786
4669
  function persistArchive(sessionId, plan, summary) {
3787
- const file = join(sessionCwd(sessionId), 'clear', 'goals', 'plans', `${plan.id}.md`)
4670
+ const file = sessionFile(sessionId, 'clear', 'goals', 'plans', `${plan.id}.md`)
4671
+ if (file === null) return null
3788
4672
  const lines = [`# 阶段归档 · ${plan.id}`, '', `- 收束时间:${new Date().toISOString()}`, `- 所属目标:${plan.goal ?? '—'}`, summary === null ? '' : `- 收束之辞:${summary}`, '', '## 步骤']
3789
4673
  for (const step of plan.steps) {
3790
4674
  lines.push(`- [${step.status}] ${step.ordinal}. ${step.do} → ${step.artifacts.join(', ') || '(未声明)'}${step.voidReason === null ? '' : ` (作废:${step.voidReason})`}`)
@@ -3853,7 +4737,7 @@ export function apply(ctx, config = {}) {
3853
4737
  if (plan.blocked !== undefined) {
3854
4738
  return fail(
3855
4739
  'plan_blocked',
3856
- `计划 ${plan.id} 已置 blocked(连续 ${plan.blocked.attempts} 次未过闸:${plan.blocked.reason}),停下等人。要接着做:AmendPlan 换一条能过闸的路、RefinePlan 补齐判据,或让人介入后重开。`,
4740
+ `计划 ${plan.id} 已置 blocked(连续 ${plan.blocked.attempts} 次未过闸:${plan.blocked.reason}),停下等人。要接着做:AmendPlan 换一条能过闸的路、RefinePlan 补齐判据,或 VoidPlanStep 作废被拦步骤。`,
3857
4741
  )
3858
4742
  }
3859
4743
  const level = step.tests?.level ?? null
@@ -3861,18 +4745,26 @@ export function apply(ctx, config = {}) {
3861
4745
 
3862
4746
  // ① 登记观测(只追加)
3863
4747
  const accepted = []
4748
+ // 目录不可得时(digest / 字节数都无从算起)不在循环里拼路径:那是"这一刻读不到",
4749
+ // 不是"这份观测有问题"。观测照旧落账(它是模型报的),只是**读数缺失**如实为 null——
4750
+ // 让调用崩掉会把这一批 observation/recorded 一起丢进可撤销的栈帧,那才是真的损失。
4751
+ const readableCwd = typeof cwd === 'string' && cwd !== ''
3864
4752
  for (const observation of Array.isArray(args.observations) ? args.observations : []) {
3865
4753
  if (typeof observation?.ref !== 'string' || observation.ref.trim() === '') continue
3866
4754
  const ref = observation.ref.trim()
3867
- const absolute = isAbsolute(ref) ? ref : resolvePath(cwd, ref)
3868
4755
  let bytes = null
3869
- try {
3870
- bytes = statSync(absolute).size
3871
- } catch {
3872
- bytes = null
4756
+ let digest = null
4757
+ if (readableCwd) {
4758
+ const absolute = isAbsolute(ref) ? ref : resolvePath(cwd, ref)
4759
+ try {
4760
+ bytes = statSync(absolute).size
4761
+ } catch {
4762
+ bytes = null
4763
+ }
4764
+ digest = sha256File(absolute)
3873
4765
  }
3874
4766
  const materialId = `m-${Math.random().toString(36).slice(2, 8)}`
3875
- mutations.push({ t: 'observation/recorded', id: materialId, ref, source: 'self', digest: sha256File(absolute), bytes, note: observation.note ?? null, step: step.id })
4767
+ mutations.push({ t: 'observation/recorded', id: materialId, ref, source: 'self', digest, bytes, note: observation.note ?? null, step: step.id })
3876
4768
  accepted.push({ id: materialId, ref })
3877
4769
  }
3878
4770
 
@@ -3912,7 +4804,7 @@ export function apply(ctx, config = {}) {
3912
4804
  count >= CFG.blockedThreshold
3913
4805
  ? stopContinuation(exec.agent, CONTINUATION_CODES.stalled, `计划 ${plan.id} 第 ${count} 次未过准入(${gate.verified_by}:${gate.hint})`, mutations)
3914
4806
  : ''
3915
- const preview = hostService.preview(sessionId, mutations)
4807
+ const preview = previewOf(hostService, sessionId, mutations)
3916
4808
  return fail(
3917
4809
  `evidence_${gate.verified_by}`,
3918
4810
  `未过观测准入(${gate.verified_by},第 ${count} 次):${gate.hint}${count >= CFG.blockedThreshold ? '\n已达阈值,计划置 blocked——停下等人,不要继续交付。' : ''}${stalledNote}\n\n${preview.card}`,
@@ -3949,6 +4841,8 @@ export function apply(ctx, config = {}) {
3949
4841
  /** 独立裁决的两件凭据(自判路径下保持 null):评估卡文件与写它的**评估者子会话**。 */
3950
4842
  let auditCardPath = null
3951
4843
  let auditSessionId = null
4844
+ /** 复用说明(模型与人都看得到的那一句);不复用时为空串。 */
4845
+ let reuseNote = ''
3952
4846
  if (levelIndex > SELF_JUDGE_MAX_INDEX) {
3953
4847
  if (typeof args.verdict === 'string' && args.verdict !== '') {
3954
4848
  return fail('verdict_not_accepted', `${level} 的证据只能由机器或独立评估者写:做的人不判自己。去掉 verdict/basis 重新交付,系统会派评估者。`)
@@ -3960,7 +4854,7 @@ export function apply(ctx, config = {}) {
3960
4854
  const count = countBlock('audit_unavailable', audit.basis)
3961
4855
  const stalled = count >= CFG.blockedThreshold
3962
4856
  const stalledNote = stalled ? stopContinuation(exec.agent, CONTINUATION_CODES.stalled, `计划 ${plan.id} 第 ${count} 次拿不到独立裁决`, mutations) : ''
3963
- const preview = hostService.preview(sessionId, mutations)
4857
+ const preview = previewOf(hostService, sessionId, mutations)
3964
4858
  return fail(
3965
4859
  'evidence_audit_unavailable',
3966
4860
  `没有拿到独立裁决,这一步不推进(fail-closed):${audit.basis}` +
@@ -3972,13 +4866,23 @@ export function apply(ctx, config = {}) {
3972
4866
  verdict = audit.verdict
3973
4867
  evaluator = 'independent'
3974
4868
  basis = audit.basis
3975
- /** 两件凭据从**这一次**的审计结果里取(卡文件 + 评估者子会话)。 */
4869
+ /** 两件凭据从**这一次**的审计结果里取(卡文件 + 评估者子会话);复用也照样带出来。 */
3976
4870
  auditCardPath = audit.cardPath ?? null
3977
- auditSessionId = audit.mutations.find((mutation) => mutation.t === 'audit/dispatched')?.evaluator_session ?? null
4871
+ auditSessionId = audit.mutations.find((mutation) => mutation.t === 'audit/dispatched')?.evaluator_session ?? audit.evaluatorSession ?? null
4872
+ /**
4873
+ * **复用改的是"花不花一次评估",不是"这次交付算不算发生过"**。
4874
+ *
4875
+ * 所以证据照旧落(这一步的历史、以及仓库既有的「同一步连续两次无法判定 ⇒ 必须先改法」
4876
+ * 都靠它),只在依据里说清这一次没有新判断。省下的正好是那两分钟子 run。
4877
+ */
4878
+ if (audit.reused === true) {
4879
+ reuseNote = `\n**同一条材料**:这次**复用了上一条独立裁决**,没有重复请人。要拿到新判断先改材料——换产物内容、补观测,或用 RefinePlan 改这一步的判据。`
4880
+ basis = `${basis}\n[同一条材料:这次复用了上一条独立裁决,没有重复请人。要拿到新判断,先改材料——换产物内容、补观测,或用 RefinePlan 改这一步的判据。]`
4881
+ }
3978
4882
  // 审计缺口定点侦察:评估者说这一步不成,系统自己派一个只读侦察去补它指出的缺口
3979
4883
  // (子角色由 Harness 按触发派生,不是模型的自由委派)
3980
- if (audit.verdict === 'refute') {
3981
- const scout = await runScout(sessionId, exec.agent, plan, step, audit.shortfalls.join('; '), `audit_shortfall:${audit.shortfalls[0] ?? '未指明'}`, exec.signal)
4884
+ if (audit.verdict === 'refute' && audit.reused !== true) {
4885
+ const scout = await runScout(sessionId, exec.agent, plan, step, verdictText(audit.shortfalls), `audit_shortfall:${verdictText([audit.shortfalls[0]]) || '未指明'}`, exec.signal)
3982
4886
  mutations.push(...scout.mutations)
3983
4887
  // 派出去就不等:结论会在下一个回合边界回灌到资料面。
3984
4888
  if (scout.pending === true) basis = `${basis}\n[已派出只读侦察补缺口 ${scout.scoutId}:结论会作为观测回灌到资料面,不在这次回执里]`
@@ -4096,7 +5000,7 @@ export function apply(ctx, config = {}) {
4096
5000
  blocked: false,
4097
5001
  message:
4098
5002
  `步骤 ${step.id} ${verdict === 'support' ? '已交付并推进' : `未收敛(${verdict})`}` +
4099
- `(${evaluator === 'independent' ? '独立评估者裁决' : '自判,依据已记账'})。观测准入:${gate.verified_by};坐标:${gate.confirmed.map((item) => item.ref).join(', ') || '(无)'}。${tail}${ledgerNote}`,
5003
+ `(${evaluator === 'independent' ? '独立评估者裁决' : '自判,依据已记账'})。观测准入:${gate.verified_by};坐标:${gate.confirmed.map((item) => item.ref).join(', ') || '(无)'}。${tail}${reuseNote}${ledgerNote}`,
4100
5004
  })
4101
5005
  },
4102
5006
  })
@@ -4233,6 +5137,8 @@ export function apply(ctx, config = {}) {
4233
5137
  * 失败如实告警并返回空串:账本没记上就说没记上,不假装记过。
4234
5138
  */
4235
5139
  function snapshotWorkspace(sessionId, cwd, state, mutations, turn, phase = 'mid') {
5140
+ // 会话目录不可得(服务瞬态掉线)时**不猜目录**:少记一次快照,也不要写到别处。
5141
+ if (typeof cwd !== 'string' || cwd === '') return ''
4236
5142
  const calls = Number(state?.writeCalls ?? 0)
4237
5143
  if (calls <= 0 || lastWorkspaceSnapshot.get(sessionId) === calls) return ''
4238
5144
  lastWorkspaceSnapshot.set(sessionId, calls)
@@ -5134,7 +6040,7 @@ export function apply(ctx, config = {}) {
5134
6040
  reading = audit.reading ?? reading
5135
6041
  validity = audit.validity ?? validity
5136
6042
  auditCardPath = audit.cardPath ?? null
5137
- auditSessionId = audit.mutations.find((mutation) => mutation.t === 'audit/dispatched')?.evaluator_session ?? null
6043
+ auditSessionId = audit.mutations.find((mutation) => mutation.t === 'audit/dispatched')?.evaluator_session ?? audit.evaluatorSession ?? null
5138
6044
  } else {
5139
6045
  if (typeof args.verdict !== 'string' || args.verdict === '') return fail('verdict_required', `${branch.level} 的世界线要你自己给裁决与依据。`)
5140
6046
  if (typeof args.basis !== 'string' || args.basis.trim().length < 8) return fail('basis_required', '依据必须可复查:写清你引用了哪个产物里的哪个事实。')
@@ -5268,7 +6174,7 @@ export function apply(ctx, config = {}) {
5268
6174
  }
5269
6175
  if (outcome.undecidable !== undefined) {
5270
6176
  mutations.push({ t: 'fork/undecidable', fork: fork.id, code: outcome.undecidable.code, reason: outcome.undecidable.reason, readings: outcome.undecidable.readings ?? [] })
5271
- const preview = hostService.preview(sessionId, mutations)
6177
+ const preview = previewOf(hostService, sessionId, mutations)
5272
6178
  return fail(
5273
6179
  outcome.undecidable.code,
5274
6180
  `算不出胜负:${outcome.undecidable.reason}${arbiterNote}\n这是**异常**,归你处置,不是死路;只有确属价值判断时才升给人。\n\n${preview.card}`,
@@ -5318,7 +6224,7 @@ export function apply(ctx, config = {}) {
5318
6224
  if (mergeable === true) {
5319
6225
  const commit = commitWorldline(winner.worktree_path, `worldline ${winner.git_branch}: 采纳前的最后状态`)
5320
6226
  if (commit.ok !== true) {
5321
- const preview = hostService.preview(sessionId, mutations)
6227
+ const preview = previewOf(hostService, sessionId, mutations)
5322
6228
  return fail('merge_prepare_failed', `采纳前提交赢家世界线失败:${commit.reason}\n\n${preview.card}`, { mutations })
5323
6229
  }
5324
6230
  const message = [
@@ -5338,7 +6244,7 @@ export function apply(ctx, config = {}) {
5338
6244
  if (merged.snapshotCommit !== null && merged.snapshotCommit !== undefined) {
5339
6245
  mutations.push({ t: 'git/snapshot', commit: merged.snapshotCommit, reason: '采纳前先把工作区手上的改动存成一条提交(否则会被合并覆盖)' })
5340
6246
  }
5341
- const preview = hostService.preview(sessionId, mutations)
6247
+ const preview = previewOf(hostService, sessionId, mutations)
5342
6248
  return fail(
5343
6249
  'merge_conflict',
5344
6250
  `算术算出了赢家「${winner.label}」,但**合并冲突**了(两个方案改了同一个地方)。这是决策门,已经在世界树上等你:请决定保留哪一边,或让模型改判据重做一条世界线。\n细节:${merged.detail ?? ''}` +
@@ -6019,6 +6925,10 @@ export function apply(ctx, config = {}) {
6019
6925
  const pathProblem = validateSkillPath(args.path ?? 'SKILL.md')
6020
6926
  if (pathProblem !== null) return fail(pathProblem, '路径只能是技能目录内的相对路径,不许 `..`。')
6021
6927
  if (typeof args.content !== 'string' || args.content.trim() === '') return fail('content_required', '写全文,不是 diff。')
6928
+ // 与 WriteMemory 同一条纪律:目录不可得就**如实说写不了**,不拿假路径去 join。
6929
+ if (typeof cwd !== 'string' || cwd === '') {
6930
+ return fail('skill_cwd_unavailable', '这一刻读不到会话的工作目录,技能写不出去。**这不是你的参数错**:宿主会话服务瞬态不可得。先看当前账本,再谈重试。')
6931
+ }
6022
6932
  const target = join(brainPaths(cwd).skills, String(args.skill).trim(), args.path ?? 'SKILL.md')
6023
6933
  const isSkillFile = (args.path ?? 'SKILL.md') === 'SKILL.md'
6024
6934
  let text = args.content
@@ -6089,6 +6999,11 @@ export function apply(ctx, config = {}) {
6089
6999
  const problem = validateMemoryFile(args.target)
6090
7000
  if (problem !== null) return fail(problem, 'target 只能是扁平的 .md 文件名。')
6091
7001
  }
7002
+ // 目录不可得就不写:记忆是往工作区落盘的,拿不到工作区时**如实说写不了**,
7003
+ // 而不是拿一个假路径去 join(null 会让整个工具崩掉,模型看到的是"我参数错了")。
7004
+ if (typeof cwd !== 'string' || cwd === '') {
7005
+ return fail('memory_cwd_unavailable', '这一刻读不到会话的工作目录,记忆写不出去。**这不是你的参数错**:宿主会话服务瞬态不可得。先看当前账本,再谈重试。')
7006
+ }
6092
7007
  const written = appendMemory(cwd, { kind, title, fields, source: args.source ?? null, target: args.target ?? null })
6093
7008
  if (written.ok !== true) return fail(String(written.reason ?? 'memory_write_failed'), '记忆写入失败。')
6094
7009
  invalidateBrainCatalog()
@@ -6519,6 +7434,16 @@ export function apply(ctx, config = {}) {
6519
7434
  // ═══ 每回合派生的运行态卡 ═══════════════════════════════════════════════
6520
7435
 
6521
7436
  const lastCard = new Map()
7437
+ /**
7438
+ * **判据全文只在修订后注入一次**(每个会话记最后一次发过的修订号)。
7439
+ *
7440
+ * 卡里给的是压缩版 + `clear/goals/{goalId}.md` 指针;但刚改完判据的那一拍,
7441
+ * 模型必须**逐字看到**新判据——否则它会照着旧判据干活,而账上已经换了尺子。
7442
+ * 所以修订后的第一张卡补一段全文,之后各拍只留压缩版。
7443
+ */
7444
+ const lastCriteriaSent = new Map()
7445
+ /** 已经落过账的宿主降级观测(按内容寻址 id);重启后重建一次,折法那侧幂等。 */
7446
+ const landedHostHealth = new Set()
6522
7447
 
6523
7448
  ctx.on('agent/pre-step', async (payload, next) => {
6524
7449
  const decision = await next()
@@ -6623,6 +7548,49 @@ export function apply(ctx, config = {}) {
6623
7548
  * 当档一样自己回到投影里;靠模型轮询 WorldlineStatus 等于把「系统知道的事」压在模型的记性上。
6624
7549
  */
6625
7550
  let factMutations = []
7551
+ /**
7552
+ * **宿主读面降级:把观测落成账本事实**(`host/inactive`)。
7553
+ *
7554
+ * 宿主半取不到 `sessions` / `sessionProjections` 时会在**进程内**记一条观测,随 `state()`/`view()`
7555
+ * 暴露出来——那是"看得见",但**不在账本上**:重启、换进程、离线复判都读不到它,
7556
+ * 而"这一刻读不到投影"恰恰是最需要能事后解释的一条事实。
7557
+ *
7558
+ * 谁合适写:变更记录只能由内核与宿主人门通道产生(权威边界),所以**内核来写**——
7559
+ * 它在每个 pre-step 读到宿主的观测,把还没上账的那几条落成变更。
7560
+ * id 用宿主给的**内容寻址 id**,折法按 id 幂等 ⇒ 反复观察到同一条也只落一条;
7561
+ * 进程重启后内核会重新观察一次(那时账上已经有一条同 id,折法照样幂等)。
7562
+ */
7563
+ try {
7564
+ const observed = hostService.state(sessionId)?.hostHealth
7565
+ for (const entry of Array.isArray(observed) ? observed : []) {
7566
+ const id = entry?.id
7567
+ if (typeof id !== 'string' || id === '') continue
7568
+ if (landedHostHealth.has(id)) continue
7569
+ landedHostHealth.add(id)
7570
+ factMutations.push({ t: 'host/inactive', id, scope: entry.scope ?? null, detail: entry.detail ?? null })
7571
+ }
7572
+ } catch {
7573
+ // 读不到就不落:这条机制本身不允许成为新的故障点。
7574
+ }
7575
+ /**
7576
+ * **目标文档**(`clear/goals/{goalId}.md`):判据全文的家。
7577
+ *
7578
+ * 卡里现在只给压缩版判据 + 一个指针(见 `knowledge-view.js` 的目标那一段),
7579
+ * 所以这份文件必须真的在盘上——否则"卡瘦了"就变成"判据找不着了"。
7580
+ * 幂等:内容没变就不重写。
7581
+ */
7582
+ try {
7583
+ ensureGoalDoc(sessionId, hostService.state(sessionId), hostService.derive(sessionId))
7584
+ } catch {
7585
+ // 写不出来不影响这一拍:卡里的指针会指向一个还不存在的文件,下拍再试。
7586
+ }
7587
+ /**
7588
+ * **独立落账通道的兜底**:上一步在 `await` 之前落下的派发/派遣事实,如果没赶上
7589
+ * 那一次工具结果(工具抛错、被 abort、或结果丢失),在这里补折一次。
7590
+ * 放在 `factMutations` 初始化之后、别的事实之前——它是**已经发生**的事,
7591
+ * 语义上早于这一拍新收上来的结论。
7592
+ */
7593
+ factMutations.push(...drainPendingFacts(sessionId))
6626
7594
  try {
6627
7595
  // 两级一起收:内存表里那些落定的,以及表里没有、只能从执行者自己的会话日志里读回来的。
6628
7596
  const swept = collectExecutors(factMutations, hostService.state(sessionId), sessionId)
@@ -6748,6 +7716,21 @@ export function apply(ctx, config = {}) {
6748
7716
  } catch (error) {
6749
7717
  return decision
6750
7718
  }
7719
+ /**
7720
+ * 判据全文的一次性注入(见 `lastCriteriaSent` 的注释):只在修订号变过时补,
7721
+ * 补完记住修订号。它走的是同一条卡通道,不另开消息。
7722
+ */
7723
+ try {
7724
+ const cardState = hostService.state(sessionId)
7725
+ const cardGoal = cardState?.goal ?? null
7726
+ if (cardGoal !== null && String(cardGoal.status) === 'open' && lastCriteriaSent.get(sessionId) !== cardGoal.revision) {
7727
+ lastCriteriaSent.set(sessionId, cardGoal.revision)
7728
+ const full = Array.isArray(cardGoal.criteria) && cardGoal.criteria.length > 0 ? cardGoal.criteria.map((item, index) => ` ${index + 1}. ${item}`).join('\n') : ` ${String(cardGoal.done_criteria ?? '')}`
7729
+ card = `${card}\n\n- **判据全文(rev${cardGoal.revision},只在修订后发这一次)**:\n${full}`
7730
+ }
7731
+ } catch {
7732
+ // 读不到状态就不补:卡里仍有压缩版与指针。
7733
+ }
6751
7734
  if (rearmNote !== '') card = `${card}\n${rearmNote}`
6752
7735
  if (brainNote !== '') card = `${card}\n${brainNote}`
6753
7736
  if (workspaceNote !== '') card = `${card}${workspaceNote}`
@@ -6794,7 +7777,9 @@ export function apply(ctx, config = {}) {
6794
7777
  // 而不是运行到某一轮才发现少装了一件工具。清单缺省 = 全开;要裁剪只改清单。
6795
7778
 
6796
7779
  for (const toolName of CONTRIB.tools) {
6797
- ctx.tools.register(TOOL_DEFS.get(toolName))
7780
+ const definition = TOOL_DEFS.get(toolName)
7781
+ // 每一个工具的输出都过一遍统一出口:独立落账通道里的事实不会因为工具抛错/被 abort 而丢。
7782
+ ctx.tools.register({ ...definition, execute: async (args, exec) => withPendingFacts(exec, await definition.execute(args, exec)) })
6798
7783
  }
6799
7784
 
6800
7785
  // 提示词段由当前 ClearAI DSH 预设提供,围绕认识论循环与事实边界组织。