@bolloon/bolloon-agent 0.4.33 → 0.5.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.
Files changed (214) hide show
  1. package/dist/agents/contacts/chain.js +27 -7
  2. package/dist/agents/execution-supervisor.js +580 -40
  3. package/dist/agents/external-events.js +35 -6
  4. package/dist/agents/gateway-group.js +100 -12
  5. package/dist/agents/goal-criteria.js +10 -3
  6. package/dist/agents/goal-flywheel/block-executor.js +346 -0
  7. package/dist/agents/goal-flywheel/continuation-decision.js +592 -0
  8. package/dist/agents/goal-flywheel/goal-change.js +754 -0
  9. package/dist/agents/goal-flywheel/index.js +26 -0
  10. package/dist/agents/goal-flywheel/memory-layers.js +227 -0
  11. package/dist/agents/goal-flywheel/run-closure.js +809 -0
  12. package/dist/agents/goal-flywheel/skill-candidate.js +628 -0
  13. package/dist/agents/goal-flywheel/types.js +329 -0
  14. package/dist/agents/goal-flywheel/wiring/change.js +288 -0
  15. package/dist/agents/goal-flywheel/wiring/closure.js +697 -0
  16. package/dist/agents/goal-flywheel/wiring/continuation.js +432 -0
  17. package/dist/agents/goal-flywheel/wiring/contract.js +270 -0
  18. package/dist/agents/goal-flywheel/wiring/index.js +26 -0
  19. package/dist/agents/goal-flywheel/wiring/monitor.js +254 -0
  20. package/dist/agents/goal-flywheel/wiring/seams.js +592 -0
  21. package/dist/agents/goal-flywheel/work-contract.js +478 -0
  22. package/dist/agents/goal-flywheel/work-monitor.js +488 -0
  23. package/dist/agents/goal-flywheel-wiring.js +1870 -0
  24. package/dist/agents/goal-state-reducer.js +406 -0
  25. package/dist/agents/goal-store.js +35 -1
  26. package/dist/agents/mobile-flywheel-view.js +144 -0
  27. package/dist/agents/mobile-task-actions-verify.js +74 -0
  28. package/dist/agents/mobile-task-actions.js +178 -0
  29. package/dist/agents/mobile-task-views.js +199 -0
  30. package/dist/agents/model-policy.js +651 -0
  31. package/dist/agents/network-pulse.js +304 -18
  32. package/dist/agents/pi-sdk-tools.js +84 -33
  33. package/dist/agents/pi-sdk.js +157 -42
  34. package/dist/agents/run-store.js +71 -5
  35. package/dist/agents/skill-readiness.js +52 -10
  36. package/dist/agents/subagent-manager.js +209 -3
  37. package/dist/agents/task/task-runner.js +17 -4
  38. package/dist/agents/task-group.js +8 -51
  39. package/dist/agents/task-public-text.js +89 -0
  40. package/dist/agents/workflow-pivot-loop.js +19 -12
  41. package/dist/cli/clipboard.js +65 -0
  42. package/dist/cli/commands/index.js +1 -0
  43. package/dist/cli/commands/tasks.js +350 -2
  44. package/dist/cli/identity-command.js +177 -0
  45. package/dist/cli/ink-app.js +145 -17
  46. package/dist/cli/log-gate.js +386 -0
  47. package/dist/cli/mention-data.js +2 -0
  48. package/dist/cli/model-selector.js +538 -0
  49. package/dist/cli/reply-hygiene.js +54 -0
  50. package/dist/cli/setup-wizard.js +757 -69
  51. package/dist/cli/startup-notice.js +160 -0
  52. package/dist/cli/stores.js +22 -1
  53. package/dist/cli/theme.js +49 -0
  54. package/dist/cli/tui-select.js +970 -0
  55. package/dist/cli/update-commands.js +150 -24
  56. package/dist/cli-entry.js +137 -20
  57. package/dist/electron-build/agents/contacts/chain.js +26 -6
  58. package/dist/electron-build/agents/contacts/chain.js.map +1 -1
  59. package/dist/electron-build/agents/execution-supervisor.js +578 -38
  60. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  61. package/dist/electron-build/agents/external-events.js +34 -5
  62. package/dist/electron-build/agents/external-events.js.map +1 -1
  63. package/dist/electron-build/agents/goal-criteria.js +10 -3
  64. package/dist/electron-build/agents/goal-criteria.js.map +1 -1
  65. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js +598 -0
  66. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js.map +1 -0
  67. package/dist/electron-build/agents/goal-flywheel/goal-change.js +773 -0
  68. package/dist/electron-build/agents/goal-flywheel/goal-change.js.map +1 -0
  69. package/dist/electron-build/agents/goal-flywheel/memory-layers.js +269 -0
  70. package/dist/electron-build/agents/goal-flywheel/memory-layers.js.map +1 -0
  71. package/dist/electron-build/agents/goal-flywheel/run-closure.js +818 -0
  72. package/dist/electron-build/agents/goal-flywheel/run-closure.js.map +1 -0
  73. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js +642 -0
  74. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js.map +1 -0
  75. package/dist/electron-build/agents/goal-flywheel/types.js +333 -0
  76. package/dist/electron-build/agents/goal-flywheel/types.js.map +1 -0
  77. package/dist/electron-build/agents/goal-flywheel/wiring/change.js +294 -0
  78. package/dist/electron-build/agents/goal-flywheel/wiring/change.js.map +1 -0
  79. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js +742 -0
  80. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js.map +1 -0
  81. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js +444 -0
  82. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js.map +1 -0
  83. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js +279 -0
  84. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js.map +1 -0
  85. package/dist/electron-build/agents/goal-flywheel/wiring/index.js +111 -0
  86. package/dist/electron-build/agents/goal-flywheel/wiring/index.js.map +1 -0
  87. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js +264 -0
  88. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js.map +1 -0
  89. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js +612 -0
  90. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js.map +1 -0
  91. package/dist/electron-build/agents/goal-flywheel/work-contract.js +488 -0
  92. package/dist/electron-build/agents/goal-flywheel/work-contract.js.map +1 -0
  93. package/dist/electron-build/agents/goal-flywheel/work-monitor.js +501 -0
  94. package/dist/electron-build/agents/goal-flywheel/work-monitor.js.map +1 -0
  95. package/dist/electron-build/agents/goal-flywheel-wiring.js +1962 -0
  96. package/dist/electron-build/agents/goal-flywheel-wiring.js.map +1 -0
  97. package/dist/electron-build/agents/goal-state-reducer.js +414 -0
  98. package/dist/electron-build/agents/goal-state-reducer.js.map +1 -0
  99. package/dist/electron-build/agents/goal-store.js +35 -1
  100. package/dist/electron-build/agents/goal-store.js.map +1 -1
  101. package/dist/electron-build/agents/model-policy.js +706 -0
  102. package/dist/electron-build/agents/model-policy.js.map +1 -0
  103. package/dist/electron-build/agents/network-pulse.js +307 -19
  104. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  105. package/dist/electron-build/agents/pi-sdk-tools.js +84 -33
  106. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  107. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  108. package/dist/electron-build/agents/pi-sdk.js +156 -41
  109. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  110. package/dist/electron-build/agents/run-store.js +75 -6
  111. package/dist/electron-build/agents/run-store.js.map +1 -1
  112. package/dist/electron-build/agents/skill-readiness.js +52 -9
  113. package/dist/electron-build/agents/skill-readiness.js.map +1 -1
  114. package/dist/electron-build/agents/task/task-runner.js +16 -3
  115. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  116. package/dist/electron-build/agents/workflow-pivot-loop.js +19 -12
  117. package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
  118. package/dist/electron-build/llm/config-store.js +146 -6
  119. package/dist/electron-build/llm/config-store.js.map +1 -1
  120. package/dist/electron-build/llm/connection-probe.js +764 -0
  121. package/dist/electron-build/llm/connection-probe.js.map +1 -0
  122. package/dist/electron-build/llm/custom-provider-store.js +272 -0
  123. package/dist/electron-build/llm/custom-provider-store.js.map +1 -0
  124. package/dist/electron-build/llm/data/provider-catalog-baked.js +23 -0
  125. package/dist/electron-build/llm/data/provider-catalog-baked.js.map +1 -0
  126. package/dist/electron-build/llm/model-catalog.js +778 -0
  127. package/dist/electron-build/llm/model-catalog.js.map +1 -0
  128. package/dist/electron-build/llm/model-discovery.js +1202 -0
  129. package/dist/electron-build/llm/model-discovery.js.map +1 -0
  130. package/dist/electron-build/llm/model-selection.js +1315 -0
  131. package/dist/electron-build/llm/model-selection.js.map +1 -0
  132. package/dist/electron-build/llm/pi-ai.js +70 -11
  133. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  134. package/dist/electron-build/llm/provider-catalog.js +969 -0
  135. package/dist/electron-build/llm/provider-catalog.js.map +1 -0
  136. package/dist/electron-build/llm/provider-registry.js +950 -0
  137. package/dist/electron-build/llm/provider-registry.js.map +1 -0
  138. package/dist/electron-build/llm/tool-name.js +266 -0
  139. package/dist/electron-build/llm/tool-name.js.map +1 -0
  140. package/dist/electron-build/orbitdb/cid-database.js +83 -17
  141. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  142. package/dist/electron-build/orbitdb/ipfs-node.js +31 -2
  143. package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
  144. package/dist/electron-build/utils/dual-source-facts.js +236 -0
  145. package/dist/electron-build/utils/dual-source-facts.js.map +1 -0
  146. package/dist/electron-build/utils/dual-source.js +347 -0
  147. package/dist/electron-build/utils/dual-source.js.map +1 -0
  148. package/dist/electron-build/utils/update-manager.js +328 -104
  149. package/dist/electron-build/utils/update-manager.js.map +1 -1
  150. package/dist/electron-build/utils/update-state.js +9 -11
  151. package/dist/electron-build/utils/update-state.js.map +1 -1
  152. package/dist/electron-build/utils/version-identity.js +193 -0
  153. package/dist/electron-build/utils/version-identity.js.map +1 -0
  154. package/dist/electron-build/utils/version-info.js +57 -31
  155. package/dist/electron-build/utils/version-info.js.map +1 -1
  156. package/dist/index.js +385 -108
  157. package/dist/ios/bolloon-web.json +10 -0
  158. package/dist/ios/chain-explorer.js +617 -0
  159. package/dist/ios/explorer.html +163 -0
  160. package/dist/ios/index.html +96 -0
  161. package/dist/ios/mobile-agent.js +2 -2
  162. package/dist/ios/mobile-contacts.js +362 -0
  163. package/dist/ios/mobile-core.js +3102 -310
  164. package/dist/ios/mobile-privacy.js +185 -0
  165. package/dist/ios/mobile-tasks.js +339 -0
  166. package/dist/ios/mobile-update.js +1442 -0
  167. package/dist/ios/mobile.css +19 -0
  168. package/dist/ios/mobile.html +96 -0
  169. package/dist/ios/mobile.js +1013 -41
  170. package/dist/ios/routes-chain-index.js +108 -0
  171. package/dist/ios/routes-contacts.js +420 -0
  172. package/dist/ios/routes-llm-config.js +344 -52
  173. package/dist/ios/routes-mobile-tasks.js +353 -0
  174. package/dist/ios/routes-onchain-trade.js +99 -0
  175. package/dist/ios/routes-x402-info.js +1 -0
  176. package/dist/ios/server.js +921 -0
  177. package/dist/ios/sw.js +1 -1
  178. package/dist/ios/task-frame-server.js +211 -0
  179. package/dist/llm/config-store.js +146 -6
  180. package/dist/llm/connection-probe.js +744 -0
  181. package/dist/llm/custom-provider-store.js +224 -0
  182. package/dist/llm/data/provider-catalog-baked.js +19 -0
  183. package/dist/llm/model-catalog.js +717 -0
  184. package/dist/llm/model-discovery.js +1137 -0
  185. package/dist/llm/model-selection.js +1241 -0
  186. package/dist/llm/pi-ai.js +70 -11
  187. package/dist/llm/provider-catalog.js +898 -0
  188. package/dist/llm/provider-registry.js +917 -0
  189. package/dist/llm/tool-name.js +220 -0
  190. package/dist/orbitdb/cid-database.js +81 -16
  191. package/dist/orbitdb/ipfs-node.js +31 -2
  192. package/dist/setup/onboard.js +34 -8
  193. package/dist/utils/dual-source-facts.js +223 -0
  194. package/dist/utils/dual-source.js +297 -0
  195. package/dist/utils/update-cli.js +16 -21
  196. package/dist/utils/update-health.js +35 -5
  197. package/dist/utils/update-manager.js +325 -96
  198. package/dist/utils/update-state.js +13 -10
  199. package/dist/utils/version-identity.js +176 -0
  200. package/dist/utils/version-info.js +27 -22
  201. package/dist/web/bolloon-web.json +10 -0
  202. package/dist/web/mobile-contacts.js +44 -11
  203. package/dist/web/mobile-core.js +2901 -580
  204. package/dist/web/mobile-tasks.js +339 -0
  205. package/dist/web/mobile-update.js +1442 -0
  206. package/dist/web/mobile.css +19 -0
  207. package/dist/web/mobile.html +64 -0
  208. package/dist/web/mobile.js +838 -35
  209. package/dist/web/routes-llm-config.js +344 -52
  210. package/dist/web/routes-mobile-tasks.js +353 -0
  211. package/dist/web/server.js +163 -4
  212. package/dist/web/sw.js +1 -1
  213. package/package.json +3 -1
  214. package/scripts/build-web.ts +36 -0
@@ -0,0 +1,697 @@
1
+ /**
2
+ * wiring/closure.ts — 接缝 ②「这一条 Run 收尾了吗, 收尾产物落在哪里?」 (**M2 独占**)
3
+ *
4
+ * 归属: `seams.ts` 的 `SEAM_ROSTER` 里 `id: 'closure'`, `stage: 'M2'`。
5
+ * M2 只准改本文件 + 本接缝声明的 `wiringPoints`
6
+ * (`goal-flywheel/{run-closure,memory-layers,skill-candidate}.ts`)。
7
+ *
8
+ * ## 这条接缝钉住的两条冻结规则
9
+ *
10
+ * **规则 ③ 只有 closeRun 能关闭 Run**: 全仓只有 `goal-flywheel/run-closure.ts` 定义
11
+ * `closeRun`, 只有 M0 接线层 (`goal-flywheel-wiring.ts`) 与**本文件**能碰它。
12
+ *
13
+ * **规则 ④ 所有终止路径必须经过 closeRun**: 成功 / 失败 / 中断 / 超时 / 人工暂停 /
14
+ * 崩溃恢复 / 权限·支付·工具失败 / 子 Agent 被阻塞 —— 八类终止**都**走收尾漏斗。
15
+ * 本文件把这句话做成**可执行**的两半:
16
+ * · `CLOSURE_TERMINAL_PATHS` + `terminalRegistryCoverage()`: 把 run-store 的每一个**终止状态**
17
+ * 映射到名册上的终止路径, 并检查 seams.ts 名册上的每条终止路径都被认领 —— 留下一条没人管的
18
+ * 路径, 门立刻判红 (而不是靠"我记得改过了");
19
+ * · `auditTerminalCoverage()`: 给一批**真的终止过**的 Run, 逐条查它有没有收尾记录 ——
20
+ * 这才是"必经阶段"的可核验形式 (终止了但没收尾 = 缺口)。
21
+ *
22
+ * ## 收尾产物的三项**真读盘**审计 (接缝问的"落在哪里", 不看返回值只看盘)
23
+ *
24
+ * ① 用户汇报 / closure 决策记录两个文件**真的存在**;
25
+ * ② P1b 的**分层 Memory 目录**里真有属于这条 Run 的记录, 且**落层正确** (layer 与目录一致);
26
+ * ③ P1b 的**候选文件**只带 `snapshotScope='next_run_only'` + `appliesToRunningRun=false`
27
+ * (试用范围; 落盘时丢了这两个字段 = 正在执行的 snapshot 可能被覆盖)。
28
+ * ②③ 是**条件检查**: 盘上没有这类产物时不判红 (一条 Run 可以如实"没有可提取的候选"),
29
+ * 但只要它落盘了, 就必须符合 P1b 的分层/试用不变量 —— 于是它既能抓到真回归, 又不会因
30
+ * "某条 Run 本来就是空的"刷假红 (门一旦有假阳性就会被白名单掉, 真信号也没了)。
31
+ *
32
+ * ## 收尾产物不完整 = `artifactsComplete:false` (**不是** `ok:false`)
33
+ *
34
+ * 任何一条终止路径都必须交出完整产物: 9 步 · 决策 · 下一步 · 与决策自洽的 continuation ·
35
+ * continuation 的时间戳属于**这一轮** · 用户汇报 (六类可见态 + 只暴露允许字段) · 两个产物文件。
36
+ * 缺任何一项 → `artifactsComplete:false` + `receipt.audit.missing` 点名 (结构化原因, 不是 boolean)。
37
+ * 收尾照做, 但**不许**当成"收好了"。
38
+ *
39
+ * ★ 为什么不用 `ok:false` 表达缺口 (2026-09-26 实测教训): 接缝协议里 `ok:false` 就是**被拒**
40
+ * (`seams.ts` 的 `isRefusal`: `ok===false && typeof reason === 'string'`)。收尾真做了却报 ok:false,
41
+ * 监督者 (execution-supervisor) 会走"收尾被拒"分支 —— 这条 Run 从 `report.closures` 里消失,
42
+ * 于是"产物有缺口"被伪装成"收尾被拒", 两件事同时失真 (一条真收尾的 Run 看起来根本没收尾)。
43
+ * 所以 `ok` 只表达"这次调用做成了吗", 缺口走 `artifactsComplete`。
44
+ *
45
+ * ## 为什么是 `Once` (幂等)
46
+ *
47
+ * 真实链路上同一条 Run 会被两处看见:
48
+ * · Runner (pi-sdk / CLI) 自己在 Run 结束时就收尾 (它知道最细的上下文);
49
+ * · Supervisor 在 Runner 返回后又收一遍 (它负责 Goal 层决策)。
50
+ * 若两边都真收, 就会写出两份 Memory / 两份候选 / 两条 closure 决策记录 —— 又是两套事实。
51
+ * 所以收尾入口是 `closeRunOnce`: **同一条 runId 只允许收一次**, 第二次返回
52
+ * `alreadyClosed: true` 且**不写任何东西**。幂等判据用的是既有的 closure 决策记录
53
+ * (`.bolloon/goal-decisions/<goalId>--<runId>--closure.json`) —— **不新增存储**。
54
+ *
55
+ * ## M2 之后仍缺的一环 (如实说明, 需要 M0 接线层的钩子)
56
+ *
57
+ * P1b 的 Skill 升级通道 (`skill-candidate.ts` 的 `openSkillTrial` / `settleSkillTrial`) 是**纯函数**:
58
+ * · **准入** (①–⑤) 要在"候选刚被提取出来"时调用 —— 那一处是 M0 接线层的 `closeGoalRun` /
59
+ * `writeSkillCandidate` (M0 冻结面, 本线不能改);
60
+ * · **提升** (⑥) 要在**下一条 Run 成功复用**的那个成功点上调用 —— 那一处目前没有钩子。
61
+ * 本文件把接缝该做的部分做完 (产物审计里已经会检查候选文件的试用范围), 通道本身与其判据在
62
+ * `skill-candidate.ts` 落地并自带测试; 接线请见本线提交说明里给主线的钩子清单。
63
+ */
64
+ import * as fs from 'node:fs/promises';
65
+ import * as path from 'node:path';
66
+ import { CONTINUATION_DECISIONS, MEMORY_LAYERS, MUST_NOT_EXPOSE_FIELDS, USER_REPORT_FIELDS, USER_VISIBLE_STATES, } from '../types.js';
67
+ import { MEMORY_LAYERS_ROOT } from '../memory-layers.js';
68
+ import { CLOSURE_STEP_ORDER, GOAL_STATE_FOR_DECISION } from '../run-closure.js';
69
+ import { TERMINAL_PATHS, WIRING_CALLERS, refuse } from './seams.js';
70
+ export const CLOSURE_SEAM_ID = 'closure';
71
+ /**
72
+ * Skill 候选落盘根 (与 M0 接线层的 `SKILL_CANDIDATES_ROOT` 必须**逐字相同**)。
73
+ *
74
+ * 为什么不 import M0: 那会造成 `closure.ts → goal-flywheel-wiring.ts → wiring/index.ts → closure.ts`
75
+ * 的循环 (M0 是接缝的宿主)。所以这里声明一份, 由本线的测试断言它与 M0 的常量相等 ——
76
+ * 一旦漂移, 门判红 (不是靠人记得改两处)。
77
+ */
78
+ export const SKILL_CANDIDATES_ROOT = '.bolloon/skill-candidates';
79
+ // ============================================================================
80
+ // §1. 终止路径名册 (规则 ④ 的第一半: 每条终止路径都有归属)
81
+ // ============================================================================
82
+ export const CLOSURE_TERMINAL_KINDS = [
83
+ 'success',
84
+ 'failure',
85
+ 'interrupted',
86
+ 'crash_recovery',
87
+ 'stall',
88
+ 'manual_stop',
89
+ 'timeout',
90
+ 'permission_or_payment_or_tool_failure',
91
+ 'child_agent_blocked',
92
+ 'escalated_to_human',
93
+ 'awaiting_external',
94
+ ];
95
+ /**
96
+ * run-store 里**会终结/收束一条 Run** 的状态 (相对地 `queued` / `running` 是"还在飞", 见
97
+ * `RUN_IN_FLIGHT_STATUSES`)。
98
+ *
99
+ * ★ 2026-09-26 实测修正: `recovering` **必须**在里面 —— 中断恢复路径上, Runner 先调
100
+ * `prepareResume` (Run → recovering), 收尾看到的 Run 状态就是 `recovering` (实测: 监督者的
101
+ * resume 分支就是这样收尾的)。漏掉它, 这条路径的收尾就会被判"没登记在任何终止路径上"。
102
+ */
103
+ export const RUN_TERMINAL_STATUSES = [
104
+ 'done',
105
+ 'failed',
106
+ 'aborted',
107
+ 'interrupted',
108
+ 'recovering',
109
+ 'stalled',
110
+ 'paused',
111
+ 'needs_human',
112
+ 'awaiting_external',
113
+ ];
114
+ /** 还没结束的状态 (在这个状态下收尾 = 收尾一个还在飞的 Run, 记 note 而不是判缺口) */
115
+ export const RUN_IN_FLIGHT_STATUSES = ['queued', 'running'];
116
+ /**
117
+ * 八类终止 (§5 的清单是**最低要求**) + 三类 run-store 的其余终止状态。
118
+ *
119
+ * 为什么多出三类: `needs_human` (无进展熔断/需要人决定) 与 `awaiting_external` (挂起等外部)
120
+ * 同样是"这条 Run 结束了", 它们**也必须**收尾 —— 名册漏掉它们, 那两条路径就会静默绕过唯一责任链。
121
+ * 另外三条 (`timeout` / `permission_or_payment_or_tool_failure` / `child_agent_blocked`) 在
122
+ * run-store 里只表现为 `failed` / `aborted` / `stalled` / `needs_human`: 它们是**由调用方申报**的
123
+ * 具体路径 (同一个出口, 但收尾时要如实写明是哪一类), 所以与主归属共用状态集。
124
+ */
125
+ export const CLOSURE_TERMINAL_PATHS = [
126
+ {
127
+ kind: 'success',
128
+ label: '成功 (Run 正常完成)',
129
+ seamLabels: ['成功 (Run 正常完成)'],
130
+ runStatuses: ['done'],
131
+ primary: true,
132
+ why: 'Runner 正常跑完 → 收尾决定了"目标是否完成", 不能只在成功时顺手收个尾',
133
+ },
134
+ {
135
+ kind: 'failure',
136
+ label: '失败 (Runner 内 finishRun)',
137
+ seamLabels: ['失败 / 中断恢复 (Runner 内 finishRun)'],
138
+ runStatuses: ['failed'],
139
+ primary: true,
140
+ why: '失败也是事实: 失败 Run 同样要写 Memory/候选并给出下一步',
141
+ },
142
+ {
143
+ kind: 'interrupted',
144
+ label: '中断恢复 (Runner 内 finishRun)',
145
+ seamLabels: ['失败 / 中断恢复 (Runner 内 finishRun)', '崩溃恢复 (孤儿对账 → interrupted) / 失速 (stalled)'],
146
+ runStatuses: ['interrupted', 'recovering'],
147
+ primary: true,
148
+ why: '中断的 Run 不许"没收尾就没了": 恢复决策与收尾事实要落在同一份记录上; 恢复路径上收尾时 Run 正处于 recovering (Runner 已 prepareResume)',
149
+ },
150
+ {
151
+ kind: 'crash_recovery',
152
+ label: '崩溃恢复 (孤儿对账)',
153
+ seamLabels: ['崩溃恢复 (孤儿对账 → interrupted) / 失速 (stalled)'],
154
+ runStatuses: ['interrupted', 'recovering'],
155
+ why: '进程死掉后由 run-store 对账判 interrupted, 再经终止钩子回到唯一责任链',
156
+ },
157
+ {
158
+ kind: 'stall',
159
+ label: '失速 (stalled)',
160
+ seamLabels: ['崩溃恢复 (孤儿对账 → interrupted) / 失速 (stalled)'],
161
+ runStatuses: ['stalled'],
162
+ primary: true,
163
+ why: '巡检判定无进展 → stalled 同样要收尾 (否则"没有进展"永远不产生教训)',
164
+ },
165
+ {
166
+ kind: 'manual_stop',
167
+ label: '人工暂停 / 中止 (外部控制面)',
168
+ seamLabels: ['人工暂停 / 中止 (外部控制面)'],
169
+ runStatuses: ['paused', 'aborted'],
170
+ primary: true,
171
+ why: '人叫停是最强语义: 收尾必须如实记"是谁停的、还剩什么", 不许假装完成',
172
+ },
173
+ {
174
+ kind: 'timeout',
175
+ label: '超时 / 预算耗尽 (收尾即放弃)',
176
+ seamLabels: ['超时 / 预算耗尽 (收尾即放弃)'],
177
+ runStatuses: ['failed', 'aborted'],
178
+ why: '与失败/中止共用出口; 由调用方申报具体是超时还是预算耗尽 (收尾要如实写明)',
179
+ },
180
+ {
181
+ kind: 'permission_or_payment_or_tool_failure',
182
+ label: '权限 / 支付 / 工具失败',
183
+ seamLabels: ['失败 / 中断恢复 (Runner 内 finishRun)'],
184
+ runStatuses: ['failed', 'aborted', 'interrupted'],
185
+ why: '与失败共用 finishRun 出口; 由调用方申报是哪一类 (这三类失败最常被"重试掉", 必须留事实)',
186
+ },
187
+ {
188
+ kind: 'child_agent_blocked',
189
+ label: '子 Agent 被阻塞 (升级 / 接管)',
190
+ seamLabels: ['子 Agent 被阻塞 (升级 / 接管)'],
191
+ runStatuses: ['stalled', 'needs_human'],
192
+ why: '子被阻塞会终结父的一轮 Run (stalled/needs_human) → 同样要过收尾',
193
+ },
194
+ {
195
+ kind: 'escalated_to_human',
196
+ label: '升级给人 (needs_human)',
197
+ seamLabels: ['失败 / 中断恢复 (Runner 内 finishRun)'],
198
+ runStatuses: ['needs_human'],
199
+ primary: true,
200
+ why: '无进展熔断 / 需要人决定 → Run 以 needs_human 结束, 收尾要写清"等人做什么"',
201
+ },
202
+ {
203
+ kind: 'awaiting_external',
204
+ label: '挂起等外部 (awaiting_external)',
205
+ seamLabels: ['失败 / 中断恢复 (Runner 内 finishRun)'],
206
+ runStatuses: ['awaiting_external'],
207
+ primary: true,
208
+ why: '等外部回话也是一次终止 (由事件或 wakeAt 唤醒) —— 收尾要写下唤醒条件',
209
+ },
210
+ ];
211
+ /** 终止状态 → **主**终止路径 (拿不到 = 这个状态没有归属, 门必须跟着补) */
212
+ export function closureTerminalKindFor(status) {
213
+ const s = String(status ?? '');
214
+ if (!s)
215
+ return null;
216
+ const hit = CLOSURE_TERMINAL_PATHS.find((p) => p.primary === true && p.runStatuses.includes(s));
217
+ return hit ? hit.kind : null;
218
+ }
219
+ /** 申报的终止路径是否接受这个 Run 状态 (truthfulness: 不许把失败路径报成成功路径) */
220
+ export function terminalKindAccepts(kind, status) {
221
+ const s = String(status ?? '');
222
+ const path0 = CLOSURE_TERMINAL_PATHS.find((p) => p.kind === kind);
223
+ if (path0) {
224
+ if (path0.runStatuses.includes(s))
225
+ return { ok: true, reason: `路径 ${kind} 接受状态 ${s}` };
226
+ return {
227
+ ok: false,
228
+ reason: `终止路径申报与 Run 事实不符: 申报 ${kind}, 但 Run 状态是 ${s || '(未知)'} (该路径只接受: ${path0.runStatuses.join(' / ')})`,
229
+ };
230
+ }
231
+ return { ok: false, reason: `终止路径 ${kind} 不在名册上 (CLOSURE_TERMINAL_KINDS)` };
232
+ }
233
+ /**
234
+ * 名册自检 (纯函数): 终止状态 ↔ 终止路径**互相覆盖**。
235
+ *
236
+ * 阴性对照: 把 `escalated_to_human` 从名册里删掉 → `unmappedStatuses` 立刻出现 `needs_human`;
237
+ * 把 seams.ts 名册里某条路径改名 → `unknownSeamLabels` + `unclaimedSeamLabels` 同时出现。
238
+ */
239
+ export function terminalRegistryCoverage(registry = CLOSURE_TERMINAL_PATHS, seamPaths = TERMINAL_PATHS) {
240
+ const seamLabels = seamPaths.map((p) => String(p.path));
241
+ const claimed = new Set();
242
+ for (const p of registry)
243
+ for (const l of p.seamLabels)
244
+ claimed.add(l);
245
+ return {
246
+ unmappedStatuses: RUN_TERMINAL_STATUSES.filter((s) => !registry.some((p) => p.primary === true && p.runStatuses.includes(s))),
247
+ unclaimedSeamLabels: seamLabels.filter((l) => !claimed.has(l)),
248
+ unknownSeamLabels: [...claimed].filter((l) => !seamLabels.includes(l)),
249
+ kindCount: registry.length,
250
+ seamPathCount: seamLabels.length,
251
+ };
252
+ }
253
+ // ============================================================================
254
+ // §2. 收尾产物的必备面 (规则 ④ 的第二半: 收尾必须**做完**且**落在盘上**)
255
+ // ============================================================================
256
+ export const CLOSURE_ARTIFACT_KINDS = [
257
+ 'steps',
258
+ 'decision',
259
+ 'next_action',
260
+ 'continuation_state',
261
+ 'continuation_time',
262
+ 'user_report',
263
+ 'terminal_kind',
264
+ 'artifact_paths',
265
+ 'artifact_files',
266
+ 'memory_layers',
267
+ 'candidate_trial_scope',
268
+ ];
269
+ /**
270
+ * 从产物路径反推 `<home>`: `<home>/.bolloon/<root>/<file>` → 去掉最后 3 段。
271
+ * 路径不是这个布局 (或不是绝对路径) → null (接缝**不猜**, 直接如实说"扫不了")。
272
+ */
273
+ export function homeFromArtifactPath(artifactPath) {
274
+ const p = typeof artifactPath === 'string' ? artifactPath : '';
275
+ if (!p || !path.isAbsolute(p))
276
+ return null;
277
+ let cur = p;
278
+ for (let i = 0; i < 3; i++) {
279
+ const parent = path.dirname(cur);
280
+ if (!parent || parent === cur)
281
+ return null;
282
+ cur = parent;
283
+ }
284
+ // 倒数第二段必须是 `.bolloon` (布局判据: 否则它只是"碰巧有三层"的路径)
285
+ if (path.basename(path.dirname(path.dirname(p))) !== '.bolloon')
286
+ return null;
287
+ return cur;
288
+ }
289
+ async function statIsFile(p) {
290
+ if (!p)
291
+ return false;
292
+ try {
293
+ return (await fs.stat(p)).isFile();
294
+ }
295
+ catch {
296
+ return false;
297
+ }
298
+ }
299
+ async function readJsonObjects(dir) {
300
+ let names = [];
301
+ try {
302
+ names = await fs.readdir(dir);
303
+ }
304
+ catch {
305
+ return [];
306
+ }
307
+ const out = [];
308
+ for (const f of names.filter((n) => n.endsWith('.json')).sort()) {
309
+ let value = null;
310
+ try {
311
+ value = JSON.parse(await fs.readFile(path.join(dir, f), 'utf8'));
312
+ }
313
+ catch {
314
+ value = null;
315
+ }
316
+ out.push({ file: f, value });
317
+ }
318
+ return out;
319
+ }
320
+ /**
321
+ * 真读盘: 这条 Run 的收尾产物到底在不在, 形态对不对。
322
+ *
323
+ * 读盘失败也**不抛**: 记为 `scanned:false` + note (拿不到事实就说拿不到, 不假装收好也不假装没收)。
324
+ */
325
+ export async function probeClosureArtifacts(view) {
326
+ const notes = [];
327
+ const base = {
328
+ scanned: false,
329
+ home: null,
330
+ reportFile: false,
331
+ decisionRecordFile: false,
332
+ memoryFiles: 0,
333
+ layerMismatches: [],
334
+ candidateFiles: 0,
335
+ trialScopeViolations: [],
336
+ notes,
337
+ };
338
+ const home = homeFromArtifactPath(view?.reportPath) ?? homeFromArtifactPath(view?.decisionRecordPath);
339
+ if (!home) {
340
+ notes.push('artifact_layout_unknown: 产物路径不是 <home>/.bolloon/<root>/<file> 的形状 (或不是绝对路径) → 不猜盘上位置, 本项审计记为未扫');
341
+ return base;
342
+ }
343
+ try {
344
+ const reportFile = await statIsFile(view.reportPath);
345
+ const decisionRecordFile = await statIsFile(view.decisionRecordPath);
346
+ const runId = String(view.runId ?? '');
347
+ const memoryFiles = [];
348
+ const layerMismatches = [];
349
+ for (const layer of MEMORY_LAYERS) {
350
+ for (const { file, value } of await readJsonObjects(path.join(home, MEMORY_LAYERS_ROOT, layer))) {
351
+ if (!value || String(value.runId ?? '') !== runId)
352
+ continue;
353
+ memoryFiles.push(`${layer}/${file}`);
354
+ if (String(value.layer ?? '') !== layer) {
355
+ layerMismatches.push(`${layer}/${file}: 记录里的 layer=${String(value.layer)} (落错层)`);
356
+ }
357
+ }
358
+ }
359
+ let candidateFiles = 0;
360
+ const trialScopeViolations = [];
361
+ for (const { file, value } of await readJsonObjects(path.join(home, SKILL_CANDIDATES_ROOT))) {
362
+ if (!value || String(value.proposedByRunId ?? '') !== runId)
363
+ continue;
364
+ candidateFiles += 1;
365
+ // ★ 候选本身**没有**范围字段 (`SkillImprovementCandidate` 里没有 snapshotScope —— 范围是
366
+ // `openSkillTrial` 在开试用时写进 `SkillTrialRecord`/`SkillPromotionRecord` 的)。
367
+ // 所以这里只查"**若**它声明了范围, 声明得安全吗": 声明了 all_runs / 作用于运行中的 Run
368
+ // = 会覆盖正在执行的 snapshot → 判红。字段缺席**不是**缺口 (那是"还没开试用", 由通道管),
369
+ // 只记一条 note —— 否则这条检查会对每条正常 Run 刷假红。
370
+ const declaresScope = Object.prototype.hasOwnProperty.call(value, 'snapshotScope')
371
+ || Object.prototype.hasOwnProperty.call(value, 'appliesToRunningRun');
372
+ if (declaresScope) {
373
+ const scope = String(value.snapshotScope ?? '');
374
+ const applies = value.appliesToRunningRun;
375
+ if (scope !== 'next_run_only' || applies !== false) {
376
+ trialScopeViolations.push(`${file}: snapshotScope=${scope || '(空)'}, appliesToRunningRun=${String(applies)} (试用必须 next_run_only 且不作用于运行中的 Run)`);
377
+ }
378
+ }
379
+ }
380
+ return {
381
+ scanned: true,
382
+ home,
383
+ reportFile,
384
+ decisionRecordFile,
385
+ memoryFiles: memoryFiles.length,
386
+ layerMismatches,
387
+ candidateFiles,
388
+ trialScopeViolations,
389
+ notes,
390
+ };
391
+ }
392
+ catch (e) {
393
+ const msg = e instanceof Error ? e.message : String(e);
394
+ notes.push(`artifact_scan_failed: 读盘失败 (${msg}) → 本项审计记为未扫, 不假装收好`);
395
+ return base;
396
+ }
397
+ }
398
+ const FORBIDDEN_REPORT_WORDS = MUST_NOT_EXPOSE_FIELDS.map((f) => String(f));
399
+ function sameStringSet(a, b) {
400
+ if (a.length !== b.length)
401
+ return false;
402
+ const sa = [...a].sort();
403
+ const sb = [...b].sort();
404
+ return sa.every((x, i) => x === sb[i]);
405
+ }
406
+ /**
407
+ * 收尾产物审计 (**纯函数**; 盘上那一半由 `probeClosureArtifacts` 传进来)。
408
+ *
409
+ * 期望值全部从**真实结构**推导: 步数来自 `CLOSURE_STEP_ORDER`, 决策取值来自
410
+ * `CONTINUATION_DECISIONS`, 决策↔状态映射来自 `run-closure.GOAL_STATE_FOR_DECISION`
411
+ * (单一来源, 不在这里重抄), 用户汇报的面来自 `USER_REPORT_FIELDS` / `USER_VISIBLE_STATES` /
412
+ * `MUST_NOT_EXPOSE_FIELDS`。因此这些冻结面一旦变了, 审计**跟着变**, 不会变成一句过期的话。
413
+ */
414
+ export function auditClosureOutcome(view, ctx) {
415
+ const missing = [];
416
+ const reasons = [];
417
+ const notes = [];
418
+ const claimed = ctx?.claimedKind ?? null;
419
+ const probe = ctx?.probe ?? null;
420
+ const runStatus = String(view?.runStatus ?? '');
421
+ const derived = closureTerminalKindFor(runStatus);
422
+ const truth = claimed ? terminalKindAccepts(claimed, runStatus) : { ok: derived !== null, reason: '' };
423
+ const terminalKind = {
424
+ derived,
425
+ claimed,
426
+ truthful: truth.ok,
427
+ reason: truth.ok
428
+ ? claimed
429
+ ? `申报路径 ${claimed} 与 Run 状态 ${runStatus} 一致`
430
+ : `未申报终止路径; 由 Run 状态 ${runStatus} 推导为 ${String(derived)}`
431
+ : claimed
432
+ ? truth.reason
433
+ : `Run 状态 ${runStatus || '(未知)'} 没有登记在任何终止路径上 → 有路径会静默绕过收尾 (名册必须跟着补)`,
434
+ };
435
+ const mark = (kind, reason) => {
436
+ if (!missing.includes(kind))
437
+ missing.push(kind);
438
+ reasons.push(reason);
439
+ };
440
+ if (!view) {
441
+ mark('artifact_paths', 'no_outcome: 拿不到收尾产物 (不编造一份)');
442
+ return { complete: false, missing, reasons, notes, terminalKind, probe };
443
+ }
444
+ // ── 步 / 决策 / 下一步 / continuation ────────────────────────────────────
445
+ if (!(view.steps >= CLOSURE_STEP_ORDER.length)) {
446
+ mark('steps', `收尾只走了 ${view.steps}/${CLOSURE_STEP_ORDER.length} 步 (被截断的收尾不算收尾)`);
447
+ }
448
+ const d = view.decision;
449
+ if (!d || typeof d.decisionId !== 'string' || !d.decisionId || !CONTINUATION_DECISIONS.includes(String(d.decision))) {
450
+ mark('decision', `继续决策缺失/不合法 (decisionId=${String(d?.decisionId)}, decision=${String(d?.decision)})`);
451
+ }
452
+ if (!String(view.continuation?.nextAction ?? '').trim()) {
453
+ mark('next_action', 'Goal 上没有任何"下一步" → 悬空态 (冻结纪律: 只有终态可以不回答下一步)');
454
+ }
455
+ if (d && CONTINUATION_DECISIONS.includes(String(d.decision))) {
456
+ const expected = GOAL_STATE_FOR_DECISION[d.decision];
457
+ if (String(view.continuation?.state ?? '') !== expected) {
458
+ mark('continuation_state', `continuation.state=${String(view.continuation?.state)} 与决策 ${d.decision} 要求的 ${expected} 不一致`);
459
+ }
460
+ }
461
+ if (String(view.continuation?.updatedAt ?? '') !== String(ctx.now ?? '')) {
462
+ mark('continuation_time', `continuation.updatedAt=${String(view.continuation?.updatedAt)} 不是这一轮的时间 (${String(ctx.now)}) → 不许拿上一轮的 continuation 冒充`);
463
+ }
464
+ // ── 用户汇报 (P4b 第一份输出) ────────────────────────────────────────────
465
+ const rep = view.userReport;
466
+ if (!rep || typeof rep !== 'object') {
467
+ mark('user_report', '缺少用户汇报 (收尾必须给人一份能读的结论)');
468
+ }
469
+ else {
470
+ if (!USER_VISIBLE_STATES.includes(String(rep.visibleState))) {
471
+ mark('user_report', `用户可见态 ${String(rep.visibleState)} 不在六类里`);
472
+ }
473
+ if (!sameStringSet((rep.exposedFields ?? []), USER_REPORT_FIELDS)) {
474
+ mark('user_report', `暴露面与 USER_REPORT_FIELDS 不一致 (收到 ${String((rep.exposedFields ?? []).length)} 项) → 汇报面不许自己长`);
475
+ }
476
+ const leaked = Object.keys(rep).filter((k) => FORBIDDEN_REPORT_WORDS.includes(k));
477
+ if (leaked.length > 0) {
478
+ mark('user_report', `用户汇报里出现了内部字段: ${leaked.join(', ')}`);
479
+ }
480
+ const text = JSON.stringify(rep);
481
+ const wordHits = FORBIDDEN_REPORT_WORDS.filter((w) => text.includes(w));
482
+ if (wordHits.length > 0) {
483
+ notes.push(`report_text_mentions_internal_words: ${wordHits.join(', ')} —— 值里的自由文本 (决策理由等) 不由本接缝改写, 交 M4 的可见态线处理`);
484
+ }
485
+ }
486
+ // ── 终止路径 (规则 ④: 说不清是哪条路径的收尾等于没收) ─────────────────────
487
+ if (!terminalKind.truthful) {
488
+ if (!claimed && RUN_IN_FLIGHT_STATUSES.includes(runStatus)) {
489
+ // 还在飞的状态收尾 (外部暂停等): 不判缺口 —— 它本来就还没"终止", 但如实记一条 note
490
+ notes.push(`run_status_in_flight: 收尾时 Run 仍是 ${runStatus} (还没终止) → 无终止路径可归属, 本次不判缺口`);
491
+ }
492
+ else {
493
+ mark('terminal_kind', terminalKind.reason);
494
+ }
495
+ }
496
+ // ── 产物落点 ─────────────────────────────────────────────────────────────
497
+ if (!String(view.reportPath ?? '') || !String(view.decisionRecordPath ?? '')) {
498
+ mark('artifact_paths', '收尾没有给出产物路径 (产物落在哪里答不出来)');
499
+ }
500
+ if (probe?.scanned) {
501
+ if (!probe.reportFile)
502
+ mark('artifact_files', `用户汇报文件不在盘上: ${view.reportPath}`);
503
+ if (!probe.decisionRecordFile)
504
+ mark('artifact_files', `closure 决策记录不在盘上: ${view.decisionRecordPath}`);
505
+ if (view.memories > 0 && probe.memoryFiles === 0) {
506
+ mark('memory_layers', `收尾报告写了 ${view.memories} 条事实, 但盘上找不到属于这条 Run 的分层记录 (${MEMORY_LAYERS_ROOT}/)`);
507
+ }
508
+ if (probe.layerMismatches.length > 0) {
509
+ mark('memory_layers', `落错层的记忆: ${probe.layerMismatches.join('; ')}`);
510
+ }
511
+ if (probe.trialScopeViolations.length > 0) {
512
+ mark('candidate_trial_scope', `候选文件丢了 next_run_only 试用范围: ${probe.trialScopeViolations.join('; ')}`);
513
+ }
514
+ notes.push(...probe.notes);
515
+ }
516
+ else {
517
+ notes.push('artifact_disk_check_skipped: 没能读盘 (路径布局未知/读盘失败) → 产物"在不在盘上"这一项本次未验');
518
+ }
519
+ return { complete: missing.length === 0, missing, reasons, notes, terminalKind, probe };
520
+ }
521
+ /** 收据的字段集 (断言只加不减: 字段变少 = 收据在退步) */
522
+ export const CLOSURE_RECEIPT_FIELDS = [
523
+ 'runId',
524
+ 'goalId',
525
+ 'claimedTerminalKind',
526
+ 'derivedTerminalKind',
527
+ 'terminalKindTruthful',
528
+ 'steps',
529
+ 'decision',
530
+ 'state',
531
+ 'nextAction',
532
+ 'memories',
533
+ 'candidates',
534
+ 'temporaryArchived',
535
+ 'artifacts',
536
+ 'audit',
537
+ ];
538
+ function buildReceipt(view, audit, claimed, temporaryArchived) {
539
+ const probe = audit.probe;
540
+ return {
541
+ runId: view.runId,
542
+ goalId: view.goalId,
543
+ claimedTerminalKind: claimed ?? 'unspecified',
544
+ derivedTerminalKind: audit.terminalKind.derived,
545
+ terminalKindTruthful: audit.terminalKind.truthful,
546
+ steps: view.steps,
547
+ decision: (view.decision?.decision ?? null),
548
+ state: (view.continuation?.state ?? null),
549
+ nextAction: String(view.continuation?.nextAction ?? ''),
550
+ memories: view.memories,
551
+ candidates: view.candidates,
552
+ temporaryArchived,
553
+ artifacts: {
554
+ reportPath: String(view.reportPath ?? ''),
555
+ decisionRecordPath: String(view.decisionRecordPath ?? ''),
556
+ reportOnDisk: probe?.scanned ? probe.reportFile : null,
557
+ decisionRecordOnDisk: probe?.scanned ? probe.decisionRecordFile : null,
558
+ memoryFiles: probe?.scanned ? probe.memoryFiles : null,
559
+ candidateFiles: probe?.scanned ? probe.candidateFiles : null,
560
+ },
561
+ audit,
562
+ };
563
+ }
564
+ /** 幂等标记 + 审计行 (落进既有的 closure 决策记录, 不新增存储) */
565
+ export const CLOSURE_IDEMPOTENCY_NOTE = '同一条 runId 只允许收一次尾: 第二次调用返回 alreadyClosed=true 且不写任何东西 '
566
+ + '(判据 = 既有的 .bolloon/goal-decisions/<goalId>--<runId>--closure.json 是否存在)';
567
+ export function createClosureSeam(deps) {
568
+ return {
569
+ id: CLOSURE_SEAM_ID,
570
+ stage: 'M2',
571
+ async closeRunOnce(input) {
572
+ const goalId = String(input?.goalId ?? '');
573
+ const runId = String(input?.runId ?? '');
574
+ const caller = input?.caller;
575
+ const now = input?.now;
576
+ // ── ① 说不清是哪条 Run / 谁在收 / 什么时候收 → 一律拒 ────────────────────
577
+ if (!goalId || !runId) {
578
+ return refuse('all_terminal_paths_pass_close_run', `拒绝: 收尾缺少 ${!goalId ? 'goalId' : 'runId'} —— 说不清是哪条 Run 的收尾等于没收`);
579
+ }
580
+ if (!now) {
581
+ return refuse('all_terminal_paths_pass_close_run', '拒绝: 收尾没有时间戳 (now 必须注入, 不许读真实钟)');
582
+ }
583
+ if (!WIRING_CALLERS.includes(String(caller))) {
584
+ return refuse('all_terminal_paths_pass_close_run', `拒绝: 调用方 "${String(caller)}" 不是已知身份 (只认 ${WIRING_CALLERS.join('/')}) —— 收尾要记清是谁在收`);
585
+ }
586
+ if (caller === 'child_agent') {
587
+ return refuse('child_cannot_mutate_goal', '拒绝: 子 Agent 不能收尾 (收尾会改 Goal 的 continuation 与 Memory) —— 由父/宿主收 (CHILD_PROHIBITIONS.mutate_parent_goal_state)');
588
+ }
589
+ const claimed = input?.terminalKind ?? null;
590
+ if (claimed !== null && !CLOSURE_TERMINAL_KINDS.includes(String(claimed))) {
591
+ return refuse('all_terminal_paths_pass_close_run', `拒绝: "${String(claimed)}" 不是名册上的终止路径 (只认 ${CLOSURE_TERMINAL_KINDS.join('/')}) —— 路径不明就不许收尾`);
592
+ }
593
+ // ── ② 幂等: 同一条 Run 只真收一次 ─────────────────────────────────────
594
+ if (await deps.hasClosure(goalId, runId)) {
595
+ deps.note?.(`[closure] run=${runId} 已经收过尾 → 幂等短路 (caller=${caller})`);
596
+ return {
597
+ ok: true,
598
+ alreadyClosed: true,
599
+ goalId,
600
+ runId,
601
+ outcome: null,
602
+ receipt: null,
603
+ reason: `同一条 Run 已收尾 (${runId}) → 不重复写: ${CLOSURE_IDEMPOTENCY_NOTE}`,
604
+ };
605
+ }
606
+ // ── ③ 真收尾 ─────────────────────────────────────────────────────────
607
+ const outcome = await deps.closeGoalRun({
608
+ goalId,
609
+ runId,
610
+ now,
611
+ finalReview: input.finalReview ?? '',
612
+ maxRetries: input.maxRetries,
613
+ });
614
+ if (!outcome) {
615
+ return {
616
+ ok: false,
617
+ alreadyClosed: false,
618
+ goalId,
619
+ runId,
620
+ outcome: null,
621
+ receipt: null,
622
+ reason: `收尾做不到: 拿不到 Run 或 Goal 事实 (goal=${goalId} run=${runId}) → 不编造收尾产物`,
623
+ };
624
+ }
625
+ // ── ④ P1b: temporary 层过期归档 (任务结束的必经一步) ────────────────────
626
+ let archived = null;
627
+ if (deps.purgeTemporary) {
628
+ archived = await deps.purgeTemporary().then((n) => (Number.isFinite(n) ? n : null), () => null);
629
+ }
630
+ // ── ⑤ 产物审计: 收尾做完了吗 + 落在哪里 (真读盘) ────────────────────────
631
+ const probe = await probeClosureArtifacts(outcome);
632
+ const audit = auditClosureOutcome(outcome, { now, claimedKind: claimed, probe });
633
+ const receipt = buildReceipt(outcome, audit, claimed, archived);
634
+ const kind = receipt.claimedTerminalKind === 'unspecified' ? String(receipt.derivedTerminalKind) : receipt.claimedTerminalKind;
635
+ deps.note?.(`[closure] run=${runId} 收尾 ${outcome.steps} 步 → ${receipt.decision} (路径=${kind}, caller=${caller}, `
636
+ + `产物完整=${audit.complete}${audit.complete ? '' : `, 缺: ${audit.missing.join(',')}`})`);
637
+ if (!audit.complete) {
638
+ // ★ 2026-09-26 (M2) 为什么这里**不能**返回 ok:false:
639
+ // 接缝协议里 ok:false 就是"被拒" (`seams.ts` 的 `isRefusal` 判据 =
640
+ // `ok===false && typeof reason === 'string'`)。收尾**真的做完了**却报 ok:false, 调用方会把它
641
+ // 当拒绝处理 —— 实测: 监督者会把这条 Run 从 report.closures 里丢掉 (一条真收尾的 Run 变成
642
+ // "没收尾"), 于是"产物有缺口"这个信号被伪装成"收尾被拒", 两件事都失真。
643
+ // 所以: 产品缺口走 `artifactsComplete:false` + reason 首句 + 收据里的明细, 不用 ok 表达。
644
+ return {
645
+ ok: true,
646
+ alreadyClosed: false,
647
+ goalId,
648
+ runId,
649
+ outcome,
650
+ artifactsComplete: false,
651
+ receipt,
652
+ reason: `收尾完成但产物有缺口 (路径=${kind}): 缺 ${audit.missing.join(', ')} —— ${audit.reasons.join(' | ')}`,
653
+ };
654
+ }
655
+ return {
656
+ ok: true,
657
+ alreadyClosed: false,
658
+ goalId,
659
+ runId,
660
+ outcome,
661
+ artifactsComplete: true,
662
+ receipt,
663
+ reason: `收尾 ${outcome.steps} 步 → ${String(receipt.decision)} (路径=${kind}; 产物齐)`,
664
+ };
665
+ },
666
+ };
667
+ }
668
+ /**
669
+ * 拿一批**真的终止过**的 Run, 逐条查它们有没有收尾记录。
670
+ *
671
+ * 为什么这条判据不能只靠源码扫描: "所有终止路径都调了漏斗"是**代码形状**,
672
+ * 而"这条真的终止过的 Run 到底收没收尾"是**事实**。两者都要有; 后者只能这样查。
673
+ * 读不到 (`hasClosure` 抛错) 时按"没收到"处理并如实记原因 —— 不许把"查不出来"当成"收过了"。
674
+ */
675
+ export async function auditTerminalCoverage(input) {
676
+ const covered = [];
677
+ const gaps = [];
678
+ for (const t of input.terminations) {
679
+ if (!CLOSURE_TERMINAL_KINDS.includes(String(t.kind))) {
680
+ gaps.push({ runId: t.runId, kind: 'unknown_kind', terminatedAt: t.terminatedAt, reason: `终止路径 "${String(t.kind)}" 不在名册上 → 说不清它该不该走收尾` });
681
+ continue;
682
+ }
683
+ let closed = false;
684
+ let why = '';
685
+ try {
686
+ closed = await input.hasClosure(t.goalId, t.runId);
687
+ }
688
+ catch (e) {
689
+ why = e instanceof Error ? e.message : String(e);
690
+ }
691
+ if (closed)
692
+ covered.push(t.runId);
693
+ else
694
+ gaps.push({ runId: t.runId, kind: t.kind, terminatedAt: t.terminatedAt, reason: why ? `查收尾记录失败: ${why}` : `这条 Run 以 ${t.kind} 终止, 但没有收尾记录 → 绕过了唯一责任链` });
695
+ }
696
+ return { checked: input.terminations.length, covered, gaps };
697
+ }