clearai-dsh 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -401,7 +401,22 @@ export const HUMAN_GATE_MARK = '[clearai·人门]'
401
401
  * 两侧白名单一旦各自维护,摘动词时只改一侧 ⇒ 内核仍认、宿主不认 ⇒ **等价性用例立刻红** ✓。
402
402
  * 这正是那条用例存在的意义:两份实现漂移不许静默。
403
403
  */
404
- export const HUMAN_GATE_ACTIONS = ['adopt_branch', 'abandon_fork', 'promote_skill', 'retract_fact', 'keep_fact', 'confirm_provisional']
404
+ /**
405
+ * **逐字镜像 `ui/lib/fold.js` 的同名清单**(预设面不能 import 宿主半,于是只能两份;
406
+ * 两份相等由 `test/authority-boundary.test.mjs` 钉死——它红的时候,是清单漂了,不是测试坏了)。
407
+ */
408
+ export const HUMAN_GATE_ACTIONS = [
409
+ 'adopt_branch',
410
+ 'abandon_fork',
411
+ 'promote_skill',
412
+ 'retract_fact',
413
+ 'keep_fact',
414
+ 'confirm_provisional',
415
+ 'register_term',
416
+ 'register_predicate',
417
+ 'revise_term',
418
+ 'deprecate_entry',
419
+ ]
405
420
  export function parseHumanGateMessage(message) {
406
421
  if (message === null || typeof message !== 'object') return null
407
422
  // 署名必须是人:插件与模型来源的同名标记不算人门动作(与宿主半同一条纪律)。
@@ -478,6 +493,8 @@ export const MECHANISM_TOOLS = {
478
493
  brain: ['SaveSkill', 'WriteMemory'],
479
494
  /** 账本两件:它长在 git 上(A 层用工作区自己的仓库,B 层用数据区的旁路账本,与世界线共用一本)。 */
480
495
  ledger: ['FileHistory', 'RestoreFile'],
496
+ /** 领域语言:七个动词 = 词汇的全部写入口(注册/修订/废止) + 一个读入口(按概念取已知)。 */
497
+ ontology: ['RegisterTerm', 'RegisterPredicate', 'ReviseTerm', 'RevisePredicate', 'DeprecateTerm', 'DeprecatePredicate', 'QueryKnowledge'],
481
498
  }
482
499
  const TOOL_CATALOG = new Set(Object.values(MECHANISM_TOOLS).flat())
483
500
 
@@ -1123,7 +1140,7 @@ export function apply(ctx, config = {}) {
1123
1140
  `- 要裁决的分歧:${fork.question}`,
1124
1141
  `- **你这一个方案**:${branch.label} —— ${branch.approach}`,
1125
1142
  `- **判定标准(你必须做到,并且可被客观核对)**:${branch.done_criteria}`,
1126
- `- **裁决指标(所有世界线共用同一把尺子,不许各自另定口径)**:${fork.decide_by?.metric ?? '(未登记)'}(${fork.decide_by?.direction === 'min' ? '越小越好' : '越大越好'})`,
1143
+ `- **裁决指标(所有世界线共用同一把尺子,不许各自另定口径;口径里引用的那个文件就是测量仪,你的读数必须出自它)**:${fork.decide_by?.metric ?? '(未登记)'}(${fork.decide_by?.direction === 'min' ? '越小越好' : '越大越好'})`,
1127
1144
  /**
1128
1145
  * **声明的物证路径必须告诉干活的人**。
1129
1146
  *
@@ -1152,7 +1169,7 @@ export function apply(ctx, config = {}) {
1152
1169
  })
1153
1170
  if (dispatched.ok !== true) return { ok: false, note: `执行者派不出去(${dispatched.reason})`, mutations }
1154
1171
  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 }
1172
+ 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
1173
  executorRuns.set(childId, entry)
1157
1174
  mutations.push({ t: 'worldline/executing', fork: fork.id, branch: branch.id, child: childId, capability: dispatched.capability, degraded_reason: dispatched.degraded ?? null })
1158
1175
  /**
@@ -1524,7 +1541,7 @@ export function apply(ctx, config = {}) {
1524
1541
 
1525
1542
 
1526
1543
  /**
1527
- * **失联的评估者**。
1544
+ * **已结束的评估者**。
1528
1545
  *
1529
1546
  * `pendingAudits` 是**进程内**的:重启之后它空了,而投影里那条 `audit/dispatched` 还在
1530
1547
  * (`verdict === null`)。后果有两条,后一条更狠:
@@ -1533,13 +1550,16 @@ export function apply(ctx, config = {}) {
1533
1550
  * 一条永远不会回来的裁决,把整个目标按死在挂起上。
1534
1551
  *
1535
1552
  * 判据不看内存,看**宿主的目录**:`subagents.listChildren(sessionId)` 给每个子会话一个
1536
- * `activity: 'running' | 'inactive'`(还在跑 / 只剩日志),而且它不需要把子代理加载起来。
1537
- * 投影里在裁决 + 子会话不在跑 ⇒ 落一条 `audit/settled{verdict:'unknown', note:'lost'}`:
1538
- * 与别的结局用同一个转变,只是缘由写具体(「失联」)。下一次交付会重新派评估者。
1553
+ * `activity: 'running' | 'inactive'`(还在跑 / 只剩日志)——**宿主是"还在不在跑"的唯一权威**。
1554
+ * 目录说已结束 ⇒ 这次运行**结束了**。结束不等于失联:结论可能就躺在它自己的会话日志里,
1555
+ * 所以**先取回**(与侦察的 `sweepScouts` 同一条路),取不回才如实落 unknown。
1556
+ *
1557
+ * 结算只报事实、不给建议(「重新交付会派一个新的评估者」那类话删了):
1558
+ * 要不要重试是计划层的决定,不是账本该说的话。
1539
1559
  *
1540
1560
  * 拿不到目录(服务不在 / 查询失败)时**什么都不做**:不猜、不误伤正在跑的裁决。
1541
1561
  */
1542
- async function sweepLostAudits(sessionId, state) {
1562
+ async function sweepEndedAudits(sessionId, state) {
1543
1563
  const pending = (state?.audits ?? []).filter((audit) => audit.verdict === null && typeof audit.child === 'string' && audit.child !== '')
1544
1564
  if (pending.length === 0) return { mutations: [], lost: 0, lines: [] }
1545
1565
  // 这个进程里正攥着的那几次派遣:它们是活的,不问目录。
@@ -1560,18 +1580,41 @@ export function apply(ctx, config = {}) {
1560
1580
  const lines = []
1561
1581
  for (const audit of unresolved) {
1562
1582
  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,可重新交付)`)
1583
+ /**
1584
+ * 宿主说已结束 ⇒ **先把它的结论取回来**(读它自己的会话日志,与侦察同一条路)。
1585
+ * 跳过它就把「结束」误报成「死亡」,还会诱导重新交付 ⇒ 同一次评估被重做。
1586
+ */
1587
+ const recovered = recoverVerdictFromChildSession(audit.child)
1588
+ if (recovered !== null && recovered.ok === true) {
1589
+ 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() }
1590
+ const cardPath = writeAuditCard(sessionId, audit.step, card)
1591
+ 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 })
1592
+ lines.push(`${audit.step}:裁决从子会话日志取回(${recovered.verdict.verdict})`)
1593
+ continue
1594
+ }
1595
+ if (recovered !== null && recovered.ok !== true) {
1596
+ mutations.push({ t: 'audit/settled', id: audit.id, step: audit.step, verdict: 'unknown', basis: `评估者已结束,但未正常完成(${recovered.stopReason})。`, shortfalls: ['audit_incomplete'], card_path: null })
1597
+ lines.push(`${audit.step}:评估者已结束、未正常完成(记为 unknown)`)
1598
+ continue
1599
+ }
1600
+ mutations.push({ t: 'audit/settled', id: audit.id, step: audit.step, verdict: 'unknown', basis: '评估者已结束(宿主目录报告),但其结论未能从子会话日志取回。', shortfalls: ['auditor_ended_uncollected'], card_path: null })
1601
+ lines.push(`${audit.step}:评估者已结束、结论未取回(记为 unknown)`)
1573
1602
  }
1574
- return { mutations, lost: mutations.length, lines }
1603
+ return { mutations, settled: mutations.length, lines }
1604
+ }
1605
+
1606
+ /**
1607
+ * 从子会话日志里取回**评估者**的裁决——与侦察的 `recoverFromChildSession` 同一条路。
1608
+ *
1609
+ * 返回三档:`{ok:true, verdict}` 取回了;`{ok:false, stopReason}` 它结束了但未正常完成;
1610
+ * `null` 连它的日志都读不到。三档都只是**读数**,判断留给调用方如实分档。
1611
+ */
1612
+ function recoverVerdictFromChildSession(childId) {
1613
+ const settled = recoverFromChildSession(childId)
1614
+ if (settled === null) return null
1615
+ if (settled.ok !== true) return { ok: false, stopReason: settled.stopReason }
1616
+ const verdict = normalizeVerdict(parseLooseJson([{ type: 'text', text: settled.conclusion }]))
1617
+ return { ok: true, verdict }
1575
1618
  }
1576
1619
 
1577
1620
  function sweepWorldlineExecutors(state, sessionId) {
@@ -1711,7 +1754,7 @@ export function apply(ctx, config = {}) {
1711
1754
  * 事实该在副作用之前/同批落账:所以派遣立即返回,结论由 `sweepScouts()` 在
1712
1755
  * 「下一个回合边界 / 用到世界线与侦察的那几件工具」上收(与执行者完全同一套)。
1713
1756
  */
1714
- const entry = { scoutId, stepId: step.id, planId: plan?.id ?? null, trigger, digest, child: childId, settled: null, reported: false, run: dispatched.run }
1757
+ const entry = { sessionId, scoutId, stepId: step.id, planId: plan?.id ?? null, trigger, digest, child: childId, settled: null, reported: false, run: dispatched.run }
1715
1758
  scoutRuns.set(childId, entry)
1716
1759
  /**
1717
1760
  * 只有一次性派遣才有 `result` promise。可续跑那一档没有 ⇒ 结算改由
@@ -1753,7 +1796,7 @@ export function apply(ctx, config = {}) {
1753
1796
  return { verdict: 'unknown', basis: `独立评估者无法派遣(${dispatched.reason})`, shortfalls: ['audit_dispatch_failed'], cardPath: null, mutations }
1754
1797
  }
1755
1798
  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 }
1799
+ entry = { sessionId, run: dispatched.run, capability: dispatched.capability, auditKey, step: step.id, plan: plan?.id ?? 'goal', kind, settled: undefined }
1757
1800
  pendingAudits.set(key, entry)
1758
1801
  entry.settled = dispatched.run.result.then(
1759
1802
  (value) => ({ ok: true, value }),
@@ -1774,12 +1817,24 @@ export function apply(ctx, config = {}) {
1774
1817
  // 已落定:这次派遣的生命周期到此为止。下一次交付是**新的一次评估**(新证据),必须重新派遣。
1775
1818
  pendingAudits.delete(key)
1776
1819
  if (outcome.ok !== true) {
1777
- return { verdict: 'unknown', basis: `评估者失败:${String(outcome.error?.message ?? outcome.error)}`, shortfalls: ['audit_failed'], cardPath: null, mutations }
1820
+ return settleUnknown(`评估者失败:${String(outcome.error?.message ?? outcome.error)}`, ['audit_failed'])
1821
+ }
1822
+ /**
1823
+ * **裁决一旦结束,就要落一条结算事实**——不管结局是什么。
1824
+ *
1825
+ * 三条路原来直接 `return unknown`(评估者失败 / 没正常结束 / 评估卡落盘失败),
1826
+ * 账上只剩 `audit/dispatched`:看上去像"它还在跑",而它**已经结束了**。
1827
+ * 结局不好也是结局,如实落下来——不然那条派发事实会在账上挂到天荒地老,
1828
+ * 而"评估者没有悬空"这条不变量也只能红着,连解释都拿不出证据。
1829
+ */
1830
+ const settleUnknown = (basis, shortfalls) => {
1831
+ mutations.push({ t: 'audit/settled', id: entry.auditKey, step: step.id, verdict: 'unknown', basis, shortfalls, card_path: null })
1832
+ return { verdict: 'unknown', basis, shortfalls, cardPath: null, mutations }
1778
1833
  }
1779
1834
  const settled = outcome.value
1780
1835
  const stopReason = String(settled?.stopReason ?? 'completed')
1781
1836
  if (stopReason !== 'completed' && settled?.structured === undefined) {
1782
- return { verdict: 'unknown', basis: `评估者未正常结束(${stopReason})`, shortfalls: ['audit_incomplete'], cardPath: null, mutations }
1837
+ return settleUnknown(`评估者未正常结束(${stopReason})`, ['audit_incomplete'])
1783
1838
  }
1784
1839
  const verdict = settled?.structured !== undefined ? normalizeVerdict(settled.structured) : normalizeVerdict(parseLooseJson(settled?.output))
1785
1840
  try {
@@ -1790,7 +1845,7 @@ export function apply(ctx, config = {}) {
1790
1845
  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
1846
  const cardPath = writeAuditCard(sessionId, step.id, card)
1792
1847
  if (cardPath === null) {
1793
- return { verdict: 'unknown', basis: '评估卡落盘失败:裁决降级', shortfalls: ['card_persist_failed'], cardPath: null, mutations }
1848
+ return settleUnknown('评估卡落盘失败:裁决降级', ['card_persist_failed'])
1794
1849
  }
1795
1850
  mutations.push({ t: 'audit/settled', id: entry.auditKey, step: step.id, verdict: verdict.verdict, basis: verdict.basis, shortfalls: verdict.shortfalls, card_path: cardPath })
1796
1851
  return { ...verdict, cardPath, mutations }
@@ -2519,7 +2574,7 @@ export function apply(ctx, config = {}) {
2519
2574
  const facts = Array.isArray(state?.facts) ? state.facts : []
2520
2575
  if (facts.length === 0) return null
2521
2576
  const lines = [
2522
- '# 事实库(已升格、可作为「已知」引用)',
2577
+ '# 本体内容(已确立条目,可作为「已知」引用;未结构化的旧条目照旧在架)',
2523
2578
  '',
2524
2579
  '> 每条都带**边界**(推翻条件)与**支持等级**:引用它之前先看边界还在不在。',
2525
2580
  '> 这一份由系统维护(做的人不能写 `clear/knowledge/facts`);新的在前。',
@@ -2567,6 +2622,59 @@ export function apply(ctx, config = {}) {
2567
2622
  }
2568
2623
  }
2569
2624
 
2625
+ /**
2626
+ * **领域词汇货架**:把折出来的词汇落成 `clear/ontology/domain.md`。
2627
+ *
2628
+ * 与过程本体货架同一条纪律(读面 + 幂等),但**触发时机不同**:过程本体随发布版本,
2629
+ * 一个会话铺一次就够;词汇每一步都可能变,所以每个本体动词落账之后都要重铺一遍——
2630
+ * 内容没变就不重写(文件时间戳是给人的读数,不是噪声)。
2631
+ *
2632
+ * 带 `mutations` 时按**这一步之后**的样子渲染:工具返回前货架就已更新,读的人不必等下一回合。
2633
+ */
2634
+ function ensureDomainShelf(hostService, sessionId, mutations = []) {
2635
+ if (hostService?.domain?.renderShelf === undefined) return ''
2636
+ try {
2637
+ const body = hostService.domain.renderShelf(sessionId, Array.isArray(mutations) ? mutations : [])
2638
+ const file = join(sessionCwd(sessionId), 'clear', 'ontology', 'domain.md')
2639
+ if (existsSync(file) && readFileSync(file, 'utf8') === body) return ''
2640
+ writeTextFile(file, body)
2641
+ return `\n词汇货架已更新:${join('clear', 'ontology', 'domain.md')}(概念 / 谓词 / 图 / 引用)。**它是读面,不是权威**——要改词汇就调注册 / 修订 / 废止动词。`
2642
+ } catch (error) {
2643
+ ctx.logger?.warn?.(`clearai domain shelf: 写入失败 ${String(error?.message ?? error).slice(0, 160)}`)
2644
+ return ''
2645
+ }
2646
+ }
2647
+
2648
+ /**
2649
+ * 领域判据的宿主入口。**拿不到就明确拒,不抛**:预设与宿主半同包同版本,
2650
+ * 但一个缺了这道门的宿主(旧包、裁剪过的部署、测试桩)不该让工具在 `undefined` 上崩——
2651
+ * 崩掉会让模型以为是自己的参数错了,而真实原因是这一层没接上。
2652
+ */
2653
+ function domainJudge(hostService) {
2654
+ return hostService?.domain ?? null
2655
+ }
2656
+
2657
+ /** 在折出来的词汇里找一个条目(概念或谓词)。判据与折法同源:读的就是 `state.lexicon`。 */
2658
+ function findLexiconEntry(state, id) {
2659
+ const lexicon = state?.lexicon ?? {}
2660
+ const wanted = String(id ?? '').trim()
2661
+ if (wanted === '') return null
2662
+ const term = (Array.isArray(lexicon.terms) ? lexicon.terms : []).find((item) => item.id === wanted)
2663
+ if (term !== undefined) return { kind: 'term', entry: term }
2664
+ const predicate = (Array.isArray(lexicon.predicates) ? lexicon.predicates : []).find((item) => item.id === wanted)
2665
+ if (predicate !== undefined) return { kind: 'predicate', entry: predicate }
2666
+ return null
2667
+ }
2668
+
2669
+ /** 查询条件的一行人话:让「查不到」也说得清查的是什么。 */
2670
+ function describeFilter(filter) {
2671
+ const parts = []
2672
+ if (filter.term !== '') parts.push(`概念「${filter.term}」`)
2673
+ if (filter.predicate !== '') parts.push(`谓词「${filter.predicate}」`)
2674
+ if (filter.subject !== '') parts.push(`主体「${filter.subject}」`)
2675
+ return `查询:${parts.join(' · ')}`
2676
+ }
2677
+
2570
2678
  /**
2571
2679
  * **本体货架**:把已装的那份本体落成 `clear/ontology/<id>.md`。
2572
2680
  *
@@ -2647,10 +2755,34 @@ export function apply(ctx, config = {}) {
2647
2755
  promote_at_level: { type: 'string', enum: LEVELS, description: '升格门槛(默认 L3)' },
2648
2756
  hypotheses: {
2649
2757
  type: 'array',
2650
- description: '候选假设:每条一句话主张 + 一句推翻条件',
2758
+ description:
2759
+ '候选假设:每条一句话主张 + 一句推翻条件;可带**类型化断言**(可选,提供即严校:谓词与概念必须已登记、宾语形态要合值域、同一事实里不许自相矛盾)。不写断言照旧成立——断言是加法,不是门槛。',
2651
2760
  items: {
2652
2761
  type: 'object',
2653
- properties: { claim: { type: 'string' }, refute_when: { type: 'string' } },
2762
+ properties: {
2763
+ claim: { type: 'string' },
2764
+ refute_when: { type: 'string' },
2765
+ assertions: {
2766
+ type: 'array',
2767
+ description: '断言:主词–谓词–宾语(引用领域词汇里的 id)',
2768
+ items: {
2769
+ type: 'object',
2770
+ properties: {
2771
+ predicate: { type: 'string' },
2772
+ subject: { type: 'object', properties: { id: { type: 'string' }, type: { type: 'string' } }, required: ['id'], additionalProperties: false },
2773
+ object: {
2774
+ type: 'object',
2775
+ properties: { kind: { type: 'string', enum: ['statement', 'quantity', 'formula', 'code', 'reference', 'instance'] }, value: {}, unit: { type: 'string' }, type: { type: 'string' } },
2776
+ required: ['kind'],
2777
+ additionalProperties: false,
2778
+ },
2779
+ qualifiers: { type: 'object' },
2780
+ },
2781
+ required: ['predicate', 'subject', 'object'],
2782
+ additionalProperties: false,
2783
+ },
2784
+ },
2785
+ },
2654
2786
  required: ['claim', 'refute_when'],
2655
2787
  additionalProperties: false,
2656
2788
  },
@@ -2676,6 +2808,19 @@ export function apply(ctx, config = {}) {
2676
2808
  for (const hypothesis of hypotheses) {
2677
2809
  if (typeof hypothesis?.claim !== 'string' || hypothesis.claim.trim() === '') return fail('hypothesis_claim_required', '每条假设要有一句话主张。')
2678
2810
  if (typeof hypothesis?.refute_when !== 'string' || hypothesis.refute_when.trim() === '') return fail('hypothesis_refute_required', '每条假设必须写清「什么结果会推翻它」——没有推翻条件的假设无法被检验。')
2811
+ /**
2812
+ * **宽松+校验**:不写断言放行(断言是加法),写了就在**落账之前**严校——
2813
+ * 引用不存在的谓词 / 概念、宾语形态不合值域、同一事实自相矛盾,一律当场拒。
2814
+ * 判据来自宿主半(`hostService.domain`),与折法和读面同源。
2815
+ */
2816
+ if (hypothesis.assertions !== undefined && hypothesis.assertions !== null) {
2817
+ const judge = domainJudge(hostService)
2818
+ if (judge === null) return fail('domain_unavailable', '这一层的宿主没有提供领域判据(domain facade):无法校验断言。请检查宿主半与预设是否同版本。')
2819
+ const problems = judge.validateAssertions(sessionId, hypothesis.assertions)
2820
+ if (problems.length > 0) {
2821
+ return fail('assertions_rejected', `这条假设的断言不能成立(先注册词汇,或改断言):\n${problems.map((item) => `- ${item}`).join('\n')}`)
2822
+ }
2823
+ }
2679
2824
  }
2680
2825
  // 假设数量下限:首次立目标就得带够候选——0 条一样拦(候选对比是检验的前提,
2681
2826
  // 只有一个猜想时「验证」容易退化成找证据支持自己)。修订不受此限。
@@ -2701,6 +2846,8 @@ export function apply(ctx, config = {}) {
2701
2846
  id: `h-${Math.random().toString(36).slice(2, 8)}`,
2702
2847
  claim: hypothesis.claim.trim(),
2703
2848
  refute_when: hypothesis.refute_when.trim(),
2849
+ /** 断言随假设落账;没写就是 null(加法,不是门槛)。 */
2850
+ assertions: Array.isArray(hypothesis.assertions) ? hypothesis.assertions : null,
2704
2851
  version: index + 1,
2705
2852
  })),
2706
2853
  })
@@ -2871,10 +3018,17 @@ export function apply(ctx, config = {}) {
2871
3018
  t: 'fact/promoted',
2872
3019
  id: factId,
2873
3020
  goal: goal.id,
3021
+ /**
3022
+ * **身份与内容一起定型**:`hypothesis` 是产出它的那条假设(按 id 关联,
3023
+ * 措辞改了也认得出),`assertions` 是这条事实的类型化内容(没写就是 null)。
3024
+ * 断言只在**升格这一刻**落地——旧事实不会被回溯改写。
3025
+ */
3026
+ hypothesis: hypothesis.id,
2874
3027
  text: hypothesis.claim,
2875
3028
  scope: hypothesis.refute_when ?? null,
2876
3029
  level: hypothesis.supportedLevel ?? null,
2877
3030
  evidence: state.evidence.filter((item) => item.verdict === 'support').map((item) => item.id),
3031
+ assertions: Array.isArray(hypothesis.assertions) ? hypothesis.assertions : null,
2878
3032
  path,
2879
3033
  })
2880
3034
  promoted.push(hypothesis.claim)
@@ -2969,6 +3123,301 @@ export function apply(ctx, config = {}) {
2969
3123
  additionalProperties: false,
2970
3124
  }
2971
3125
 
3126
+
3127
+ // ── 领域语言(本体动词)─────────────────────────────────────────────────
3128
+ /**
3129
+ * 七个动词 = 领域词汇的**全部写入口**(注册 / 修订 / 废止,概念与谓词各一套)+ 一个读入口。
3130
+ *
3131
+ * 它们只做一件事:把「这个领域有哪些概念、哪些谓词、关系取什么值形态」写进账本事件,
3132
+ * 由折法折成 `state.lexicon`;再由同一份折法长出本体图、冲突读数与货架。
3133
+ *
3134
+ * 四条贯穿所有动词的纪律:
3135
+ * · **判据只有一份**——校验经 `hostService.domain.*`(与折法、读面同源),预设侧不复制规则;
3136
+ * · **依据必填**——约定可以自愿,不能无来由;
3137
+ * · **没有删除**——修订留版本,废止留缘由且是黏性终态;
3138
+ * · **语义变化必须换 id**——改展示信息走修订,改含义/主词域/值域走「废止 + 新注册」。
3139
+ */
3140
+ defineTool({
3141
+ name: 'RegisterTerm',
3142
+ description:
3143
+ '登记一个领域概念(本体图上的节点)。id 用小写 slug;`gloss` 一句话说清它指什么;`basis` 必填——哪份材料、哪条事实或人说的哪句话让这个词成立。`parent` 可选(is_a,只能连概念)。登记是**约定**不是主张:它不需要证据等级,但从此可以出现在断言里。要改展示信息用 ReviseTerm,要作废用 DeprecateTerm(记录不会删)。',
3144
+ parameters: {
3145
+ type: 'object',
3146
+ properties: {
3147
+ id: { type: 'string', description: '小写字母开头的 slug(字母/数字/下划线,≤40)' },
3148
+ label: { type: 'string', description: '给人看的名字' },
3149
+ gloss: { type: 'string', description: '一句话释义' },
3150
+ aliases: { type: 'array', items: { type: 'string' }, description: '别名(可选)' },
3151
+ parent: { type: 'string', description: '父概念 id(可选,is_a)' },
3152
+ basis: { type: 'string', description: '依据:哪份材料 / 哪条事实 / 谁说的' },
3153
+ },
3154
+ required: ['id', 'label', 'gloss', 'basis'],
3155
+ additionalProperties: false,
3156
+ },
3157
+ output: CARD_OUTPUT,
3158
+ async execute(args, exec) {
3159
+ const call = open(exec)
3160
+ if (call.ok !== true) return call.response
3161
+ const { hostService, sessionId, mutations } = call
3162
+ const done = finish(hostService, sessionId, mutations)
3163
+ const judge = domainJudge(hostService)
3164
+ if (judge === null) return fail('domain_unavailable', '这一层的宿主没有提供领域判据(domain facade):无法校验词汇。请检查宿主半与预设是否同版本。')
3165
+ const problems = judge.validateTerm(sessionId, args)
3166
+ if (problems.length > 0) return fail('term_rejected', `这个词不能登记:\n${problems.map((item) => `- ${item}`).join('\n')}`)
3167
+ mutations.push({
3168
+ t: 'ontology/term_added',
3169
+ id: String(args.id).trim(),
3170
+ label: String(args.label).trim(),
3171
+ gloss: String(args.gloss).trim(),
3172
+ aliases: Array.isArray(args.aliases) ? args.aliases.map((alias) => String(alias)) : [],
3173
+ parent: args.parent === undefined ? null : String(args.parent).trim(),
3174
+ basis: String(args.basis).trim(),
3175
+ })
3176
+ const note = ensureDomainShelf(hostService, sessionId, mutations)
3177
+ return done({
3178
+ ok: true,
3179
+ code: 'term_registered',
3180
+ message: `概念 ${String(args.id).trim()} 已登记。它现在是本体图上的一个节点:新断言可以引用它。${note}`,
3181
+ })
3182
+ },
3183
+ })
3184
+
3185
+ defineTool({
3186
+ name: 'RegisterPredicate',
3187
+ description:
3188
+ '登记一个领域谓词(本体图上的边)。值域二选一:`range={form:"quantity"|"statement"|"formula"|"code"|"reference",unit?}` 说宾语是一个**字面值**,或 `range={term:"<概念 id>"}` 说宾语是**另一个概念的实例**。`domain` 可选(主词域,声明了它,断言的主体就必须写明类型)。`functional=true` 表示单值:同一主体出现两个不同取值时,投影会给出一对**冲突**(只暴露,不裁决)。',
3189
+ parameters: {
3190
+ type: 'object',
3191
+ properties: {
3192
+ id: { type: 'string', description: '小写字母开头的 slug' },
3193
+ label: { type: 'string', description: '给人看的名字' },
3194
+ gloss: { type: 'string', description: '一句话释义(可选)' },
3195
+ domain: { type: 'string', description: '主词域:概念 id(可选)' },
3196
+ range: {
3197
+ type: 'object',
3198
+ description: '值域:{form,unit?} 或 {term}',
3199
+ properties: {
3200
+ form: { type: 'string', enum: ['statement', 'quantity', 'formula', 'code', 'reference'] },
3201
+ unit: { type: 'string' },
3202
+ term: { type: 'string' },
3203
+ },
3204
+ additionalProperties: false,
3205
+ },
3206
+ functional: { type: 'boolean', description: '是否单值(默认否)' },
3207
+ basis: { type: 'string', description: '依据(必填)' },
3208
+ },
3209
+ required: ['id', 'label', 'range', 'basis'],
3210
+ additionalProperties: false,
3211
+ },
3212
+ output: CARD_OUTPUT,
3213
+ async execute(args, exec) {
3214
+ const call = open(exec)
3215
+ if (call.ok !== true) return call.response
3216
+ const { hostService, sessionId, mutations } = call
3217
+ const done = finish(hostService, sessionId, mutations)
3218
+ const judge = domainJudge(hostService)
3219
+ if (judge === null) return fail('domain_unavailable', '这一层的宿主没有提供领域判据(domain facade):无法校验词汇。请检查宿主半与预设是否同版本。')
3220
+ const problems = judge.validatePredicate(sessionId, args)
3221
+ if (problems.length > 0) return fail('predicate_rejected', `这个谓词不能登记:\n${problems.map((item) => `- ${item}`).join('\n')}`)
3222
+ mutations.push({
3223
+ t: 'ontology/predicate_added',
3224
+ id: String(args.id).trim(),
3225
+ label: String(args.label).trim(),
3226
+ gloss: args.gloss === undefined ? '' : String(args.gloss).trim(),
3227
+ domain: args.domain === undefined ? null : String(args.domain).trim(),
3228
+ range: args.range,
3229
+ functional: args.functional === true,
3230
+ basis: String(args.basis).trim(),
3231
+ })
3232
+ const note = ensureDomainShelf(hostService, sessionId, mutations)
3233
+ return done({
3234
+ ok: true,
3235
+ code: 'predicate_registered',
3236
+ message: `谓词 ${String(args.id).trim()} 已登记(${args.range?.term ? `宾语是概念 ${args.range.term} 的实例` : `宾语取 ${args.range?.form} 形态`}${args.functional === true ? ' · 单值' : ''})。下一步:在 SetGoal 的假设里带上断言,引用它。${note}`,
3237
+ })
3238
+ },
3239
+ })
3240
+
3241
+ defineTool({
3242
+ name: 'ReviseTerm',
3243
+ description:
3244
+ '修订一个概念的**展示信息**(名字 / 释义 / 别名):版本 +1,旧值留在账上,id 不变。**语义变化不许走这条路**——含义、父概念变了就废止旧条目、注册新条目;稳定 id 的含义在历史上悄悄改变,等于拿今天的释义重写所有旧事实。',
3245
+ parameters: {
3246
+ type: 'object',
3247
+ properties: {
3248
+ id: { type: 'string' },
3249
+ label: { type: 'string', description: '新名字(可选)' },
3250
+ gloss: { type: 'string', description: '新释义(可选)' },
3251
+ aliases: { type: 'array', items: { type: 'string' } },
3252
+ reason: { type: 'string', description: '为什么改(必填)' },
3253
+ },
3254
+ required: ['id', 'reason'],
3255
+ additionalProperties: false,
3256
+ },
3257
+ output: CARD_OUTPUT,
3258
+ async execute(args, exec) {
3259
+ const call = open(exec)
3260
+ if (call.ok !== true) return call.response
3261
+ const { hostService, sessionId, state, mutations } = call
3262
+ const done = finish(hostService, sessionId, mutations)
3263
+ const entry = findLexiconEntry(state, args.id)
3264
+ if (entry === null || entry.kind !== 'term') return fail('unknown_term', `词汇里没有这个概念:${String(args.id)}`)
3265
+ if (entry.entry.status === 'deprecated') return fail('term_deprecated', `概念 ${String(args.id)} 已废止,不能修订(要恢复语义就注册新条目)。`)
3266
+ if (typeof args.reason !== 'string' || args.reason.trim() === '') return fail('reason_required', '修订要写一句原因:改了什么、为什么改。')
3267
+ if (args.label === undefined && args.gloss === undefined && args.aliases === undefined) return fail('nothing_to_revise', '没有要改的字段:label / gloss / aliases 至少给一个。')
3268
+ mutations.push({ t: 'ontology/term_revised', id: String(args.id), label: args.label === undefined ? undefined : String(args.label).trim(), gloss: args.gloss === undefined ? undefined : String(args.gloss).trim(), aliases: Array.isArray(args.aliases) ? args.aliases.map((alias) => String(alias)) : undefined, reason: String(args.reason).trim() })
3269
+ const note = ensureDomainShelf(hostService, sessionId, mutations)
3270
+ return done({ ok: true, code: 'term_revised', message: `概念 ${String(args.id)} 已修订(旧值留在账上)。${note}` })
3271
+ },
3272
+ })
3273
+
3274
+ defineTool({
3275
+ name: 'RevisePredicate',
3276
+ description: '修订一个谓词的**展示信息**(名字 / 释义)。值域、主词域与单值性是语义——那三样变了要废止旧谓词、注册新的。',
3277
+ parameters: {
3278
+ type: 'object',
3279
+ properties: { id: { type: 'string' }, label: { type: 'string' }, gloss: { type: 'string' }, reason: { type: 'string' } },
3280
+ required: ['id', 'reason'],
3281
+ additionalProperties: false,
3282
+ },
3283
+ output: CARD_OUTPUT,
3284
+ async execute(args, exec) {
3285
+ const call = open(exec)
3286
+ if (call.ok !== true) return call.response
3287
+ const { hostService, sessionId, state, mutations } = call
3288
+ const done = finish(hostService, sessionId, mutations)
3289
+ const entry = findLexiconEntry(state, args.id)
3290
+ if (entry === null || entry.kind !== 'predicate') return fail('unknown_predicate', `词汇里没有这个谓词:${String(args.id)}`)
3291
+ if (entry.entry.status === 'deprecated') return fail('predicate_deprecated', `谓词 ${String(args.id)} 已废止,不能修订。`)
3292
+ if (typeof args.reason !== 'string' || args.reason.trim() === '') return fail('reason_required', '修订要写一句原因。')
3293
+ mutations.push({ t: 'ontology/predicate_revised', id: String(args.id), label: args.label === undefined ? undefined : String(args.label).trim(), gloss: args.gloss === undefined ? undefined : String(args.gloss).trim(), reason: String(args.reason).trim() })
3294
+ const note = ensureDomainShelf(hostService, sessionId, mutations)
3295
+ return done({ ok: true, code: 'predicate_revised', message: `谓词 ${String(args.id)} 已修订(版本 +1,旧值留着)。${note}` })
3296
+ },
3297
+ })
3298
+
3299
+ /**
3300
+ * **废止是黏性终态**:没有复活这条路。存量事实照旧可读(历史留着),
3301
+ * 引用它的**新**断言会被拒,并在货架上标明「所用术语已废止」。
3302
+ */
3303
+ defineTool({
3304
+ name: 'DeprecateTerm',
3305
+ description:
3306
+ '废止一个概念(或谓词):缘由必填。**没有删除**——条目、旧版本与引用过它的事实全部留着;新断言不许再引用它,存量事实在货架上标明「所用术语已废止」。要恢复语义就注册一个新条目(那是覆盖,不是复活)。',
3307
+ parameters: {
3308
+ type: 'object',
3309
+ properties: { id: { type: 'string', description: '概念或谓词的 id' }, reason: { type: 'string', description: '为什么废止(必填)' } },
3310
+ required: ['id', 'reason'],
3311
+ additionalProperties: false,
3312
+ },
3313
+ output: CARD_OUTPUT,
3314
+ async execute(args, exec) {
3315
+ const call = open(exec)
3316
+ if (call.ok !== true) return call.response
3317
+ const { hostService, sessionId, state, mutations } = call
3318
+ const done = finish(hostService, sessionId, mutations)
3319
+ const entry = findLexiconEntry(state, args.id)
3320
+ if (entry === null) return fail('unknown_entry', `词汇里没有这个条目:${String(args.id)}`)
3321
+ if (entry.entry.status === 'deprecated') return fail('already_deprecated', `${String(args.id)} 已经是废止状态(这一步不会重复记账)。`)
3322
+ if (typeof args.reason !== 'string' || args.reason.trim() === '') return fail('reason_required', '废止要写缘由:为什么这个词不再用。')
3323
+ mutations.push({ t: entry.kind === 'term' ? 'ontology/term_deprecated' : 'ontology/predicate_deprecated', id: String(args.id), reason: String(args.reason).trim() })
3324
+ const note = ensureDomainShelf(hostService, sessionId, mutations)
3325
+ return done({
3326
+ ok: true,
3327
+ code: 'entry_deprecated',
3328
+ message: `${entry.kind === 'term' ? '概念' : '谓词'} ${String(args.id)} 已废止(记录保留,新断言不许再引用;引用过它的事实照旧可读)。${note}`,
3329
+ })
3330
+ },
3331
+ })
3332
+
3333
+ defineTool({
3334
+ name: 'DeprecatePredicate',
3335
+ description: '废止一个谓词:缘由必填,记录保留,新断言不许再引用它(与 DeprecateTerm 同一条纪律,单列出来是为了让参数与语义各自说清)。',
3336
+ parameters: {
3337
+ type: 'object',
3338
+ properties: { id: { type: 'string' }, reason: { type: 'string' } },
3339
+ required: ['id', 'reason'],
3340
+ additionalProperties: false,
3341
+ },
3342
+ output: CARD_OUTPUT,
3343
+ async execute(args, exec) {
3344
+ const call = open(exec)
3345
+ if (call.ok !== true) return call.response
3346
+ const { hostService, sessionId, state, mutations } = call
3347
+ const done = finish(hostService, sessionId, mutations)
3348
+ const entry = findLexiconEntry(state, args.id)
3349
+ if (entry === null || entry.kind !== 'predicate') return fail('unknown_predicate', `词汇里没有这个谓词:${String(args.id)}`)
3350
+ if (entry.entry.status === 'deprecated') return fail('already_deprecated', `${String(args.id)} 已经是废止状态。`)
3351
+ if (typeof args.reason !== 'string' || args.reason.trim() === '') return fail('reason_required', '废止要写缘由。')
3352
+ mutations.push({ t: 'ontology/predicate_deprecated', id: String(args.id), reason: String(args.reason).trim() })
3353
+ const note = ensureDomainShelf(hostService, sessionId, mutations)
3354
+ return done({ ok: true, code: 'entry_deprecated', message: `谓词 ${String(args.id)} 已废止(记录保留,新断言不许再引用)。${note}` })
3355
+ },
3356
+ })
3357
+
3358
+ /**
3359
+ * **螺旋上半圈的入口**:按概念 / 谓词 / 主体取「已知」。
3360
+ * 读数全部来自投影(事实与流转中的假设),不新建任何存储;它也不改任何东西。
3361
+ */
3362
+ defineTool({
3363
+ name: 'QueryKnowledge',
3364
+ description:
3365
+ '按概念 / 谓词 / 主体查「已知」:返回带类型化断言的**已升格事实**与**还在流转的命题**。要复用前面的结论就用它,而不是把事实库重读一遍。只读:它不改任何东西。',
3366
+ parameters: {
3367
+ type: 'object',
3368
+ properties: {
3369
+ term: { type: 'string', description: '概念 id:匹配断言的主体类型 / 宾语概念 / 词条本身' },
3370
+ predicate: { type: 'string', description: '谓词 id' },
3371
+ subject: { type: 'string', description: '主体名称(实例)' },
3372
+ },
3373
+ additionalProperties: false,
3374
+ },
3375
+ output: CARD_OUTPUT,
3376
+ async execute(args, exec) {
3377
+ const call = open(exec)
3378
+ if (call.ok !== true) return call.response
3379
+ const { hostService, sessionId, state, mutations } = call
3380
+ const done = finish(hostService, sessionId, mutations)
3381
+ const derived = hostService.derive(sessionId)
3382
+ const filter = { term: args.term === undefined ? '' : String(args.term).trim(), predicate: args.predicate === undefined ? '' : String(args.predicate).trim(), subject: args.subject === undefined ? '' : String(args.subject).trim() }
3383
+ if (filter.term === '' && filter.predicate === '' && filter.subject === '') return fail('query_empty', '至少给一个条件:term / predicate / subject。')
3384
+ const match = (assertion) => {
3385
+ const subject = assertion?.subject ?? {}
3386
+ const object = assertion?.object ?? {}
3387
+ if (filter.predicate !== '' && String(assertion?.predicate ?? '') !== filter.predicate) return false
3388
+ if (filter.subject !== '' && String(subject.id ?? '') !== filter.subject) return false
3389
+ if (filter.term !== '') {
3390
+ const hits = [String(subject.type ?? ''), String(subject.id ?? ''), String(object.kind) === 'instance' ? String(object.type ?? '') : '', String(object.kind) === 'instance' ? String(object.value ?? '') : '', String(assertion?.predicate ?? '')]
3391
+ if (!hits.includes(filter.term)) return false
3392
+ }
3393
+ return true
3394
+ }
3395
+ const facts = derived.factRows.filter((fact) => (Array.isArray(fact.assertions) ? fact.assertions : []).some(match))
3396
+ const inFlight = derived.hypotheses.filter((hypothesis) => (Array.isArray(hypothesis.assertions) ? hypothesis.assertions : []).some(match))
3397
+ if (facts.length === 0 && inFlight.length === 0) {
3398
+ return done({ ok: true, code: 'knowledge_empty', message: `这个词汇条件下还没有任何东西:${describeFilter(filter)}。要么先登记/验证,要么换个条件——**不要**把「查不到」写成「不存在」。` })
3399
+ }
3400
+ const lines = []
3401
+ if (facts.length > 0) {
3402
+ lines.push(`已知(已升格,可作为已知引用):${facts.length} 条`)
3403
+ for (const fact of facts.slice(0, 12)) {
3404
+ lines.push(`- ${fact.id} · ${clip(String(fact.text ?? ''), 120)}(支持到 ${fact.level ?? '—'}${fact.review?.decision === 'retracted' ? ' · **已撤回**' : fact.refuted === true ? ' · 有推翻证据等人决定' : ''})`)
3405
+ lines.push(` 边界:${clip(String(fact.scope ?? '(未写)'), 120)}`)
3406
+ for (const assertion of fact.assertions ?? []) if (match(assertion)) lines.push(` 断言:${hostService.domain.format(sessionId, assertion)}`)
3407
+ }
3408
+ if (facts.length > 12) lines.push(` (还有 ${facts.length - 12} 条,见 clear/knowledge/facts/INDEX.md)`)
3409
+ }
3410
+ if (inFlight.length > 0) {
3411
+ lines.push(`还在流转的命题(未升格):${inFlight.length} 条`)
3412
+ for (const hypothesis of inFlight.slice(0, 8)) {
3413
+ lines.push(`- ${hypothesis.id} [${hypothesis.status}] ${clip(String(hypothesis.claim ?? ''), 100)}${hypothesis.supportedLevel === null ? '' : `(支持到 ${hypothesis.supportedLevel})`}`)
3414
+ for (const assertion of hypothesis.assertions ?? []) if (match(assertion)) lines.push(` 断言:${hostService.domain.format(sessionId, assertion)}`)
3415
+ }
3416
+ }
3417
+ return done({ ok: true, code: 'knowledge_found', message: `${describeFilter(filter)}\n${lines.join('\n')}` })
3418
+ },
3419
+ })
3420
+
2972
3421
  defineTool({
2973
3422
  name: 'CreatePlan',
2974
3423
  description:
@@ -3661,18 +4110,23 @@ export function apply(ctx, config = {}) {
3661
4110
  *
3662
4111
  * 失败如实告警并返回空串:账本没记上就说没记上,不假装记过。
3663
4112
  */
3664
- function snapshotWorkspace(sessionId, cwd, state, mutations, turn) {
4113
+ function snapshotWorkspace(sessionId, cwd, state, mutations, turn, phase = 'mid') {
3665
4114
  const calls = Number(state?.writeCalls ?? 0)
3666
4115
  if (calls <= 0 || lastWorkspaceSnapshot.get(sessionId) === calls) return ''
3667
4116
  lastWorkspaceSnapshot.set(sessionId, calls)
3668
- const committed = commitLedger(cwd, `clearai: 探索期快照 — 第 ${turn} 回合中尚未交付的写入`)
4117
+ /**
4118
+ * 措辞分流:pre-step 是**回合中途**的恢复点,turn-stopping 是**回合结束时**那一笔
4119
+ * ——后者才是"跑动在这里停下,工作区长这样",也是唯一在一次性形态里会落地的那笔。
4120
+ */
4121
+ const when = phase === 'end' ? `第 ${turn} 回合结束时尚未交付的写入` : `第 ${turn} 回合中尚未交付的写入`
4122
+ const committed = commitLedger(cwd, `clearai: 探索期快照 — ${when}`)
3669
4123
  if (committed.ok !== true) {
3670
4124
  ctx.logger?.warn?.(`clearai: 工作区快照失败 ${String(committed.reason ?? '').slice(0, 160)}`)
3671
4125
  return ''
3672
4126
  }
3673
4127
  if (committed.skipped === true) return ''
3674
4128
  // 落一条**台账事实**:系统对工作区做过什么,日志里要说得出来(与交付那笔同一条纪律)。
3675
- mutations.push({ t: 'git/snapshot', commit: committed.commit, reason: '探索期快照(尚未交付的写入)', turn })
4129
+ mutations.push({ t: 'git/snapshot', commit: committed.commit, reason: phase === 'end' ? '探索期快照(回合结束时尚未交付的写入)' : '探索期快照(尚未交付的写入)', turn })
3676
4130
  const short = committed.commit === null ? '' : `(${String(committed.commit).slice(0, 7)})`
3677
4131
  return `\n(工作区已记入账本${short}:探索期的产出从此可查、可恢复——不需要你为它声明什么。)`
3678
4132
  }
@@ -3977,7 +4431,7 @@ export function apply(ctx, config = {}) {
3977
4431
  }
3978
4432
  }
3979
4433
 
3980
- function validateForkOptions(options, decideBy) {
4434
+ function validateForkOptions(options, decideBy, cwd) {
3981
4435
  if (!Array.isArray(options) || options.length < 2 || options.length > 4) return 'fork_needs_2to4_options:世界线要 2–4 条(分叉是「选一」,不是清单)'
3982
4436
  if (decideBy === null || decideBy === undefined || typeof decideBy !== 'object') return 'decide_by_required:分叉必须登记一把尺子 {metric, direction}——没有判定契约就不能收敛'
3983
4437
  if (typeof decideBy.metric !== 'string' || decideBy.metric.trim() === '') return 'decide_by_metric_required:尺子要有指标名(如 yield_pct)'
@@ -3994,7 +4448,26 @@ export function apply(ctx, config = {}) {
3994
4448
  */
3995
4449
  const scale = decideBy.metric.split(/[==]/).slice(1).join('=').trim()
3996
4450
  if (!/[==]/.test(decideBy.metric) || scale.length < 4) {
3997
- return 'decide_by_scale_required:尺子要写成「量 = 口径」(口径 = 这个数怎么算出来、单位是什么),例:字节数 = 生成文件的字节数。只有指标名不是尺子:口径不能留给每条世界线各自去定,否则两条线各写一套公式,比出来的大小不作数'
4451
+ return 'decide_by_scale_required:尺子要写成「量 = 口径」——口径必须是**工作区里真有的一个文件**(脚本或规格),例:读数 = lab/measure.py 的输出。只有指标名不是尺子:口径留给每条世界线各自去定,两条线各写一套公式,比出来的大小不作数'
4452
+ }
4453
+ /**
4454
+ * **口径必须是可指认的引用,不是散文**。
4455
+ *
4456
+ * 「评分 = 按本路线情况评分」同样能通过格式检查,却什么也没保证。一条引用
4457
+ * (工作区里的路径)则不同:路径相等是机器可核的,文件内容在账本里有版本——
4458
+ * 「同一把尺子」从一句声明变成一个可指认的对象。至于两条世界线**有没有真的
4459
+ * 用它**去量,那是评估者重跑的事,字符串检查到这里就到头了,不冒充能验。
4460
+ */
4461
+ const pathTokens = scale.match(/[^\s,;(){}"']+\/+[^\s,;(){}"']*/g) ?? []
4462
+ const resolvable = pathTokens.filter((token) => {
4463
+ try {
4464
+ return existsSync(isAbsolute(token) ? token : resolvePath(cwd, token))
4465
+ } catch {
4466
+ return false
4467
+ }
4468
+ })
4469
+ if (pathTokens.length === 0 || resolvable.length === 0) {
4470
+ return `decide_by_scale_not_reference:口径里必须引用**工作区里真有的一个文件**(现在写的是「${scale.slice(0, 60)}」)。先在工作区写好测量脚本或口径说明,再用它登记尺子——散文口径两条世界线各读各的,比出来的大小不作数`
3998
4471
  }
3999
4472
  if (decideBy.direction !== 'max' && decideBy.direction !== 'min') return 'decide_by_direction_required:尺子要说明方向:max(越大越好)或 min(越小越好)'
4000
4473
  /** 量那一半:判据里要点名它。 */
@@ -4252,7 +4725,7 @@ export function apply(ctx, config = {}) {
4252
4725
  defineTool({
4253
4726
  name: 'ForkPlan',
4254
4727
  description:
4255
- '分叉:把一个步骤分成 2–4 条互斥的世界线,每条自己做一份工作副本、自己交付。**必须先登记一把尺子**(decide_by{metric,direction}),而 metric 要写成**「量 = 口径」**(口径 = 这个数怎么算出来、单位是什么),并且这句话要**逐字**出现在每条世界线的判据里——把同一台测量仪装到每条世界线上。口径不能留给各条世界线自己定:两条线上各写一套公式,比出来的大小不作数。收敛由算术决定,不由谁说得响。分叉是「选一」,不是并行加速。',
4728
+ '分叉:把一个步骤分成 2–4 条互斥的世界线,每条自己做一份工作副本、自己交付。**必须先登记一把尺子**(decide_by{metric,direction}),而 metric 要写成**「量 = 口径」**,口径必须是**工作区里真有的一个文件**(测量脚本或口径说明),例:读数 = lab/measure.py 的输出;先写好那个文件再开分叉。量那一半要**逐字**出现在每条世界线的判据里。散文口径两条世界线各读各的,比出来的大小不作数。收敛由算术决定,不由谁说得响。分叉是「选一」,不是并行加速。',
4256
4729
  parameters: {
4257
4730
  type: 'object',
4258
4731
  properties: {
@@ -4297,7 +4770,7 @@ export function apply(ctx, config = {}) {
4297
4770
  if (step === null) return fail('no_open_step', '这份计划没有未落定的步了。')
4298
4771
  if (typeof args.question !== 'string' || args.question.trim() === '') return fail('question_required', '分叉要说清要裁决的分歧是什么。')
4299
4772
  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)
4773
+ const problem = validateForkOptions(args.options, args.decide_by, cwd)
4301
4774
  if (problem !== null) return fail(problem.split(':')[0], problem)
4302
4775
  const forkId = uniqueId('k')
4303
4776
  const options = args.options.map((option) => ({
@@ -5645,7 +6118,12 @@ export function apply(ctx, config = {}) {
5645
6118
 
5646
6119
  function protectedRoots(sessionId) {
5647
6120
  const cwd = sessionCwd(sessionId)
5648
- return [join(cwd, 'clear', 'evidence'), join(cwd, 'clear', 'knowledge', 'facts'), join(cwd, 'clear', 'goals')]
6121
+ /**
6122
+ * 系统所有的四格:`evidence` / `facts` / `goals`,加上 `ontology`(词汇货架)。
6123
+ * 为什么词汇也在此列:货架是**渲染**,词条的权威在账本事件里——
6124
+ * 谁直接改那份 markdown,谁就制造了一份谁也不认的词汇表(面板读的是折法)。
6125
+ */
6126
+ return [join(cwd, 'clear', 'evidence'), join(cwd, 'clear', 'knowledge', 'facts'), join(cwd, 'clear', 'goals'), join(cwd, 'clear', 'ontology')]
5649
6127
  }
5650
6128
 
5651
6129
  function touchesProtected(sessionId, value) {
@@ -5782,7 +6260,7 @@ export function apply(ctx, config = {}) {
5782
6260
  // 「评估卡与事实只能由系统写」:做的人写不进证据面(执行者同样适用)
5783
6261
  const suspect = touchesProtected(sessionId, args.file_path) ?? touchesProtected(sessionId, args.path) ?? (exec.name === 'bash' ? touchesProtected(sessionId, args.command) : null)
5784
6262
  if (suspect !== null) {
5785
- return { kind: 'deny', reason: `clear/evidence、clear/knowledge/facts、clear/goals 由系统所有,做的人不能写:${suspect}。事实与评估卡只能由系统落盘。` }
6263
+ return { kind: 'deny', reason: `clear/evidence、clear/knowledge/facts、clear/goals、clear/ontology 由系统所有,做的人不能写:${suspect}。事实、评估卡与词汇货架只能由系统落盘(词汇要改就调注册/修订/废止动词)。` }
5786
6264
  }
5787
6265
  // L4 人放行:ClearAI 的设计目标未实现,这里用宿主的审批瀑布实现。
5788
6266
  // 门挂在**等级**上:主线看步骤等级,世界线看分支等级——两条路同一道门。
@@ -5853,6 +6331,62 @@ export function apply(ctx, config = {}) {
5853
6331
  }
5854
6332
 
5855
6333
  // 会话一被创建/接入就把工作区铺好(见 ensureWorkspace:要抢在原生目录快照之前)。
6334
+ /**
6335
+ * **子 run 落定由宿主告诉我们**(`subagent/end`),不再只靠"轮询子会话日志"。
6336
+ *
6337
+ * 载荷 `{runId, provider, id, local, stopReason, lastAssistantMessage?}` 由持有 `run.result`
6338
+ * 的那个 service 在**同一个 promise 落定的那一刻**发出(成功与失败都发)——与我们本来
6339
+ * 就信任的那次 `run.result` 是同一个触发点,所以权威性相同,而好处是:handle 已经不在
6340
+ * (重启、换档、早退)时结算也不会丢,失败一路也不用再靠日志里的停止原因去猜。
6341
+ *
6342
+ * 认出 id 才吃:只认我们自己派出去的那几个(别人的子 run 不是我们的事实)。
6343
+ */
6344
+ ctx.on('subagent/end', (info) => {
6345
+ try {
6346
+ const childId = String(info?.id ?? '')
6347
+ if (childId === '') return
6348
+ const settled = settleSubRun({ output: Array.isArray(info.lastAssistantMessage) ? info.lastAssistantMessage : [], stopReason: String(info.stopReason ?? 'completed') })
6349
+ for (const table of [executorRuns, scoutRuns]) {
6350
+ const entry = table.get(childId)
6351
+ // 已经落定的不覆盖:先到的那一条为准(事件与 promise 是同一个触发点)。
6352
+ if (entry !== undefined && (entry.settled === null || entry.settled === undefined)) entry.settled = settled
6353
+ }
6354
+ } catch (error) {
6355
+ ctx.logger?.warn?.(`clearai: 子 run 结算事件处理失败 ${String(error?.message ?? error).slice(0, 160)}`)
6356
+ }
6357
+ })
6358
+
6359
+
6360
+ /**
6361
+ * **回合收尾**:宿主在回合将停、且队列里没有下一步时**串行 await** 这一拍。
6362
+ *
6363
+ * 这里只做一件真正属于我们的事:把这一回合的写入记进账本(**回合边界**的那一笔,
6364
+ * 一次性形态里唯一会落地的那笔)。**不判在飞、不写裁决、不叫醒任何人**:
6365
+ * 「还在不在跑」的权威是宿主的子任务目录,「结果收没收回」是收集通道的事——
6366
+ * 收尾那一拍各有一句要说的话,但都不该由这里替它们说。
6367
+ */
6368
+ function snapshotAtTurnEnd(payload) {
6369
+ try {
6370
+ const hostService = host()
6371
+ if (hostService === undefined) return
6372
+ const sessionId = String(payload?.agent?.id ?? '')
6373
+ if (sessionId === '') return
6374
+ const turn = typeof payload?.turn === 'number' ? payload.turn : null
6375
+ // mutations 进空数组:这一拍没有工具结果可以携带变更,账本提交本身就是记录。
6376
+ snapshotWorkspace(sessionId, sessionCwd(sessionId), hostService.state(sessionId), [], turn, 'end')
6377
+ } catch (error) {
6378
+ ctx.logger?.warn?.(`clearai: 回合收尾失败 ${String(error?.message ?? error).slice(0, 160)}`)
6379
+ }
6380
+ }
6381
+
6382
+ ctx.on('agent/turn-stopping', snapshotAtTurnEnd)
6383
+
6384
+ /**
6385
+ * **回合以错误结束也要记一笔工作区**:那一拍的写入不能因为出错就丢。
6386
+ * 宿主在出错那一档**不派** `agent/turn-stopping`(它 throw 出去了),所以这里也挂一份。
6387
+ */
6388
+ ctx.on('agent/error', snapshotAtTurnEnd)
6389
+
5856
6390
  ctx.on('agent/created', (payload) => {
5857
6391
  const agent = payload?.agent
5858
6392
  const id = agent?.id ?? agent?.session?.id
@@ -5899,6 +6433,14 @@ export function apply(ctx, config = {}) {
5899
6433
  ontologyShelved.add(sessionId)
5900
6434
  if (shelf !== null) ontologyNote = `\n- 本体已就位(${CONTRIB.ontology.objects.length} 个对象 · ${CONTRIB.ontology.levels.length} 级):${join('clear', 'ontology', `${CONTRIB.ontology.id}.md`)}——交付前对照它(哪些状态合法、每一级谁来判)。`
5901
6435
  }
6436
+ /**
6437
+ * **领域词汇货架**每一拍都重铺一次(幂等:内容没变就不重写)。
6438
+ *
6439
+ * 为什么不像过程本体那样只铺一次:那一份随**发布版本**,这一份随**会话**——
6440
+ * 一个项目今天没用词汇、明天开始用,货架必须自己长出来,而不是等人记得去建。
6441
+ * 它也不进卡:货架的位置在提示词里说一次就够,每拍重复就是往上下文里灌水。
6442
+ */
6443
+ ensureDomainShelf(host(), sessionId)
5902
6444
  let brainNote = ''
5903
6445
  /** 事实货架那句话说一次就够(事实很少变)。 */
5904
6446
  let factsNote = ''
@@ -5986,14 +6528,14 @@ export function apply(ctx, config = {}) {
5986
6528
  */
5987
6529
  let auditsResolved = false
5988
6530
  try {
5989
- const audits = await sweepLostAudits(sessionId, hostService.state(sessionId))
6531
+ const audits = await sweepEndedAudits(sessionId, hostService.state(sessionId))
5990
6532
  if (audits.mutations.length > 0) {
5991
6533
  factMutations.push(...audits.mutations)
5992
6534
  auditsResolved = true
5993
- brainNote = `${brainNote}\n(有 ${audits.lost} 条独立裁决**失联**(子会话已不在跑):已如实记为 unknown——不必再等它,重新交付这一步就会派一个新的评估者。)`
6535
+ brainNote = `${brainNote}\n(有 ${audits.settled} 条此前在等的独立裁决已收口:结论从子会话日志取回,或如实记为 unknown。)`
5994
6536
  }
5995
6537
  } catch (error) {
5996
- ctx.logger?.warn?.(`clearai: 失联裁决盘点失败 ${String(error?.message ?? error).slice(0, 160)}`)
6538
+ ctx.logger?.warn?.(`clearai: 已结束裁决盘点失败 ${String(error?.message ?? error).slice(0, 160)}`)
5997
6539
  }
5998
6540
  /**
5999
6541
  * **失联的侦察**:同一拍、同一套判据(原生子代理目录 + 本进程攥着的派遣)。
@@ -6005,7 +6547,7 @@ export function apply(ctx, config = {}) {
6005
6547
  const scouts = await sweepLostScouts(sessionId, hostService.state(sessionId), settledInBatch)
6006
6548
  if (scouts.mutations.length > 0) {
6007
6549
  factMutations.push(...scouts.mutations)
6008
- brainNote = `${brainNote}\n(有 ${scouts.lost} 条侦察**失联**(子会话已不在跑):已如实记下「结论收不回来」——不必再等它,需要那份材料就重新派一次。)`
6550
+ brainNote = `${brainNote}\n(有 ${scouts.lost} 条侦察已结束但结论未能取回:已如实记下。)`
6009
6551
  }
6010
6552
  } catch (error) {
6011
6553
  ctx.logger?.warn?.(`clearai: 失联侦察盘点失败 ${String(error?.message ?? error).slice(0, 160)}`)