dsh-engineering-ui 0.0.0-stage → 1.5.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.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +772 -3
  3. package/engineering/plugins/dsh-engineering-ui/cordis.patch.yml +5 -0
  4. package/engineering/plugins/dsh-engineering-ui/icon.svg +14 -0
  5. package/engineering/plugins/dsh-engineering-ui/lib/client.js +8259 -0
  6. package/engineering/plugins/dsh-engineering-ui/lib/defense-sign.js +1552 -0
  7. package/engineering/plugins/dsh-engineering-ui/lib/index.js +5633 -0
  8. package/engineering/plugins/dsh-engineering-ui/lib/job-wait-guard.js +236 -0
  9. package/engineering/plugins/dsh-engineering-ui/lib/mem-guard.js +331 -0
  10. package/engineering/plugins/dsh-engineering-ui/lib/webots.js +114 -0
  11. package/engineering/plugins/dsh-engineering-ui/locale/en.json +6 -0
  12. package/engineering/plugins/dsh-engineering-ui/locale/zh.json +6 -0
  13. package/engineering/plugins/dsh-engineering-ui/preset-engineering.patch.yml +601 -0
  14. package/engineering/skills/assembly-orchestration/SKILL.md +651 -0
  15. package/engineering/skills/cad-workflow/SKILL.md +132 -0
  16. package/engineering/skills/mode-selection/SKILL.md +660 -0
  17. package/engineering/skills/physics-in-loop/SKILL.md +238 -0
  18. package/engineering/skills/physics-in-loop/references/feadocs.md +142 -0
  19. package/engineering/skills/solidworks-bridge/SKILL.md +445 -0
  20. package/engineering/skills/sw-design/SKILL.md +112 -0
  21. package/engineering/skills/sw-design/references/autocad-workflow.md +81 -0
  22. package/engineering/skills/sw-design/references/gbt-drafting.md +71 -0
  23. package/engineering/skills/sw-to-cad/SKILL.md +121 -0
  24. package/engineering/skills/webots-simulation/SKILL.md +58 -0
  25. package/engineering/tools/LICENSE.Auto-SolidWorks +21 -0
  26. package/engineering/tools/_plugin_paths.py +93 -0
  27. package/engineering/tools/_root.py +330 -0
  28. package/engineering/tools/_store.py +446 -0
  29. package/engineering/tools/ac_bridge.py +459 -0
  30. package/engineering/tools/ac_rules.json +21 -0
  31. package/engineering/tools/ac_validate.py +699 -0
  32. package/engineering/tools/ask_user.py +887 -0
  33. package/engineering/tools/build_arm1.py +46 -0
  34. package/engineering/tools/build_assembly.py +94 -0
  35. package/engineering/tools/build_base.py +57 -0
  36. package/engineering/tools/build_housing.py +70 -0
  37. package/engineering/tools/build_structural.py +43 -0
  38. package/engineering/tools/build_transmission.py +69 -0
  39. package/engineering/tools/cadx.lsp +128 -0
  40. package/engineering/tools/choice_contract.py +61 -0
  41. package/engineering/tools/conn_state.py +1671 -0
  42. package/engineering/tools/defense_gate.py +3374 -0
  43. package/engineering/tools/drawing_input.py +314 -0
  44. package/engineering/tools/env_check.py +467 -0
  45. package/engineering/tools/examples/build_axis.py +142 -0
  46. package/engineering/tools/examples/build_cup.py +56 -0
  47. package/engineering/tools/examples/build_plate.py +58 -0
  48. package/engineering/tools/examples/build_sphere.py +30 -0
  49. package/engineering/tools/examples/build_transition_duct.py +381 -0
  50. package/engineering/tools/gate_integrity.py +401 -0
  51. package/engineering/tools/mode2_flow.py +3589 -0
  52. package/engineering/tools/mode_checker.py +77 -0
  53. package/engineering/tools/mode_gate.py +8013 -0
  54. package/engineering/tools/part_registry.py +4266 -0
  55. package/engineering/tools/physics/__init__.py +50 -0
  56. package/engineering/tools/physics/_compat.py +12 -0
  57. package/engineering/tools/physics/design_state.py +142 -0
  58. package/engineering/tools/physics/domain_validator.py +751 -0
  59. package/engineering/tools/physics/fatigue.py +489 -0
  60. package/engineering/tools/physics/fea_solver.py +1163 -0
  61. package/engineering/tools/physics/feapy_solver.py +314 -0
  62. package/engineering/tools/physics/gb_materials.json +1228 -0
  63. package/engineering/tools/physics/geometry_gate.py +337 -0
  64. package/engineering/tools/physics/load_case.py +951 -0
  65. package/engineering/tools/physics/material_db.py +874 -0
  66. package/engineering/tools/physics/mesh_adapter.py +321 -0
  67. package/engineering/tools/physics/refine_rules.py +272 -0
  68. package/engineering/tools/physics/rules/gb_base_rules.json +377 -0
  69. package/engineering/tools/physics/rules/gb_drafting_rules.json +57 -0
  70. package/engineering/tools/physics/rules/gb_electrical_rules.json +25 -0
  71. package/engineering/tools/physics/rules/gb_fasteners_rules.json +73 -0
  72. package/engineering/tools/physics/rules/gb_housing_rules.json +41 -0
  73. package/engineering/tools/physics/rules/gb_manufacturing_rules.json +25 -0
  74. package/engineering/tools/physics/rules/gb_material_verify_rules.json +72 -0
  75. package/engineering/tools/physics/rules/gb_materials_rules.json +377 -0
  76. package/engineering/tools/physics/rules/gb_safety_rules.json +89 -0
  77. package/engineering/tools/physics/rules/gb_springs_rules.json +41 -0
  78. package/engineering/tools/physics/rules/gb_standards_registry.json +1646 -0
  79. package/engineering/tools/physics/rules/gb_structural_rules.json +201 -0
  80. package/engineering/tools/physics/rules/gb_surface_rules.json +25 -0
  81. package/engineering/tools/physics/rules/gb_transmission_rules.json +233 -0
  82. package/engineering/tools/physics/rules/housing_rules.json +48 -0
  83. package/engineering/tools/physics/rules/manifest.json +54 -0
  84. package/engineering/tools/physics/rules/mold_rules.json +48 -0
  85. package/engineering/tools/physics/rules/structural_rules.json +56 -0
  86. package/engineering/tools/physics/rules/transmission_rules.json +48 -0
  87. package/engineering/tools/physics/simulation_report.py +829 -0
  88. package/engineering/tools/physics_bridge.py +2685 -0
  89. package/engineering/tools/profile-manifest.ps1 +72 -0
  90. package/engineering/tools/run_regression.py +109 -0
  91. package/engineering/tools/settings.py +419 -0
  92. package/engineering/tools/solidworks-modeling.md +519 -0
  93. package/engineering/tools/sw_bridge.py +8215 -0
  94. package/engineering/tools/sw_guard.py +1066 -0
  95. package/engineering/tools/sw_surface.py +87 -0
  96. package/engineering/tools/swapi.py +14766 -0
  97. package/engineering/tools/verify_sync.py +218 -0
  98. package/engineering/tools/visual_check.py +2589 -0
  99. package/engineering/tools/webots_env.py +121 -0
  100. package/engineering/tools/webots_mcp/__init__.py +3 -0
  101. package/engineering/tools/webots_mcp/__main__.py +3 -0
  102. package/engineering/tools/webots_mcp/assembly.py +391 -0
  103. package/engineering/tools/webots_mcp/bootstrap.py +55 -0
  104. package/engineering/tools/webots_mcp/controller_worker.py +124 -0
  105. package/engineering/tools/webots_mcp/evidence.py +288 -0
  106. package/engineering/tools/webots_mcp/fake_worker.py +69 -0
  107. package/engineering/tools/webots_mcp/fault_resources.py +121 -0
  108. package/engineering/tools/webots_mcp/host.py +45 -0
  109. package/engineering/tools/webots_mcp/ledger.py +73 -0
  110. package/engineering/tools/webots_mcp/owned_job.py +70 -0
  111. package/engineering/tools/webots_mcp/physical_gate.py +187 -0
  112. package/engineering/tools/webots_mcp/physics.py +152 -0
  113. package/engineering/tools/webots_mcp/pipeline.py +108 -0
  114. package/engineering/tools/webots_mcp/recipes.py +129 -0
  115. package/engineering/tools/webots_mcp/repair.py +132 -0
  116. package/engineering/tools/webots_mcp/repair_trial.py +375 -0
  117. package/engineering/tools/webots_mcp/runtime.py +311 -0
  118. package/engineering/tools/webots_mcp/server.py +217 -0
  119. package/engineering/tools/webots_mcp/sw_export.py +509 -0
  120. package/engineering/tools/webots_mcp/transport.py +156 -0
  121. package/engineering/tools/workflow_gate.py +6134 -0
  122. package/package.json +64 -4
@@ -0,0 +1,3589 @@
1
+ # -*- coding: utf-8 -*-
2
+ """mode2_flow —— 模式二(大型复杂器械装配)新流程的【代码级强制契约】
3
+
4
+ ═══════════════════════════════════════════════════════════════════════════
5
+ 本模块存在的唯一理由
6
+ ═══════════════════════════════════════════════════════════════════════════
7
+ 用户明确要求:
8
+
9
+ 「所有改动都要写在代码里面,强制执行的」
10
+ 「不能是写在提示词里面的」
11
+
12
+ 因此本模块把原先只存在于 SKILL.md / persona 自然语言里的流程约束,
13
+ 全部升级为**可以被调用、会返回失败、能阻断流程**的代码契约:
14
+
15
+ ① 提问数量硬约束 —— 强需求 [10,50]、弱需求 [5,10];
16
+ 超界/未达下限一律拒绝,不再"问几题都行"。
17
+ ② 按需提问(取消"一股脑全问")—— 每道题必须挂在一个**已开始的
18
+ 分析阶段**上,并写明**为什么需要它**;不挂阶段 = 拒绝登记。
19
+ 这就是"AI 思考到那里,才问对应的问题"的机器判据。
20
+ ③ 十阶段流水线 —— 阶段必须按序推进,跳阶段 = 拒绝。
21
+ ④ 子代理数量由【总计子代理】决定 —— 不再固定 3 个;
22
+ 数量与依据必须落盘,且必须来自 `decided_by == 总计子代理`。
23
+ ⑤ 总理子代理 —— 固定存在;建模小屋必须向其上报
24
+ 三道防线凭据 / 尺寸 / 名称 / 相邻配合零件,缺字段 = 拒绝。
25
+ ⑥ 相邻零件尺寸一致性 —— 不一致时【必须】升级给父代理干涉。
26
+ ⑦ 零件数据持久台账 —— 可反查(见 part_registry.py)。
27
+
28
+ 设计原则:**fail-closed**。任何"信息不足"的情形都返回 ok=False 并说明原因,
29
+ 绝不静默放行。这样上游(门禁/主对话)无法"假装做过了"。
30
+
31
+ ═══════════════════════════════════════════════════════════════════════════
32
+ 原子写入
33
+ ═══════════════════════════════════════════════════════════════════════════
34
+ 本文件会被多个小屋并发读取(各自上报),因此落盘一律走 `os.replace`
35
+ 原子替换,避免读到半个 JSON —— 这与 defense_gate.py 的处理保持一致。
36
+ """
37
+ from __future__ import annotations
38
+
39
+ try:
40
+ import sys as _sys_utf8
41
+ for _stream_name in ("stdout", "stderr"):
42
+ _st = getattr(_sys_utf8, _stream_name, None)
43
+ if _st is not None and hasattr(_st, "reconfigure"):
44
+ _st.reconfigure(encoding="utf-8", errors="replace")
45
+ except Exception:
46
+ pass
47
+
48
+ import json
49
+ import os
50
+ import re
51
+ import sys
52
+ import time
53
+
54
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
55
+
56
+ #: 流程状态文件(与 part_registry.json 同目录,便于一并归档)
57
+ PLAN_FILE = "mode2_plan.json"
58
+
59
+
60
+ # ═══════════════════════════════════════════════════════════════════════════
61
+ # 一、十阶段设计流水线(来自用户给定的方法论文件)
62
+ # ═══════════════════════════════════════════════════════════════════════════
63
+ # 顺序即契约:`begin_stage` 只允许推进到【下一个】阶段,禁止跳跃。
64
+ # 每个阶段带 `needs`(该阶段要产出什么),用于校验阶段真的做完了。
65
+ STAGES = [
66
+ ("TaskUnderstanding", "任务理解", "结构化 TaskSpec:目标/环境/载荷/约束/未知量"),
67
+ ("FunctionalDecomposition", "功能分解", "FunctionTree:子功能 + 输入输出 + 依赖"),
68
+ ("PhysicsReasoning", "物理推导", "PhysicsModel:运动/力/转矩/能量/自由度 + 假定"),
69
+ ("PrincipleSynthesis", "工作原理探索", "多个候选原理 + 淘汰理由"),
70
+ ("MechanismSynthesis", "机构综合", "MechanismGraph:构件 + 连接 + 自由度"),
71
+ ("RecursivePartReasoning", "递归零件需求推导", "PartRequirementGraph:每个零件为何存在"),
72
+ ("PartSynthesis", "零件设计与选型", "标准件/自定义件 + 几何与接口定义"),
73
+ ("EngineeringValidation", "工程验证", "PASS/FAIL/UNKNOWN + 证据"),
74
+ ("DesignIteration", "自主迭代", "回退位置 + 版本 + 未解决问题"),
75
+ ("ExplainableDesignOutput", "可解释输出", "完整设计报告 + 可追溯性"),
76
+ ]
77
+
78
+ STAGE_IDS = [s[0] for s in STAGES]
79
+ STAGE_LABELS = {s[0]: s[1] for s in STAGES}
80
+ STAGE_NEEDS = {s[0]: s[2] for s in STAGES}
81
+
82
+
83
+ # ═══════════════════════════════════════════════════════════════════════════
84
+ # 一·补充、【Bug② 修复】阶段产物新鲜度(stale)基础判据
85
+ # ═══════════════════════════════════════════════════════════════════════════
86
+ # 缺陷回顾(2026-10-19 循迹小车实测):
87
+ # 十阶段 artifacts 在【用户答复之前】就落盘了,答复之后从不回写 ——
88
+ # mode2_plan.json 里仍是「底盘 100×100 / PLA」,用户确认的是
89
+ # 「150×120 / PETG」。下游房间读到的全是过期值,只能靠主代理在 prompt 里
90
+ # 手工硬写才纠正。
91
+ #
92
+ # 本段的判据(全部落盘,不靠模型自觉):
93
+ # · 每个阶段的"最新一条记录"决定它到底完成没有 —— 重做会用新记录覆盖旧状态;
94
+ # · 阶段产物若依赖【未答题目】,就在 stage_status 里显式暴露;
95
+ # · 题目一旦被作答,该题目挂载的阶段产物立刻标记 stale(带原因);
96
+ # · stale 阶段一律视为【未完成】(fail-closed),必须重做后才能继续;
97
+ # · 重做必须给出【新的 artifact】,与过期前完全相同的产物被拒绝(防走过场)。
98
+ def _stage_records(plan):
99
+ """按阶段汇总 stage 记录(保持落盘顺序)。"""
100
+ out = {}
101
+ for s in (plan or {}).get("stages", []) or []:
102
+ if not isinstance(s, dict):
103
+ continue
104
+ sid = s.get("stage")
105
+ if sid not in STAGE_LABELS:
106
+ continue
107
+ out.setdefault(sid, []).append(s)
108
+ return out
109
+
110
+
111
+ def _stage_latest(plan):
112
+ """每个阶段的【最新一条】记录。
113
+
114
+ 为什么要"最新一条"而不是"任意一条 done":
115
+ 重做一个早期阶段时,旧记录仍是 done;若只看"存在 done",重做永远
116
+ 无法让该阶段回到未完成,stale 也就永远清不掉。取最新记录才反映真实状态。
117
+ """
118
+ return {sid: recs[-1] for sid, recs in _stage_records(plan).items() if recs}
119
+
120
+
121
+ def _stage_done_map(plan):
122
+ """阶段 → 已登记的 artifact(仅"最新记录为 done"的阶段)。"""
123
+ out = {}
124
+ for sid, rec in _stage_latest(plan).items():
125
+ if rec.get("status") == "done":
126
+ out[sid] = rec.get("artifact") or ""
127
+ return out
128
+
129
+
130
+ def _stage_question_seqs(plan):
131
+ """阶段 → 挂在它上面的题号(台账 + 依赖登记两处合并,兼容旧计划文件)。"""
132
+ out = {}
133
+ for st, seqs in ((plan or {}).get("stage_question_deps") or {}).items():
134
+ for s in (seqs or []):
135
+ try:
136
+ out.setdefault(st, []).append(int(s))
137
+ except Exception:
138
+ continue
139
+ for q in ((plan or {}).get("questions") or []):
140
+ st = q.get("stage")
141
+ if st not in STAGE_LABELS:
142
+ continue
143
+ try:
144
+ seq = int(q.get("seq"))
145
+ except Exception:
146
+ continue
147
+ if seq not in out.setdefault(st, []):
148
+ out[st].append(seq)
149
+ for st in out:
150
+ out[st] = sorted(set(out[st]))
151
+ return out
152
+
153
+
154
+ def _unanswered_by_stage(plan):
155
+ """阶段 → 该阶段上【尚未作答】的题目列表("产物依赖未答题目"的判据)。"""
156
+ qmap = {q.get("seq"): q for q in ((plan or {}).get("questions") or [])}
157
+ out = {}
158
+ for st, seqs in _stage_question_seqs(plan).items():
159
+ pend = [qmap[s] for s in seqs if s in qmap and not qmap[s].get("answered")]
160
+ if pend:
161
+ out[st] = pend
162
+ return out
163
+
164
+
165
+ def _stale_map(plan):
166
+ """阶段 → stale 记录(同一阶段只保留一条,后写覆盖前写)。"""
167
+ out = {}
168
+ for e in ((plan or {}).get("stale_stages") or []):
169
+ if isinstance(e, dict) and e.get("stage") in STAGE_LABELS:
170
+ out[e["stage"]] = e
171
+ return out
172
+
173
+
174
+ def _mark_stage_stale(plan, stage, questions, old_artifact, reason):
175
+ """把某阶段标记为【产物过期】。同阶段多次标记 → 合并题号、刷新原因。"""
176
+ stale = [e for e in (plan.get("stale_stages") or [])
177
+ if isinstance(e, dict) and e.get("stage") != stage]
178
+ prev = _stale_map(plan).get(stage) or {}
179
+ qs = []
180
+ for q in (prev.get("questions") or []) + list(questions or []):
181
+ if not isinstance(q, dict):
182
+ continue
183
+ if all(int(x.get("seq", -1)) != int(q.get("seq", -2)) for x in qs):
184
+ qs.append(q)
185
+ entry = {
186
+ "stage": stage,
187
+ "label": STAGE_LABELS[stage],
188
+ "reason": reason,
189
+ "questions": qs,
190
+ "old_artifact": old_artifact,
191
+ "at": time.strftime("%Y-%m-%d %H:%M:%S"),
192
+ "ts": time.time(),
193
+ "since": prev.get("since") or time.strftime("%Y-%m-%d %H:%M:%S"),
194
+ "redo_started_at": prev.get("redo_started_at"),
195
+ }
196
+ stale.append(entry)
197
+ plan["stale_stages"] = stale
198
+ return entry
199
+
200
+
201
+ def _clear_stale(plan, stage, cleared_by, artifact=None):
202
+ """清除某阶段的 stale,并留审计痕迹(stale_history 只增不改)。"""
203
+ keep = []
204
+ hit = None
205
+ for e in (plan.get("stale_stages") or []):
206
+ if isinstance(e, dict) and e.get("stage") == stage:
207
+ hit = e
208
+ continue
209
+ keep.append(e)
210
+ plan["stale_stages"] = keep
211
+ if hit is not None:
212
+ hist = dict(hit)
213
+ hist["cleared_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
214
+ hist["cleared_by"] = cleared_by
215
+ hist["cleared_artifact"] = artifact
216
+ plan.setdefault("stale_history", []).append(hist)
217
+ return hit
218
+
219
+
220
+ def _parse_ts(s):
221
+ """把 'YYYY-mm-dd HH:MM:SS' 解析成 epoch;解析不了返回 None(fail-safe 不误判)。"""
222
+ try:
223
+ return time.mktime(time.strptime(str(s).strip(), "%Y-%m-%d %H:%M:%S"))
224
+ except Exception:
225
+ return None
226
+
227
+
228
+ def _stale_implied(plan):
229
+ """【Bug② 修复 · 存量数据判定】从时间戳推断"产物过期"(只读,不落盘)。
230
+
231
+ 为什么还要这一层:
232
+ answer_question 的 stale 标记是【向前生效】的 —— 它只能拦住"之后"的改答。
233
+ 而 bug 记录里的现场是【已经发生】的:十阶段产物 20:30~20:33 落盘,
234
+ 用户答复 20:55~20:57 才到,mode2_plan.json 里躺的就是过期文本。
235
+ 若只认落盘标记,这些历史/存量计划文件依旧会被判"完成"。
236
+
237
+ 判据(纯数据,不靠模型自述):
238
+ 阶段最新一条 done 记录的 done_at < 该阶段上某道题的 answered_at
239
+ → 产物写完【之后】用户才给出答案 → 产物必然没吸收这个答案 → 过期。
240
+
241
+ 只做只读推断;真正清除仍必须走 refresh-stage + 新产物(见 mark_stage_done)。
242
+ """
243
+ out = {}
244
+ done = _stage_done_map(plan)
245
+ latest = _stage_latest(plan)
246
+ q_by_seq = {q.get("seq"): q for q in (plan.get("questions") or [])}
247
+ for st, seqs in _stage_question_seqs(plan).items():
248
+ if st not in done:
249
+ continue
250
+ _art_at = _parse_ts((latest.get(st) or {}).get("done_at"))
251
+ if _art_at is None:
252
+ continue
253
+ _late = []
254
+ for s in seqs:
255
+ q = q_by_seq.get(s)
256
+ if not q or not q.get("answered"):
257
+ continue
258
+ _ans_at = _parse_ts(q.get("answered_at"))
259
+ if _ans_at is not None and _ans_at > _art_at:
260
+ _late.append({"seq": s, "topic": q.get("topic"),
261
+ "answered_at": q.get("answered_at")})
262
+ if _late:
263
+ out[st] = {
264
+ "stage": st,
265
+ "label": STAGE_LABELS[st],
266
+ "artifact_at": (latest.get(st) or {}).get("done_at"),
267
+ "questions": _late,
268
+ "reason": ("题目 %s 在产物落盘【之后】才被作答,产物未吸收该答复"
269
+ % "、".join(str(q["seq"]) for q in _late)),
270
+ "source": "timestamp_inference",
271
+ }
272
+ return out
273
+
274
+
275
+ def _redo_watch(plan):
276
+ """重做监视表:refresh-stage 之后,必须交回【不同的新产物】才算重做完成。"""
277
+ return plan.setdefault("stale_redo_watch", {})
278
+
279
+
280
+ def _redo_expectation(plan, stage):
281
+ """取某阶段"必须重做"的期望(stale 记录或 refresh 后的监视项,任一存在即可)。"""
282
+ st = _stale_map(plan).get(stage)
283
+ if st:
284
+ return st, "stale"
285
+ w = (_redo_watch(plan) or {}).get(stage)
286
+ if isinstance(w, dict):
287
+ return w, "redo_watch"
288
+ return None, None
289
+
290
+
291
+ # ═══════════════════════════════════════════════════════════════════════════
292
+ # 二、提问数量硬约束(用户口径)
293
+ # ═══════════════════════════════════════════════════════════════════════════
294
+ # 用户原话:
295
+ # 「强需求的18题限制取消,但是最少不能少于10但是可以等于10,不能超过50题,
296
+ # 弱需求取消5题,但不能少于5题或者可以等于5题,但不能超过10题。」
297
+ #
298
+ # 注意"可以等于":下界是闭区间 —— 恰好 10 题(强)/ 恰好 5 题(弱)都是合法的。
299
+ QUESTION_LIMITS = {
300
+ "full": {"min": 10, "max": 50, "label": "强需求"},
301
+ "lite": {"min": 5, "max": 10, "label": "弱需求"},
302
+ }
303
+
304
+
305
+ def limits_for(depth):
306
+ """取某深度的题数上下界;深度非法时返回 None(由调用方 fail-closed)。"""
307
+ return QUESTION_LIMITS.get(str(depth or "").strip().lower())
308
+
309
+
310
+ def validate_question_budget(depth, asked_count, *, final=False):
311
+ """检查"已问/将问题数"是否落在该深度的合法区间内。
312
+
313
+ Args:
314
+ depth: "full" / "lite"
315
+ asked_count: 已登记的问题数量(整数)
316
+ final: True 表示这是收尾校验 —— 此时【必须】达到下界;
317
+ False 表示过程校验 —— 只禁止超过上界(还没问完是正常的)。
318
+
319
+ Returns:
320
+ {"ok": bool, "depth":..., "min":..., "max":..., "asked":...,
321
+ "remaining":..., "error":...}
322
+
323
+ 为什么区分 final:
324
+ 过程里随时可能只问了 3 题(正常,还没分析到需要更多的地方),
325
+ 此时用下界去卡会误杀。只有"分析结束、准备交付"时才要求达到下界。
326
+ """
327
+ lim = limits_for(depth)
328
+ if not lim:
329
+ return {"ok": False, "error": "未知的参数需求深度: %r(合法值 full/lite)" % (depth,)}
330
+ try:
331
+ n = int(asked_count)
332
+ except Exception:
333
+ return {"ok": False, "error": "asked_count 不是整数: %r" % (asked_count,)}
334
+ if n < 0:
335
+ return {"ok": False, "error": "asked_count 不能为负: %d" % n}
336
+ out = {"depth": depth, "min": lim["min"], "max": lim["max"],
337
+ "asked": n, "label": lim["label"],
338
+ "remaining_to_min": max(0, lim["min"] - n),
339
+ "remaining_to_max": max(0, lim["max"] - n)}
340
+ if n > lim["max"]:
341
+ out.update({
342
+ "ok": False,
343
+ "error": ("【提问超限】%s 最多 %d 题,当前已问 %d 题(超出 %d 题)。"
344
+ "禁止继续提问 —— 请用已有信息推进,或把问题合并。"
345
+ % (lim["label"], lim["max"], n, n - lim["max"])),
346
+ })
347
+ return out
348
+ if final and n < lim["min"]:
349
+ out.update({
350
+ "ok": False,
351
+ "error": ("【提问不足】%s 至少需要 %d 题,当前只问了 %d 题(还差 %d 题)。"
352
+ "参数不足以支撑建模与校核,禁止进入下一阶段。"
353
+ % (lim["label"], lim["min"], n, lim["min"] - n)),
354
+ })
355
+ return out
356
+ out["ok"] = True
357
+ return out
358
+
359
+
360
+ # ═══════════════════════════════════════════════════════════════════════════
361
+ # 三、流程状态读写(原子写)
362
+ # ═══════════════════════════════════════════════════════════════════════════
363
+ def _plan_dir(work_dir=None):
364
+ """计划文件所在目录。
365
+
366
+ 优先级:显式 work_dir → workflow_state 里当前任务的 work_dir → tools/load_cases。
367
+
368
+ 为什么要加中间这一层:
369
+ set-subagent-plan 不带 --work-dir 时曾直接落到 load_cases,
370
+ 而 workflow_gate.select / mode_gate.room-start 读的是【任务目录】,
371
+ 于是"登记成功"却"门禁看不见"—— 表现为编制 task_id 对不上、
372
+ room-start 被 SUBAGENT_PLAN_STALE 误拦。
373
+ 现在默认对齐到当前任务目录,两边永远是同一份文件。
374
+ """
375
+ base = work_dir
376
+ if not base:
377
+ # 从 workflow_state 取当前任务目录 —— 必须与门禁【同一份】状态文件。
378
+ # 直接问 workflow_gate 要(它知道真实的 STATE_DIR),
379
+ # 不要自己拼路径:工程模式有"仓库 / 安装副本"两套 tools,
380
+ # 自己拼会读到另一套里的旧任务,导致编制 task_id 永远对不上。
381
+ # 注意:不能直接 `import workflow_gate` —— 工程模式有"仓库 /
382
+ # 安装副本"两套 tools,sys.path 里可能先命中另一套,
383
+ # 于是读到【另一个任务】的 work_dir,编制就永远对不上。
384
+ # 这里改为按【本文件同目录】加载,保证与调用方是同一份状态。
385
+ # ── 直接读状态文件,不 import 任何同族模块 ─────────────────────
386
+ # 踩过两次的坑:sys.path 里同时存在「仓库 tools」与「安装副本 tools」,
387
+ # import workflow_gate 可能命中另一套 → 读到另一个任务的 work_dir。
388
+ # 状态目录的权威裁决者是 _store.state_dir(),直接用它最稳。
389
+ try:
390
+ _sd = None
391
+ try:
392
+ import _store as _st_mod
393
+ _sd = _st_mod.state_dir()
394
+ except Exception:
395
+ _sd = None
396
+ if not _sd:
397
+ _sd = os.path.join(os.path.dirname(os.path.abspath(__file__)))
398
+ _wf = os.path.join(_sd, "workflow_state.json")
399
+ if os.path.exists(_wf):
400
+ with open(_wf, "r", encoding="utf-8") as _f:
401
+ _stj = json.load(_f) or {}
402
+ base = _stj.get("work_dir") or None
403
+ except Exception:
404
+ base = None
405
+ if not base:
406
+ try:
407
+ import _root # noqa: F401 (仅为触发根目录解析,失败则忽略)
408
+ except Exception:
409
+ pass
410
+ base = os.path.join(BASE_DIR, "load_cases")
411
+ try:
412
+ os.makedirs(base, exist_ok=True)
413
+ except Exception:
414
+ base = BASE_DIR
415
+ return base
416
+
417
+
418
+ def plan_path(work_dir=None):
419
+ return os.path.join(_plan_dir(work_dir), PLAN_FILE)
420
+
421
+
422
+ def _atomic_write_json(path, data):
423
+ """原子写:先写临时文件再 os.replace,避免并发读到半个 JSON。"""
424
+ tmp = path + ".tmp.%d" % os.getpid()
425
+ with open(tmp, "w", encoding="utf-8") as f:
426
+ json.dump(data, f, ensure_ascii=False, indent=2)
427
+ os.replace(tmp, path)
428
+
429
+
430
+ def _fresh_plan():
431
+ return {
432
+ "schema": "dsh-engineering/mode2-plan@1",
433
+ "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
434
+ "updated_at": None,
435
+ "param_depth": None,
436
+ "stages": [], # 已开始的阶段(含 status)
437
+ "stage_index": -1, # 已推进到的阶段下标(-1 = 未开始)
438
+ "questions": [], # 【按需提问】台账,见 record_question
439
+ # ══ 【Bug② 修复】十阶段产物与用户答复的依赖 / 过期(stale)台账 ══
440
+ # 原缺陷:十阶段 artifacts 在用户答复【之前】就落盘,答复后从不回写,
441
+ # 导致 mode2_plan.json 里仍是过期值(实测:底盘 100×100/PLA,
442
+ # 而用户确认的是 150×120/PETG),后续房间只能靠主代理手工覆盖。
443
+ # 现在:
444
+ # stage_question_deps —— 哪个阶段的产物依赖哪些题目(含未答标记);
445
+ # stale_stages —— 因用户答复而【产物过期】、必须重算的阶段;
446
+ # stale_history —— 已清除的过期记录(审计用,只增不改)。
447
+ "stage_question_deps": {},
448
+ "stale_stages": [],
449
+ "stale_history": [],
450
+ # ── 【2026-10-28 重构】总计子代理的真实身份(analyst-bind 写入)──
451
+ # 没有它 = 十阶段/编制都不合法(主对话不能再自己走完流程)。
452
+ "analyst": None,
453
+ "subagent_plan": None, # 由【总计子代理】写入
454
+ # ══ 【Bug③ 修复】rooms 快照(与门禁同一口径)══
455
+ # 原缺陷:set-subagent-plan 只改 subagent_plan,不刷新 mode2_plan 的
456
+ # rooms 快照,于是 mode_gate / defense_gate / workflow_gate 三处
457
+ # room_type 各说各话(drivetrain/sensor/drawing vs transmission/...)。
458
+ "subagent_config": None,
459
+ "prime_reports": [], # 建模小屋 → 总理子代理 的上报
460
+ "prime_consistency": None,
461
+ "notes": [],
462
+ }
463
+
464
+
465
+ def load_plan(work_dir=None):
466
+ p = plan_path(work_dir)
467
+ if not os.path.exists(p):
468
+ return _fresh_plan()
469
+ try:
470
+ with open(p, "r", encoding="utf-8") as f:
471
+ d = json.load(f) or {}
472
+ # 老文件缺字段时补齐,避免下游 KeyError
473
+ base = _fresh_plan()
474
+ for k, v in base.items():
475
+ d.setdefault(k, v)
476
+ return d
477
+ except Exception:
478
+ # 读坏时【不静默返回空计划】——空计划会让"未做"看起来像"没要求"。
479
+ # 这里显式标记,交由调用方 fail-closed。
480
+ d = _fresh_plan()
481
+ d["_load_error"] = "计划文件读取失败: %s" % p
482
+ return d
483
+
484
+
485
+ def _state_dir():
486
+ """状态目录权威裁决者(_store.state_dir()),与门禁读同一份状态文件。
487
+
488
+ ── 为什么不能按"脚本所在目录"拼路径 ────────────────────────────────
489
+ 工程模式在磁盘上有两份 tools(仓库副本 + ~/.dsh 安装副本)。按
490
+ __file__ 拼 workflow_state.json 会拿到【自己这份】的状态 —— 而门禁
491
+ / 守卫读的是权威目录。实测后果:plan 的 task_id 取自另一份,
492
+ 编制 task_id 与当前任务永远对不上 → select 报 MODE2_SUBAGENT_PLAN_STALE,
493
+ Bug③ 的"rooms 快照同步"也就整条链路失效。
494
+ """
495
+ try:
496
+ import _store as _st
497
+ _d = _st.state_dir()
498
+ if _d:
499
+ return _d
500
+ except Exception:
501
+ pass
502
+ return os.path.dirname(os.path.abspath(__file__))
503
+
504
+
505
+ def current_task_id(work_dir=None):
506
+ """当前任务 task_id(权威目录里的 workflow_state)。读不到返回 None。"""
507
+ try:
508
+ _p = os.path.join(_state_dir(), "workflow_state.json")
509
+ if os.path.exists(_p):
510
+ with open(_p, "r", encoding="utf-8") as _f:
511
+ return (json.load(_f) or {}).get("task_id") or None
512
+ except Exception:
513
+ pass
514
+ return None
515
+
516
+
517
+ def save_plan(plan, work_dir=None):
518
+ plan = dict(plan or {})
519
+ plan["schema"] = "dsh-engineering/mode2-plan@1"
520
+ plan["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
521
+ # task_id 一律以【权威目录】的 workflow_state 为准 ——
522
+ # 否则编制与任务对不上,room-start 会被 SUBAGENT_PLAN_STALE 误拦。
523
+ if not plan.get("task_id"):
524
+ try:
525
+ plan["task_id"] = current_task_id()
526
+ except Exception:
527
+ pass
528
+ p = plan_path(work_dir)
529
+ try:
530
+ _atomic_write_json(p, plan)
531
+ return {"ok": True, "path": p}
532
+ except Exception as e:
533
+ return {"ok": False, "error": "写入计划失败: %r" % (e,), "path": p}
534
+
535
+
536
+ # ═══════════════════════════════════════════════════════════════════════════
537
+ # 四、参数深度
538
+ # ═══════════════════════════════════════════════════════════════════════════
539
+ def set_depth(depth, work_dir=None):
540
+ """登记参数需求强度(强需求 full / 弱需求 lite)。"""
541
+ if not limits_for(depth):
542
+ return {"ok": False, "error": "参数需求强度必须是 full(强需求)或 lite(弱需求),收到 %r" % (depth,)}
543
+ plan = load_plan(work_dir)
544
+ if plan.get("_load_error"):
545
+ return {"ok": False, "error": plan["_load_error"]}
546
+ old = plan.get("param_depth")
547
+ if old and old != depth and plan.get("questions"):
548
+ # 已经按某个口径问过题了就【不允许】改口径 —— 否则 10~50 的约束会被绕过
549
+ return {"ok": False,
550
+ "error": ("已按【%s】口径登记过 %d 道题,禁止再改成【%s】"
551
+ "(改口径会绕过题数硬约束)。如需重来请先 reset。"
552
+ % (QUESTION_LIMITS[old]["label"], len(plan["questions"]),
553
+ QUESTION_LIMITS[depth]["label"]))}
554
+ plan["param_depth"] = depth
555
+ lim = limits_for(depth)
556
+ save_plan(plan, work_dir)
557
+ return {"ok": True, "depth": depth, "label": lim["label"],
558
+ "min": lim["min"], "max": lim["max"],
559
+ "path": plan_path(work_dir)}
560
+
561
+
562
+ # ═══════════════════════════════════════════════════════════════════════════
563
+ # 五、十阶段按序推进
564
+ # ═══════════════════════════════════════════════════════════════════════════
565
+ def begin_stage(stage, work_dir=None, note="", actor=None):
566
+ """开始一个分析阶段。**必须按序**,跳阶段直接拒绝。
567
+
568
+ ── 【2026-10-28 重构】只能由【总计子代理】调用 ────────────────────
569
+ analyst-bind 之后,主对话(sessionId 不同)直接调会被
570
+ ANALYST_ACTOR_REQUIRED 拒绝。
571
+
572
+ 这条约束是"按需提问"的前提:只有真的推进到某个阶段,
573
+ 才允许登记挂在那个阶段上的问题(见 record_question)。
574
+ """
575
+ _guard = _require_analyst_actor("begin-stage", actor, work_dir)
576
+ if _guard:
577
+ return _guard
578
+ if stage not in STAGE_IDS:
579
+ return {"ok": False,
580
+ "error": "未知阶段 %r;合法阶段:%s" % (stage, " → ".join(STAGE_IDS))}
581
+ plan = load_plan(work_dir)
582
+ if plan.get("_load_error"):
583
+ return {"ok": False, "error": plan["_load_error"]}
584
+
585
+ idx = STAGE_IDS.index(stage)
586
+ cur = int(plan.get("stage_index", -1))
587
+ # 允许"重复进入当前阶段"(迭代会回到同一阶段),但不允许跳跃
588
+ if idx > cur + 1:
589
+ _skipped = STAGE_IDS[cur + 1:idx]
590
+ return {"ok": False,
591
+ "error": ("【跳阶段】不允许从【%s】直接跳到【%s】—— 中间跳过了:%s。"
592
+ "十阶段必须按序推进(DesignIteration 可回退到任意更早阶段)。"
593
+ % (STAGE_LABELS[STAGE_IDS[cur]] if cur >= 0 else "(未开始)",
594
+ STAGE_LABELS[stage], "、".join(STAGE_LABELS[s] for s in _skipped))),
595
+ "stage_index": cur, "expected": STAGE_IDS[cur + 1] if cur + 1 < len(STAGE_IDS) else None}
596
+ # 回退:只允许 DesignIteration 发起
597
+ if idx < cur:
598
+ _done = {s.get("stage") for s in plan.get("stages", [])}
599
+ if "DesignIteration" not in _done:
600
+ return {"ok": False,
601
+ "error": ("【非法回退】不允许从【%s】退回【%s】—— "
602
+ "只有 DesignIteration(自主迭代)阶段可以发起回退。"
603
+ % (STAGE_LABELS[STAGE_IDS[cur]], STAGE_LABELS[stage]))}
604
+
605
+ plan["stage_index"] = max(cur, idx)
606
+ rec = {"stage": stage, "label": STAGE_LABELS[stage], "needs": STAGE_NEEDS[stage],
607
+ "status": "in_progress", "note": note,
608
+ "at": time.strftime("%Y-%m-%d %H:%M:%S"), "ts": time.time()}
609
+ # 同一阶段重复进入 → 追加一条(保留迭代痕迹),不覆盖历史
610
+ plan.setdefault("stages", []).append(rec)
611
+ save_plan(plan, work_dir)
612
+ nxt = STAGE_IDS[idx + 1] if idx + 1 < len(STAGE_IDS) else None
613
+ return {"ok": True, "stage": stage, "label": STAGE_LABELS[stage],
614
+ "needs": STAGE_NEEDS[stage], "stage_index": idx,
615
+ "next_stage": nxt, "next_label": STAGE_LABELS.get(nxt),
616
+ "progress": "%d/%d" % (idx + 1, len(STAGE_IDS))}
617
+
618
+
619
+ def mark_stage_done(stage, artifact="", work_dir=None, actor=None):
620
+ """把某阶段标记为完成,并登记它的产出物(artifact)。
621
+
622
+ 【2026-10-28 重构】只能由【总计子代理】调用(见 begin_stage 注释)。
623
+
624
+ 为什么要登记 artifact:没有产出的"阶段完成"就是走过场。
625
+ 最终交付时会校验关键阶段是否真的留下了产出。
626
+
627
+ ── 【Bug② 修复】重做 / stale 语义(代码强制)─────────────────────────
628
+ ① 只认【最新一条】未完成记录为落点:重做一个早期阶段时,新记录会被
629
+ mark 成 done,旧记录保持原样 —— stage_status 取最新记录,所以重做
630
+ 是"重新开始"而不是"补一条 done 骗过去"。
631
+ ② 若该阶段是 stale(题目已作答、产物需重算),必须交回【新产物】:
632
+ 与过期前的 artifact 完全相同 → 拒绝(否则模型写回旧文本就清掉 stale)。
633
+ ③ 成功后才清 stale(清不掉就说明重做没发生)。
634
+ ④ 阶段产物若依赖【仍未作答】的题目,直接拒收 —— 这正是"产物写在用户
635
+ 答复之前"的根因,必须堵在写入侧。
636
+ """
637
+ _guard = _require_analyst_actor("mark-stage-done", actor, work_dir)
638
+ if _guard:
639
+ return _guard
640
+ if stage not in STAGE_IDS:
641
+ return {"ok": False,
642
+ "error": "未知阶段 %r;合法阶段:%s" % (stage, " → ".join(STAGE_IDS))}
643
+ plan = load_plan(work_dir)
644
+ if plan.get("_load_error"):
645
+ return {"ok": False, "error": plan["_load_error"]}
646
+ hit = None
647
+ for s in reversed(plan.get("stages", [])):
648
+ if s.get("stage") == stage and s.get("status") != "done":
649
+ hit = s
650
+ break
651
+ if hit is None:
652
+ return {"ok": False,
653
+ "error": ("阶段【%s】尚未 begin_stage,无法标记完成。"
654
+ "必须先 begin_stage 再 mark_stage_done。"
655
+ % STAGE_LABELS.get(stage, stage)),
656
+ "hint": "先 begin-stage %s(重做也要重新 begin-stage)再 mark-stage-done。" % stage}
657
+
658
+ # ④ 依赖未答题目 → 拒收(fail-closed)
659
+ _pend = _unanswered_by_stage(plan).get(stage) or []
660
+ if _pend:
661
+ return {
662
+ "ok": False,
663
+ "gate": "STAGE_AWAITING_ANSWER",
664
+ "stage": stage,
665
+ "label": STAGE_LABELS[stage],
666
+ "unanswered": [{"seq": q.get("seq"), "topic": q.get("topic")} for q in _pend],
667
+ "error": ("阶段【%s】的产物依赖尚未作答的题目(%s)—— "
668
+ "禁止在用户答复之前把产物落成最终态。"
669
+ % (STAGE_LABELS[stage],
670
+ "、".join("第%s题" % q.get("seq") for q in _pend))),
671
+ "hint": ("先让用户作答:mode2_flow.py answer-question <seq> \"<答复>\","
672
+ "然后重做该阶段(refresh-stage)再 mark-stage-done。"),
673
+ }
674
+
675
+ # ②③ 重做必须交新产物
676
+ _exp, _src = _redo_expectation(plan, stage)
677
+ if _exp:
678
+ _old = (_exp or {}).get("old_artifact")
679
+ _new = str(artifact or "").strip()
680
+ if not _new:
681
+ return {
682
+ "ok": False, "gate": "STAGE_REDO_ARTIFACT_REQUIRED", "stage": stage,
683
+ "error": ("阶段【%s】的产物已过期/已登记重做,mark-stage-done 必须带 "
684
+ "--artifact 提交【重算后的新产物】(空产物不算重做)。"
685
+ % STAGE_LABELS[stage]),
686
+ "old_artifact": _old,
687
+ "hint": ("python mode2_flow.py mark-stage-done %s "
688
+ "--artifact \"<重算后的产物>\"" % stage),
689
+ }
690
+ if _old and _new == str(_old).strip():
691
+ return {
692
+ "ok": False, "gate": "STAGE_REDO_NOT_REFRESHED", "stage": stage,
693
+ "error": ("阶段【%s】的产物与过期前【完全相同】—— 这不是重做。"
694
+ "用户答复已改变输入,必须真的重算后再提交。"
695
+ % STAGE_LABELS[stage]),
696
+ "old_artifact": _old,
697
+ "reason": (_exp or {}).get("reason"),
698
+ }
699
+
700
+ hit["status"] = "done"
701
+ hit["artifact"] = artifact
702
+ hit["done_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
703
+ hit["artifact_ts"] = time.time()
704
+ _cleared = None
705
+ if _exp:
706
+ hit["redone_at"] = hit["done_at"]
707
+ hit["redone_from_artifact"] = (_exp or {}).get("old_artifact")
708
+ _cleared = _clear_stale(plan, stage, cleared_by="mark_stage_done",
709
+ artifact=artifact)
710
+ _redo_watch(plan).pop(stage, None)
711
+ # 依赖台账同步刷新:新产物 >= 当前已答题目
712
+ plan.setdefault("stage_question_deps", {})[stage] = _stage_question_seqs(plan).get(stage, [])
713
+ save_plan(plan, work_dir)
714
+ out = {"ok": True, "stage": stage, "artifact": artifact}
715
+ if _cleared is not None:
716
+ out["stale_cleared"] = True
717
+ out["redone"] = True
718
+ out["note"] = ("阶段【%s】已按用户答复重做,stale 已清除。" % STAGE_LABELS[stage])
719
+ return out
720
+
721
+
722
+ def stage_status(work_dir=None):
723
+ """返回十阶段的推进情况,含"哪些阶段有产出、哪些是 stale、哪些在等答复"。
724
+
725
+ ── 【Bug② 修复】输出必须能一眼看到:
726
+ · done / artifact:按【最新一条记录】判定(重做后旧 done 不算数);
727
+ · stale:产物因用户作答而过期,必须重做;
728
+ · awaiting_answer:产物依赖未答题目(还没到可交付的状态);
729
+ · blocking:会真正挡住 require_stages_complete 的阶段标签。
730
+ """
731
+ plan = load_plan(work_dir)
732
+ if plan.get("_load_error"):
733
+ return {"ok": False, "error": plan["_load_error"]}
734
+ done = _stage_done_map(plan)
735
+ stale = dict(_stale_map(plan))
736
+ # 存量数据:answer 早于产物落盘的历史计划文件,也要在查询里暴露出来
737
+ # (只读推断,不污染 stale_stages 落盘台账 —— 清除仍必须走真重做)
738
+ _implied = _stale_implied(plan)
739
+ for _sid, _rec in _implied.items():
740
+ if _sid not in stale:
741
+ stale[_sid] = _rec
742
+ pending = _unanswered_by_stage(plan)
743
+ rows = []
744
+ for _id, _lab, _need in STAGES:
745
+ _q = _stage_question_seqs(plan).get(_id, [])
746
+ _st = stale.get(_id)
747
+ _pend = pending.get(_id) or []
748
+ rows.append({
749
+ "stage": _id, "label": _lab, "needs": _need,
750
+ "done": _id in done,
751
+ "artifact": done.get(_id, ""),
752
+ "stale": bool(_st),
753
+ "stale_reason": (_st or {}).get("reason"),
754
+ "stale_questions": [q.get("seq") for q in ((_st or {}).get("questions") or [])],
755
+ "awaiting_answer": [q.get("seq") for q in _pend],
756
+ "questions": _q,
757
+ "blocking": (bool(_st) or bool(_pend) or _id not in done),
758
+ })
759
+ _missing = [r["label"] for r in rows if not r["done"]]
760
+ _stale_labels = [r["label"] for r in rows if r["stale"]]
761
+ _await_labels = [r["label"] for r in rows if r["awaiting_answer"]]
762
+ _blocking = [r["label"] for r in rows if r["blocking"]]
763
+ return {"ok": True, "stage_index": plan.get("stage_index", -1),
764
+ "total": len(STAGES), "done_count": len(done),
765
+ "rows": rows, "missing": _missing,
766
+ "stale": _stale_labels, "stale_count": len(_stale_labels),
767
+ "awaiting_answer": _await_labels,
768
+ "blocking": _blocking,
769
+ "complete": not _blocking,
770
+ "counted_done": not _missing,
771
+ "note": ("stale 阶段视为未完成(fail-closed):用户答复改变了输入,"
772
+ "必须 refresh-stage 重做后才能继续。") if _stale_labels else None}
773
+
774
+
775
+ # ═══════════════════════════════════════════════════════════════════════════
776
+ # 六、★ 按需提问(取消"一股脑全问")
777
+ # ═══════════════════════════════════════════════════════════════════════════
778
+ def record_question(topic, stage, reason, work_dir=None, actor=None):
779
+ """登记一道"按需提问"。**这是"AI 思考到那里才问"的机器判据。**
780
+
781
+ 强制点(任一不满足即拒绝):
782
+ 1. 必须先 set_depth(否则算不出题数上界);
783
+ 2. 必须属于十阶段之一;
784
+ 3. 该阶段必须已 begin_stage(= 真的分析到那里了);
785
+ 4. 必须写明 reason(= 为什么现在需要这个信息);
786
+ 5. 题数不得超过该深度上界。
787
+
788
+ 第 3 条正是"取消一股脑全问"的落实:还没分析到那个阶段,
789
+ 就不允许问那个阶段才会产生的问题 —— 无法再"开头把 50 题全抛出去"。
790
+ """
791
+ _guard = _require_analyst_actor("ask", actor, work_dir)
792
+ if _guard:
793
+ return _guard
794
+ topic = str(topic or "").strip()
795
+ reason = str(reason or "").strip()
796
+ if not topic:
797
+ return {"ok": False, "error": "必须给出问题主题(topic)—— 空问题不登记。"}
798
+ if not reason:
799
+ return {"ok": False,
800
+ "error": ("必须写明【为什么现在需要这个问题】(reason)。"
801
+ "拒绝「无理由提问」—— 这正是「一股脑全问」的特征。")}
802
+ if stage not in STAGE_IDS:
803
+ return {"ok": False,
804
+ "error": ("问题必须挂在某个分析阶段上;%r 不是合法阶段。"
805
+ "合法阶段:%s" % (stage, " → ".join(STAGE_IDS)))}
806
+
807
+ plan = load_plan(work_dir)
808
+ if plan.get("_load_error"):
809
+ return {"ok": False, "error": plan["_load_error"]}
810
+ depth = plan.get("param_depth")
811
+ if not depth:
812
+ return {"ok": False,
813
+ "error": "尚未确定参数需求强度(强需求/弱需求)—— 请先 set_depth 再提问。"}
814
+
815
+ # ③ 该阶段必须已经开始
816
+ _begun = {s.get("stage") for s in plan.get("stages", [])}
817
+ if stage not in _begun:
818
+ return {"ok": False,
819
+ "error": ("【尚未分析到该阶段】问题挂在【%s】,但该阶段还没 begin_stage。"
820
+ "按需提问要求先分析、再提问;禁止在分析之前就把问题全抛出来。"
821
+ % STAGE_LABELS[stage]),
822
+ "stage_index": plan.get("stage_index", -1),
823
+ "hint": "先 begin_stage %s,分析到确实缺信息时再 record_question。" % stage}
824
+
825
+ # ⑤ 题数上界(过程校验:只卡上界,不卡下界)
826
+ lim = limits_for(depth)
827
+ n_now = len(plan.get("questions", []))
828
+ chk = validate_question_budget(depth, n_now + 1, final=False)
829
+ if not chk["ok"]:
830
+ return dict(chk, blocked_by="question_budget")
831
+
832
+ q = {"seq": n_now + 1, "topic": topic, "stage": stage, "answered": False,
833
+ "stage_label": STAGE_LABELS[stage], "reason": reason,
834
+ "at": time.strftime("%Y-%m-%d %H:%M:%S"), "ts": time.time()}
835
+ plan.setdefault("questions", []).append(q)
836
+ # ── 【Bug② 修复】登记「该阶段产物依赖这道题」───────────────────────
837
+ # 这是"产物依赖未答题目"的落盘判据:answer_question 会据此把该阶段标 stale,
838
+ # mark_stage_done 会据此拒收"用户答复前就定稿"的产物。
839
+ deps = plan.setdefault("stage_question_deps", {})
840
+ _seqs = [int(x) for x in (deps.get(stage) or [])]
841
+ if q["seq"] not in _seqs:
842
+ deps[stage] = sorted(_seqs + [q["seq"]])
843
+ plan["stage_question_deps"] = deps
844
+ save_plan(plan, work_dir)
845
+ _answered_map = {qq.get("seq"): bool(qq.get("answered"))
846
+ for qq in plan.get("questions", [])}
847
+ return {"ok": True, "question": q, "asked": len(plan["questions"]),
848
+ "min": lim["min"], "max": lim["max"],
849
+ "remaining_to_max": max(0, lim["max"] - len(plan["questions"])),
850
+ "stage_question_deps": deps,
851
+ "note": ("已登记按需提问 %d/%d(%s)。"
852
+ "该题答复后,阶段【%s】的产物会被标记为过期(stale),"
853
+ "必须重做。"
854
+ % (len(plan["questions"]), lim["max"], lim["label"],
855
+ STAGE_LABELS[stage])),
856
+ "answered_map": _answered_map}
857
+
858
+
859
+ def answer_question(seq, answer, work_dir=None, actor=None):
860
+ """记录某道已登记问题的【用户真实答复】。
861
+
862
+ 为什么必须有这一步:record_question 只登记"我要问什么",
863
+ 模型完全可以登记完就当问过了(实测把 21 条拼成"汇总答复"直接
864
+ 调 provide_context)。这里强制把答复逐条落到对应题目上,
865
+ 未答的题在收尾校验时会让交付直接失败。
866
+ """
867
+ _guard = _require_analyst_actor("answer-question", actor, work_dir)
868
+ if _guard:
869
+ return _guard
870
+ ans = str(answer or "").strip()
871
+ if not ans:
872
+ return {"ok": False, "error": "答复内容为空 —— 空答复不算问过。"}
873
+ try:
874
+ seq = int(seq)
875
+ except Exception:
876
+ return {"ok": False, "error": "seq 必须是整数题号。"}
877
+ plan = load_plan(work_dir)
878
+ qs = plan.get("questions") or []
879
+ hit = None
880
+ for q in qs:
881
+ if q.get("seq") == seq:
882
+ hit = q
883
+ break
884
+ if hit is None:
885
+ return {"ok": False, "error": "没有第 %d 题(请先 record_question)。" % seq}
886
+ _prev_answer = hit.get("answer")
887
+ hit["answered"] = True
888
+ hit["answer"] = ans
889
+ hit["answered_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
890
+ if _prev_answer is not None and str(_prev_answer) != ans:
891
+ hit["reanswered_count"] = int(hit.get("reanswered_count") or 0) + 1
892
+ plan["questions"] = qs
893
+ _stage = hit.get("stage")
894
+
895
+ # ── 【Bug② 修复】答复落盘即把该阶段产物标为 stale(题目 X 已作答,产物需重算)
896
+ _affected = []
897
+ if _stage in STAGE_LABELS:
898
+ _done_art = _stage_done_map(plan).get(_stage)
899
+ _reason = ("题目 %d(%s)已作答,产物需重算:%s"
900
+ % (seq, hit.get("topic") or "", ans[:60]))
901
+ deps = plan.setdefault("stage_question_deps", {})
902
+ _seqs = [int(x) for x in (deps.get(_stage) or [])]
903
+ if seq not in _seqs:
904
+ deps[_stage] = sorted(_seqs + [seq])
905
+ plan["stage_question_deps"] = deps
906
+ # 只有"该阶段真的产出过东西"才需要过期:
907
+ # · 从没 done 过(只是 begin 了)→ 本来就待做,靠 awaiting_answer 拦截即可;
908
+ # · 已经 stale(refresh 后又改答)→ 保留 stale,不能因刷新而洗白。
909
+ _had_done = any(s.get("status") == "done"
910
+ for s in (plan.get("stages") or [])
911
+ if isinstance(s, dict) and s.get("stage") == _stage)
912
+ if _done_art is not None or _had_done or _stage in _stale_map(plan):
913
+ _stale = _mark_stage_stale(
914
+ plan, _stage,
915
+ [{"seq": seq, "topic": hit.get("topic"), "answer": ans}],
916
+ old_artifact=_done_art, reason=_reason)
917
+ _affected.append({"stage": _stage, "label": STAGE_LABELS[_stage],
918
+ "reason": _stale.get("reason"),
919
+ "old_artifact": _done_art,
920
+ "redo_command": ("python mode2_flow.py refresh-stage %s"
921
+ % _stage)})
922
+ save_plan(plan, work_dir)
923
+ _done = len([q for q in qs if q.get("answered")])
924
+ out = {"ok": True, "seq": seq, "answered": _done, "total": len(qs),
925
+ "note": "已记录第 %d 题答复(%d/%d)。" % (seq, _done, len(qs))}
926
+ if _affected:
927
+ out["stale_stages"] = _affected
928
+ out["stale_marked"] = [a["stage"] for a in _affected]
929
+ out["note"] += (" 阶段【%s】的产物已标为过期(stale),"
930
+ "必须 refresh-stage 重做后才能继续。"
931
+ % "、".join(a["label"] for a in _affected))
932
+ return out
933
+
934
+
935
+ def import_answers(answers, work_dir=None, actor=None, overwrite=False):
936
+ """【BUG-05 修复】批量登记用户答复,一次性写完,避免逐个 CLI 调用。
937
+
938
+ 要解决的实测缺陷(2026-10-30 四工位转台任务):
939
+ 总计子代理收齐 11 题答案后,需要把它们逐条同步进 mode2_flow 的台账。
940
+ 但 answer-question 一次只能答一题 → 它要连续调 11 次 CLI;
941
+ 更糟的是它无法确认到底同步成功了没有,于是陷入
942
+ 反复 ask 同一批题 的循环,最终把上下文烧光(turn/end=max-tokens),
943
+ 编制没能登记,整个任务卡在 select 之前。
944
+
945
+ 用户的建议(原话):提供批量登记问题/答案的入口,
946
+ 避免逐个 CLI 调用消耗上下文。
947
+
948
+ Args:
949
+ answers: [{seq: int, answer: str}, ...] 或 {seq: answer} 映射
950
+ work_dir: 计划目录(默认按任务)
951
+ actor: 调用者(默认取 DSH_SESSION_ID,必须是已绑定的总计子代理)
952
+ overwrite: 已答过的题是否用新答复覆盖(默认 False,只补未答的)
953
+
954
+ Returns:
955
+ {"ok":bool, "imported":[seq...], "skipped":[seq...],
956
+ "failed":[{seq, error}...], "summary":str}
957
+ """
958
+ _guard = _require_analyst_actor("import-answers", actor, work_dir)
959
+ if _guard:
960
+ return _guard
961
+
962
+ # 规整输入:list[dict] 或 dict 都接受
963
+ pairs = []
964
+ if isinstance(answers, dict):
965
+ for k, v in answers.items():
966
+ pairs.append((k, v))
967
+ elif isinstance(answers, list):
968
+ for it in answers:
969
+ if isinstance(it, dict):
970
+ pairs.append((it.get("seq"), it.get("answer")))
971
+ if not pairs:
972
+ return {"ok": False,
973
+ "error": "没有可导入的答复。传 [{seq,answer},...] 或 {seq: answer}。",
974
+ "how": "python mode2_flow.py import-answers --json-file answers.json"}
975
+
976
+ imported, skipped, failed = [], [], []
977
+ for seq, ans in pairs:
978
+ try:
979
+ _seq = int(seq)
980
+ except Exception:
981
+ failed.append({"seq": seq, "error": "seq 不是整数"})
982
+ continue
983
+ if not str(ans or "").strip():
984
+ failed.append({"seq": _seq, "error": "答复为空(空答复不算问过)"})
985
+ continue
986
+ plan = load_plan(work_dir)
987
+ hit = next((q for q in (plan.get("questions") or [])
988
+ if q.get("seq") == _seq), None)
989
+ if hit is None:
990
+ failed.append({"seq": _seq, "error": "该题号未登记(先 record_question)"})
991
+ continue
992
+ if hit.get("answered") and not overwrite:
993
+ skipped.append(_seq) # 已答过,默认不覆盖
994
+ continue
995
+ # 复用单题逻辑,保证 stale 标记等副作用完全一致
996
+ r = answer_question(_seq, ans, work_dir, actor)
997
+ if r.get("ok"):
998
+ imported.append(_seq)
999
+ else:
1000
+ failed.append({"seq": _seq, "error": r.get("error") or "失败"})
1001
+
1002
+ rep = question_report(work_dir)
1003
+ return {
1004
+ "ok": not failed,
1005
+ "imported": imported,
1006
+ "skipped": skipped,
1007
+ "failed": failed,
1008
+ "answered": rep.get("answered"),
1009
+ "unanswered": rep.get("unanswered"),
1010
+ "param_depth": rep.get("param_depth"),
1011
+ "depth_source": rep.get("depth_source"),
1012
+ "summary": ("导入 %d 条,跳过 %d 条(已答),失败 %d 条;"
1013
+ "当前已答 %s / 未答 %s"
1014
+ % (len(imported), len(skipped), len(failed),
1015
+ rep.get("answered"), rep.get("unanswered"))),
1016
+ "note": ("一次性写入,避免逐个 answer-question 消耗上下文。"
1017
+ "已答过的题默认跳过;确需改答传 --overwrite。"),
1018
+ }
1019
+
1020
+
1021
+ def refresh_stage(stage, work_dir=None, reason="", artifact="", actor=None):
1022
+ """【Bug② 修复】显式重做某阶段:进入重做态,并要求交回【新产物】。
1023
+
1024
+ 为什么需要这个显式入口:
1025
+ stale 只是"标记"。若没有"开始重做"这个动作,模型可以继续说
1026
+ "我已经重做了"却什么都不写。这里把重做变成一次**状态变更**:
1027
+ ① 只接受已做过(或 stale)的阶段 —— 没做过的直接 begin-stage 即可;
1028
+ ② 立刻追加一条 in_progress 记录(最新记录不再是 done → 阶段重新变成未完成);
1029
+ ③ 登记 stale_redo_watch:后续 mark-stage-done 必须带来【新产物】,
1030
+ 与过期前完全相同的产物会被拒(防"写回旧文本就清掉 stale");
1031
+ ④ stale 记录**此时不清除** —— 只有真的重做成功后才清除。
1032
+
1033
+ Args:
1034
+ stage: 阶段 id
1035
+ work_dir: 计划文件目录
1036
+ reason: 为什么要重做(留痕;为空时沿用 stale 原因)
1037
+ artifact: 可选的新产物 —— 给出即等价于 refresh + mark-stage-done 一步完成
1038
+
1039
+ Returns: {"ok","stage","redone","stale_before","old_artifact","questions_to_apply",...}
1040
+ """
1041
+ _guard = _require_analyst_actor("refresh-stage", actor, work_dir)
1042
+ if _guard:
1043
+ return _guard
1044
+ if stage not in STAGE_IDS:
1045
+ return {"ok": False,
1046
+ "error": "未知阶段 %r;合法阶段:%s" % (stage, " → ".join(STAGE_IDS))}
1047
+ plan = load_plan(work_dir)
1048
+ if plan.get("_load_error"):
1049
+ return {"ok": False, "error": plan["_load_error"]}
1050
+ _latest = _stage_latest(plan).get(stage)
1051
+ _stale = _stale_map(plan).get(stage)
1052
+ if _latest is None and _stale is None:
1053
+ return {"ok": False,
1054
+ "error": ("阶段【%s】还没做过,无需 refresh —— 直接 begin-stage "
1055
+ "正常推进即可。" % STAGE_LABELS[stage]),
1056
+ "hint": "python mode2_flow.py begin-stage %s" % stage}
1057
+
1058
+ _reason = (str(reason or "").strip()
1059
+ or (_stale or {}).get("reason") or "手工请求重做该阶段")
1060
+ _old_artifact = (_stale or {}).get("old_artifact")
1061
+ if _old_artifact is None and _latest:
1062
+ _old_artifact = _latest.get("artifact")
1063
+
1064
+ idx = STAGE_IDS.index(stage)
1065
+ plan["stage_index"] = max(int(plan.get("stage_index", -1)), idx)
1066
+ plan.setdefault("stages", []).append({
1067
+ "stage": stage, "label": STAGE_LABELS[stage], "needs": STAGE_NEEDS[stage],
1068
+ "status": "in_progress", "note": "redo: " + _reason,
1069
+ "at": time.strftime("%Y-%m-%d %H:%M:%S"), "ts": time.time(),
1070
+ "redo_of_artifact": _old_artifact,
1071
+ })
1072
+ _redo_watch(plan)[stage] = {
1073
+ "stage": stage,
1074
+ "old_artifact": _old_artifact,
1075
+ "reason": _reason,
1076
+ "questions": ((_stale or {}).get("questions") or []),
1077
+ "started_at": time.strftime("%Y-%m-%d %H:%M:%S"),
1078
+ }
1079
+ save_plan(plan, work_dir)
1080
+
1081
+ out = {"ok": True, "stage": stage, "label": STAGE_LABELS[stage],
1082
+ "redone": True, "stale_before": bool(_stale),
1083
+ "old_artifact": _old_artifact, "reason": _reason,
1084
+ "requires": STAGE_NEEDS[stage],
1085
+ "questions_to_apply": [q for q in ((_stale or {}).get("questions") or [])],
1086
+ "next": ("重算后提交【新】产物:python mode2_flow.py mark-stage-done %s "
1087
+ "--artifact \"<新产物>\"(与旧产物相同会被拒)" % stage)}
1088
+ if str(artifact or "").strip():
1089
+ _mk = mark_stage_done(stage, artifact, work_dir)
1090
+ out["mark"] = _mk
1091
+ out["ok"] = bool(_mk.get("ok"))
1092
+ out["artifact"] = artifact if _mk.get("ok") else None
1093
+ if not _mk.get("ok"):
1094
+ out["error"] = _mk.get("error")
1095
+ out["gate"] = _mk.get("gate")
1096
+ return out
1097
+
1098
+
1099
+ def question_report(work_dir=None, final=False):
1100
+ """汇总提问情况。final=True 时【强制】校验下界是否达到。"""
1101
+ plan = load_plan(work_dir)
1102
+ if plan.get("_load_error"):
1103
+ return {"ok": False, "error": plan["_load_error"]}
1104
+ depth = plan.get("param_depth")
1105
+ # ── 【BUG-02 修复】depth 读不到时先回退 workflow_state,再判错 ─────────
1106
+ # 实测(2026-10-30 转台任务):连续两次调 questions,第一次正常返回
1107
+ # asked=10/answered=6,第二次却返回「尚未确定参数需求强度」。
1108
+ # 根因:refresh-stage / 阶段重做期间 mode2_plan 会被重写,
1109
+ # param_depth 有窗口期读不到;而真正的判定其实在 workflow_state 里。
1110
+ # 修复:plan 里没有时回退到权威 workflow_state 的 param_depth,
1111
+ # 仍读不到才报错;回退会在返回体里带 depth_source 便于排查。
1112
+ _depth_src = "plan"
1113
+ if not depth:
1114
+ try:
1115
+ _st = _read_state_json("workflow_state.json") or {}
1116
+ _d2 = _st.get("param_depth")
1117
+ if _d2:
1118
+ depth = _d2
1119
+ _depth_src = "workflow_state"
1120
+ except Exception:
1121
+ pass
1122
+ if not depth:
1123
+ return {"ok": False,
1124
+ "error": ("尚未确定参数需求强度(强需求/弱需求)—— "
1125
+ "mode2_plan 与 workflow_state 里都没有 param_depth。"),
1126
+ "hint": "先 set_depth(或重跑 provide_context #2)再查询。"}
1127
+ qs = plan.get("questions", [])
1128
+ chk = validate_question_budget(depth, len(qs), final=final)
1129
+ # 登记 ≠ 问过:只有 answered=True 才算真的问到了用户。
1130
+ _answered = [q for q in qs if q.get("answered")]
1131
+ _unanswered = [q for q in qs if not q.get("answered")]
1132
+ if final and _unanswered:
1133
+ chk = dict(chk)
1134
+ chk["ok"] = False
1135
+ chk["error"] = ("有 %d 题只登记、未记录用户答复(等于没问):%s"
1136
+ % (len(_unanswered),
1137
+ "、".join(str(q.get("topic")) for q in _unanswered[:6])))
1138
+ by_stage = {}
1139
+ for q in qs:
1140
+ by_stage.setdefault(q.get("stage_label") or q.get("stage"), []).append(q.get("topic"))
1141
+ out = dict(chk)
1142
+ out["questions"] = qs
1143
+ out["answered"] = len(_answered)
1144
+ out["unanswered"] = len(_unanswered)
1145
+ out["by_stage"] = by_stage
1146
+ # 【BUG-02】说明 param_depth 来自哪里(plan / workflow_state 回退)
1147
+ out["depth_source"] = _depth_src
1148
+ out["param_depth"] = depth
1149
+ return out
1150
+
1151
+
1152
+ SUBPLAN_AUTHORITY = "总计子代理"
1153
+
1154
+ #: 每个子代理(房间)必须被总计子代理说明"为什么需要它"。
1155
+ MIN_SUBAGENTS = 1
1156
+ MAX_SUBAGENTS = 24
1157
+
1158
+
1159
+ # ═══════════════════════════════════════════════════════════════════════════
1160
+ # 七·零、【2026-10-28 重构】总计子代理必须是一个【真实子代理】
1161
+ # ═══════════════════════════════════════════════════════════════════════════
1162
+ # 实测缺陷(会话 session-31c56a28):
1163
+ # 主对话自己在主会话里把十阶段全跑完了,然后调 set-subagent-plan 时
1164
+ # 只写了 --decided-by "总计子代理" 这个【字符串】。门禁只校验字符串,
1165
+ # 于是"总计子代理"从头到尾没有被创建过 —— 用户原话:
1166
+ # 「总计子代理十阶段流水线不是要开总计子代理的吗?你在干嘛」
1167
+ #
1168
+ # 现在(代码强制,不靠提示词):
1169
+ # ① 总计子代理开工第一件事必须跑 analyst-bind:
1170
+ # 它从环境变量 DSH_SESSION_ID 取到【自己】的 sessionId 并落盘;
1171
+ # ② 十阶段命令(begin-stage / mark-stage-done / ask / answer-question /
1172
+ # refresh-stage / set-subagent-plan)一律要求【当前调用者】就是
1173
+ # 被绑定的那个 sessionId;主对话直接调 → ANALYST_ACTOR_REQUIRED;
1174
+ # ③ 未绑定 analyst 时:set-subagent-plan 直接拒绝;
1175
+ # workflow_gate.select 也会拦(见 workflow_gate 的 MODE2_ANALYST_REQUIRED)。
1176
+ ANALYST_ROOM_NAME = "总计子代理"
1177
+ ANALYST_ROOM_TYPE = "analyst"
1178
+ ANALYST_SESSION_ENV = "DSH_SESSION_ID"
1179
+
1180
+
1181
+ def _current_actor(actor=None):
1182
+ """当前调用者身份:显式 --actor 优先,其次宿主注入的 DSH_SESSION_ID。"""
1183
+ v = str(actor or "").strip()
1184
+ if v:
1185
+ return v
1186
+ try:
1187
+ return str(os.environ.get(ANALYST_SESSION_ENV) or "").strip() or None
1188
+ except Exception:
1189
+ return None
1190
+
1191
+
1192
+ def analyst_bind(session_id=None, work_dir=None, force=False):
1193
+ """把【总计子代理】的 sessionId 绑定到当前任务(由它在自己会话内调用)。
1194
+
1195
+ 只在总计子代理内部跑:它会从 DSH_SESSION_ID 读到自己的 id。
1196
+ 重复绑定同一 id → 幂等;绑定到另一个 id 而未 force → 拒绝(防止主对话
1197
+ 抢着绑一个假的)。
1198
+ """
1199
+ sid = str(session_id or "").strip() or _current_actor()
1200
+ if not sid:
1201
+ return {"ok": False,
1202
+ "error": ("无法确定总计子代理的 sessionId:请在本子代理会话内运行,"
1203
+ "或显式传 --session-id。"),
1204
+ "env_checked": ANALYST_SESSION_ENV}
1205
+ # ── 【2026-10-28 重构】只允许【真实子代理】绑定自己 ──────────────────
1206
+ # 否则主对话可以拿自己的 DSH_SESSION_ID 抢先 analyst-bind,
1207
+ # 把"总计子代理"重新退回成一个身份字符串。
1208
+ # 判定:插件注册表存在且非空时,本会话 id 必须是其中一条;
1209
+ # 注册表不存在(测试/老宿主)则放行(不阻塞可测试性)。
1210
+ # 插件登记有 300ms 节流,子代理刚创建时注册表可能还没写入 → 最多重试 6 次
1211
+ # (共约 3 秒)。主对话永远等不到自己的 id 出现,最终仍会失败。
1212
+ _agents = []
1213
+ _registry_seen = False
1214
+ for _attempt in range(6):
1215
+ try:
1216
+ _reg_p = os.path.join(_state_dir(), "platform_agents.json")
1217
+ if os.path.exists(_reg_p):
1218
+ with open(_reg_p, "r", encoding="utf-8-sig") as _rf:
1219
+ _reg = json.load(_rf) or {}
1220
+ _agents = [a for a in (_reg.get("agents") or [])
1221
+ if isinstance(a, dict) and a.get("id")]
1222
+ # 注册表过旧(>10 分钟没刷新)= 上一个会话的残留,不能当权威
1223
+ _upd = _reg.get("updatedAt")
1224
+ if isinstance(_upd, str):
1225
+ try:
1226
+ import datetime as _dt
1227
+ _upd = _dt.datetime.fromisoformat(
1228
+ _upd.replace("Z", "+00:00")).timestamp()
1229
+ except Exception:
1230
+ _upd = None
1231
+ else:
1232
+ try:
1233
+ _upd = float(_upd) if isinstance(_upd, (int, float)) else None
1234
+ except Exception:
1235
+ _upd = None
1236
+ if _upd and _upd > 1e11:
1237
+ _upd = _upd / 1000.0
1238
+ if _upd and (time.time() - _upd) > 600:
1239
+ _agents = []
1240
+ _registry_seen = bool(_agents)
1241
+ except Exception:
1242
+ _agents, _registry_seen = [], False
1243
+ if not _registry_seen:
1244
+ break # 无注册表(测试/老宿主)→ 放行
1245
+ if any(str(a.get("id")) == sid for a in _agents):
1246
+ break # 已在注册表里 → 放行
1247
+ if _attempt < 5:
1248
+ time.sleep(0.5)
1249
+ if _registry_seen and not any(str(a.get("id")) == sid for a in _agents):
1250
+ return {"ok": False,
1251
+ "gate": "ANALYST_BIND_REQUIRES_SUBAGENT",
1252
+ "session_id": sid,
1253
+ "error": ("analyst-bind 只能在【子代理会话】里执行:"
1254
+ "当前 sessionId 不在平台子代理注册表中。"
1255
+ "主对话不能把自己绑成总计子代理 —— "
1256
+ "请先用 subagent_fork 创建总计子代理,"
1257
+ "由它自己运行本命令。"),
1258
+ "known_subagents": [{"id": a.get("id"), "label": a.get("label")}
1259
+ for a in _agents]}
1260
+ plan = load_plan(work_dir)
1261
+ if plan.get("_load_error"):
1262
+ return {"ok": False, "error": plan["_load_error"]}
1263
+ old = plan.get("analyst") or {}
1264
+ _old_sid = str(old.get("session_id") or "").strip()
1265
+ # ── 【2026-10-29 G05】旧 analyst 已失联时,允许【免 --force】接管 ──────
1266
+ # 实测:第一任总计子代理跑完十阶段 + 13 题,但在 set-subagent-plan 前
1267
+ # 因 ask_user 阻塞超时中断;续跑子代理必须 --force 才能绑,否则
1268
+ # ANALYST_ALREADY_BOUND —— 分析成果明明在盘上,却被"身份"挡住。
1269
+ # 判据(全部满足才自动放行,其余仍要求 --force):
1270
+ # ① 旧 sessionId 不在当前平台注册表里(已 disposed / 崩溃丢失);
1271
+ # ② 十阶段已全部完成(分析成果在盘上,接管不会覆盖半成品)。
1272
+ # ── 【2026-10-29 修复】旧 analyst 还活着 → 拒绝抢身份 ────────────────
1273
+ # 会话 ceac3fff 事故:三个总计子代理互相 analyst-bind --force 抢了 9 次,
1274
+ # 每个都以为自己是唯一的总计子代理,各自提问 → 用户看到重复选项。
1275
+ # 现在:旧 analyst 心跳/平台证据显示仍存活时,改绑一律拒绝(除非 --force)。
1276
+ if _old_sid and _old_sid != sid and not force:
1277
+ _live_old = analyst_liveness(work_dir)
1278
+ if _live_old.get("alive") and str(_live_old.get("session_id") or "") == _old_sid:
1279
+ return {"ok": False,
1280
+ "gate": "ANALYST_STILL_ALIVE",
1281
+ "bound_session_id": _old_sid,
1282
+ "session_id": sid,
1283
+ "liveness": _live_old,
1284
+ "error": ("【拒绝抢身份】已绑定的总计子代理 %s 仍在运行(%s)。"
1285
+ % (_old_sid, _live_old.get("detail"))),
1286
+ "why": ("2026-10-29 实测:多个总计子代理同时 analyst-bind --force "
1287
+ "互相覆盖身份,导致三方并行提问、用户看到重复选项。"),
1288
+ "next": ("不要抢。若你是被误开的重复子代理 → 立刻结束回合(不要"
1289
+ "再调任何阶段命令);确需接管 → 父代理先确认旧会话已结束。")}
1290
+
1291
+ _auto_rebind = False
1292
+ _auto_rebind_why = None
1293
+ if _old_sid and _old_sid != sid and not force:
1294
+ try:
1295
+ _reg_agents = []
1296
+ _regp = os.path.join(_state_dir(), "platform_agents.json")
1297
+ if os.path.exists(_regp):
1298
+ with open(_regp, "r", encoding="utf-8-sig") as _rf2:
1299
+ _reg2 = json.load(_rf2) or {}
1300
+ _reg_agents = [a for a in (_reg2.get("agents") or [])
1301
+ if isinstance(a, dict) and a.get("id")]
1302
+ _old_still_alive = any(
1303
+ str(a.get("id")) == _old_sid
1304
+ and str(a.get("status") or "").lower() != "disposed"
1305
+ for a in _reg_agents)
1306
+ _all_done = bool(stage_status(work_dir).get("complete"))
1307
+ if _reg_agents and not _old_still_alive and _all_done:
1308
+ _auto_rebind = True
1309
+ _auto_rebind_why = (
1310
+ "旧总计子代理 %s 已不在平台注册表(disposed/失联),"
1311
+ "且十阶段已全部完成 —— 自动允许新子代理接管身份,无需 --force。"
1312
+ % _old_sid)
1313
+ except Exception:
1314
+ _auto_rebind = False
1315
+ if _old_sid and _old_sid != sid and not force and not _auto_rebind:
1316
+ return {"ok": False, "gate": "ANALYST_ALREADY_BOUND",
1317
+ "session_id": _old_sid,
1318
+ "error": ("本任务的总计子代理已绑定为 %s,拒绝改绑到 %s。"
1319
+ "确需改绑(例如上一个子代理确实挂了)请加 --force。"
1320
+ % (_old_sid, sid)),
1321
+ "how": ("若旧子代理确实已失联,加 --force;"
1322
+ "现在也支持'旧 analyst 已 disposed + 十阶段已完成'时自动接管。"),
1323
+ "bound_at": old.get("bound_at")}
1324
+ if _auto_rebind and not force:
1325
+ force = True # 走下面的统一改绑分支,并在返回里说明原因
1326
+ if not _old_sid or force:
1327
+ plan["analyst"] = {
1328
+ "session_id": sid,
1329
+ "room_name": ANALYST_ROOM_NAME,
1330
+ "type": ANALYST_ROOM_TYPE,
1331
+ "bound_at": time.strftime("%Y-%m-%d %H:%M:%S"),
1332
+ "ts": time.time(),
1333
+ "bound_by": "analyst-bind",
1334
+ }
1335
+ save_plan(plan, work_dir)
1336
+ try:
1337
+ plan = load_plan(work_dir)
1338
+ except Exception:
1339
+ pass
1340
+ _out = {"ok": True, "session_id": sid, "room_name": ANALYST_ROOM_NAME,
1341
+ "task_id": plan.get("task_id"),
1342
+ "note": ("总计子代理已绑定。现在【只有这个会话】能推进十阶段 / 登记编制;"
1343
+ "主对话直接调这些命令会被 ANALYST_ACTOR_REQUIRED 拒绝。"),
1344
+ "next": "python mode2_flow.py begin-stage TaskUnderstanding"}
1345
+ if _auto_rebind:
1346
+ _out["auto_rebind"] = True
1347
+ _out["auto_rebind_reason"] = _auto_rebind_why
1348
+ _out["note"] = _auto_rebind_why + " " + _out["note"]
1349
+ return _out
1350
+
1351
+
1352
+
1353
+
1354
+ # ═══════════════════════════════════════════════════════════════════════════
1355
+ # 七·零·补、【2026-10-29 修复】总计子代理【存活心跳】—— 防止重复 fork
1356
+ # ═══════════════════════════════════════════════════════════════════════════
1357
+ # 现场事故(会话 ceac3fff,三个总计子代理同时在跑):
1358
+ # 18:20:52 fork #1 (fff32ea0)
1359
+ # 18:21:10 lead 调 wait_agent → noProgress(no-active-peer)
1360
+ # 18:21:22 lead 调 list_agents → 只看到自己 → 判定"子代理已死"
1361
+ # 18:23:24 fork #2 (11de1d27) ← 此时 #1 还在跑
1362
+ # 18:25:40 fork #3 (6bf2ef59) ← 此时 #1 #2 都还在跑
1363
+ # 三个子代理互相 analyst-bind --force 抢身份 9 次,各自 ask_user.py 提问
1364
+ # → 用户看到已经答过的选项反复弹出。
1365
+ #
1366
+ # 根因有两条,缺一不可:
1367
+ # ① 【平台层】subagent_fork 出来的子代理【不是 Team member】:
1368
+ # list_agents 只返回 lead,wait_agent 返回 noProgress(no-active-peer)。
1369
+ # 主对话因此【没有任何手段】判断 fork 出去的子代理是否还活着。
1370
+ # ② 【门禁层】analyst-bind 只记 sessionId,不记"它还活着吗"。
1371
+ # 绑定存在 ≠ 子代理在跑;反过来"绑定存在"也没能阻止再 fork 一个。
1372
+ #
1373
+ # 修复(本段):
1374
+ # · 总计子代理在每个阶段动作时自动刷新心跳(无需模型额外调用);
1375
+ # · analyst-status 回传 alive / heartbeat_age_sec / alive_source;
1376
+ # · 新增 analyst-guard:主对话 fork 之前【必须】先问它,
1377
+ # 活着的 analyst 会被拒绝重复创建(gate=ANALYST_ALREADY_ALIVE);
1378
+ # · analyst-bind 在"已绑定且心跳新鲜"时拒绝改绑(除非 --force)。
1379
+ ANALYST_HEARTBEAT_FILE = "analyst_heartbeat.json"
1380
+ #: 心跳多久没刷新算"可能已死"。留足余量:子代理跑一段长分析不刷新是常态。
1381
+ ANALYST_ALIVE_SEC = 600
1382
+
1383
+
1384
+ def _analyst_hb_path(work_dir=None):
1385
+ """心跳文件路径(与 plan 同目录,跟随当前任务)。"""
1386
+ try:
1387
+ return os.path.join(_plan_dir(work_dir), ANALYST_HEARTBEAT_FILE)
1388
+ except Exception:
1389
+ return os.path.join(BASE_DIR, ANALYST_HEARTBEAT_FILE)
1390
+
1391
+
1392
+ def analyst_heartbeat(session_id=None, work_dir=None, note=""):
1393
+ """总计子代理刷新存活心跳(阶段动作会自动调用,也可手动调)。
1394
+
1395
+ 只认【已绑定的那个 sessionId】—— 别的会话调它会被拒绝,
1396
+ 避免主对话替子代理伪造存活。
1397
+ """
1398
+ sid = str(session_id or "").strip() or _current_actor()
1399
+ if not sid:
1400
+ return {"ok": False, "error": "无法确定 sessionId(不在 DSH 会话内?)"}
1401
+ plan = load_plan(work_dir)
1402
+ bound = str((plan.get("analyst") or {}).get("session_id") or "").strip()
1403
+ if bound and bound != sid:
1404
+ return {"ok": False, "gate": "ANALYST_HEARTBEAT_WRONG_ACTOR",
1405
+ "bound_session_id": bound, "session_id": sid,
1406
+ "error": ("心跳只能由【已绑定的总计子代理】刷新;当前会话 %s "
1407
+ "不是绑定的 %s。" % (sid, bound))}
1408
+ rec = {"session_id": sid, "ts": time.time(),
1409
+ "at": time.strftime("%Y-%m-%d %H:%M:%S"), "note": str(note or "")}
1410
+ p = _analyst_hb_path(work_dir)
1411
+ try:
1412
+ _atomic_write_json(p, rec)
1413
+ except Exception as e:
1414
+ return {"ok": False, "error": "写心跳失败: %r" % (e,)}
1415
+ return {"ok": True, "session_id": sid, "path": p,
1416
+ "ts": rec["ts"], "alive_sec": ANALYST_ALIVE_SEC}
1417
+
1418
+
1419
+ def _read_analyst_hb(work_dir=None):
1420
+ try:
1421
+ p = _analyst_hb_path(work_dir)
1422
+ if not os.path.exists(p):
1423
+ return {}
1424
+ with open(p, "r", encoding="utf-8-sig") as f:
1425
+ d = json.load(f)
1426
+ return d if isinstance(d, dict) else {}
1427
+ except Exception:
1428
+ return {}
1429
+
1430
+
1431
+ def analyst_liveness(work_dir=None):
1432
+ """判断【已绑定的总计子代理】现在是否还活着。
1433
+
1434
+ 判据(从严到宽):
1435
+ ① 心跳新鲜(< ANALYST_ALIVE_SEC)—— 最可靠,阶段动作会自动刷新;
1436
+ ② 平台注册表里该 sessionId 仍是 running(插件自动登记);
1437
+ ③ 都拿不到 → alive=False 但 source="unknown"(**不是**"确认已死")。
1438
+ """
1439
+ plan = load_plan(work_dir)
1440
+ sid = str((plan.get("analyst") or {}).get("session_id") or "").strip()
1441
+ out = {"bound": bool(sid), "session_id": sid or None, "alive": False,
1442
+ "age_sec": None, "source": "none", "detail": None}
1443
+ if not sid:
1444
+ out["detail"] = "尚未绑定总计子代理"
1445
+ return out
1446
+ hb = _read_analyst_hb(work_dir)
1447
+ _hb_ok = False
1448
+ if str(hb.get("session_id") or "") == sid:
1449
+ try:
1450
+ _age = time.time() - float(hb.get("ts") or 0)
1451
+ except Exception:
1452
+ _age = None
1453
+ out["age_sec"] = round(_age, 1) if _age is not None else None
1454
+ if _age is not None and _age <= ANALYST_ALIVE_SEC:
1455
+ _hb_ok = True
1456
+
1457
+ # ── 【2026-10-29 修复】心跳不能覆盖"平台已 disposed" ────────────────
1458
+ # 坑:子代理被 disposed 之后,心跳文件里还留着它最后写的 ts(很新),
1459
+ # 若只看心跳就会判"还活着" → 新子代理永远接不了班(ANALYST_STILL_ALIVE)。
1460
+ # 判据优先级:**平台 disposed 是终态**,心跳只能证明"曾经活过"。
1461
+ _plat_disposed = False
1462
+ try:
1463
+ _regp0 = os.path.join(_state_dir(), "platform_agents.json")
1464
+ if os.path.exists(_regp0):
1465
+ with open(_regp0, "r", encoding="utf-8-sig") as _rf0:
1466
+ _reg0 = json.load(_rf0) or {}
1467
+ for _a0 in (_reg0.get("agents") or []):
1468
+ if (isinstance(_a0, dict)
1469
+ and str(_a0.get("id") or "") == sid
1470
+ and str(_a0.get("status") or "").lower() == "disposed"):
1471
+ _plat_disposed = True
1472
+ break
1473
+ except Exception:
1474
+ pass
1475
+ if _plat_disposed:
1476
+ out["alive"] = False
1477
+ out["source"] = "platform_disposed"
1478
+ out["detail"] = ("平台已 disposed(终态)→ 判定【已结束】,"
1479
+ "心跳仅是历史残留(%ss 前)"
1480
+ % (round(out["age_sec"]) if out["age_sec"] else "?"))
1481
+ return out
1482
+
1483
+ if _hb_ok:
1484
+ out["alive"] = True
1485
+ out["source"] = "heartbeat"
1486
+ out["detail"] = ("心跳 %ss 前刷新(阈值 %ss)→ 判定【活着】"
1487
+ % (round(out["age_sec"]), ANALYST_ALIVE_SEC))
1488
+ return out
1489
+ # ② 平台注册表 + 主进程脉冲(2026-10-29:这是 99% 可靠的判据)
1490
+ # pulse 由【DSH 主进程的 setInterval】写,与子代理在干什么无关:
1491
+ # 子代理跑 SW / 等锁 / 发呆 → 脉冲照跳;
1492
+ # agent 被 disposed → 从脉冲台账移除;
1493
+ # DSH 进程没了 → 脉冲停(此时所有子代理也都没了,判死正确)。
1494
+ try:
1495
+ _regp = os.path.join(_state_dir(), "platform_agents.json")
1496
+ if os.path.exists(_regp):
1497
+ with open(_regp, "r", encoding="utf-8-sig") as _rf:
1498
+ _reg = json.load(_rf) or {}
1499
+ # 先看脉冲还活着吗
1500
+ _pulse = _reg.get("pulse") if isinstance(_reg.get("pulse"), dict) else {}
1501
+ _pulse_ok = False
1502
+ _pulse_age = None
1503
+ if _pulse:
1504
+ _pts = _pulse.get("atMs") or _pulse.get("at")
1505
+ try:
1506
+ if isinstance(_pts, str):
1507
+ import datetime as _dt
1508
+ _psec = _dt.datetime.fromisoformat(
1509
+ _pts.replace("Z", "+00:00")).timestamp()
1510
+ else:
1511
+ _psec = float(_pts) / 1000.0 if float(_pts) > 1e11 else float(_pts)
1512
+ _pulse_age = time.time() - _psec
1513
+ _pulse_ok = _pulse_age <= 35
1514
+ except Exception:
1515
+ _pulse_ok = False
1516
+ for _a in (_reg.get("agents") or []):
1517
+ if not isinstance(_a, dict):
1518
+ continue
1519
+ if str(_a.get("id") or "") != sid:
1520
+ continue
1521
+ _st = str(_a.get("status") or "").lower()
1522
+ if _st == "disposed":
1523
+ out["source"] = "platform_registry"
1524
+ out["detail"] = "平台已 disposed → 判定【已结束】"
1525
+ return out
1526
+ if _pulse_ok:
1527
+ out["alive"] = True
1528
+ out["source"] = "pulse+registry"
1529
+ out["detail"] = ("主进程脉冲 %ss 前跳动 + agent 在册(status=%s) "
1530
+ "→ 判定【活着】(与子代理忙不忙无关)"
1531
+ % (round(_pulse_age or 0), _st or "running"))
1532
+ return out
1533
+ # agent 在册但脉冲停了 → 进程可能没了,判"未知"而非"活着"
1534
+ out["source"] = "registry_only"
1535
+ out["detail"] = ("agent 在册但主进程脉冲已停(%ss 未更新)—— "
1536
+ "【未知】:DSH 进程可能已退出。"
1537
+ % (round(_pulse_age) if _pulse_age else "?"))
1538
+ return out
1539
+ except Exception:
1540
+ pass
1541
+ out["source"] = "unknown"
1542
+ if out["age_sec"] is not None:
1543
+ out["detail"] = ("心跳已 %ss 未刷新(阈值 %ss),且平台注册表无该 id —— "
1544
+ "【未知】,不等于确认死亡。"
1545
+ % (round(out["age_sec"]), ANALYST_ALIVE_SEC))
1546
+ else:
1547
+ out["detail"] = ("既无心跳也无平台登记 —— 【未知】。旧版本/刚绑定属正常;"
1548
+ "建议先 analyst-guard 再决定是否重开。")
1549
+ return out
1550
+
1551
+
1552
+ def analyst_guard(work_dir=None):
1553
+ """【fork 前置闸门】主对话在 subagent_fork 之前必须先问它。
1554
+
1555
+ 活着的总计子代理 → 拒绝重复创建(ceac3fff 事故的直接修复点)。
1556
+ """
1557
+ live = analyst_liveness(work_dir)
1558
+ if not live.get("bound"):
1559
+ return {"ok": True, "may_fork": True,
1560
+ "reason": "尚未绑定,可以创建总计子代理",
1561
+ "liveness": live,
1562
+ "next": "subagent_fork(description=总计子代理(十阶段分析))"}
1563
+ if live.get("alive"):
1564
+ return {
1565
+ "ok": False,
1566
+ "gate": "ANALYST_ALREADY_ALIVE",
1567
+ "may_fork": False,
1568
+ "liveness": live,
1569
+ "error": ("【禁止重复创建】总计子代理仍在运行(session %s,%s)。"
1570
+ % (live.get("session_id"), live.get("detail"))),
1571
+ "why": ("2026-10-29 实测事故:主对话因 list_agents 看不到 fork 的子代理,",
1572
+ "误判已死并连续 fork 了 3 个,导致三方抢身份、题目重复弹出。"),
1573
+ "next": ("不要 fork。改用:① 等它(sleep 后重查 analyst-status);",
1574
+ "② 需要催它 → send_message 给它;",
1575
+ "③ 只有 analyst-status 连续 2 次显示 source=platform_registry",
1576
+ " 且 status=disposed,才允许 analyst-bind --force 后重开。"),
1577
+ }
1578
+ return {
1579
+ "ok": True,
1580
+ "may_fork": True,
1581
+ "liveness": live,
1582
+ "caution": ("绑定存在但存活证据不足(%s)—— 允许重开,但请先确认:"
1583
+ "① 再查一次 analyst-status;② 若十阶段已完成,直接补 "
1584
+ "set-subagent-plan,不必重跑分析。" % (live.get("source"),)),
1585
+ "next": "subagent_fork(description=总计子代理(接管/续跑))",
1586
+ }
1587
+
1588
+ def analyst_status(work_dir=None):
1589
+ """查看总计子代理是否已真实绑定 + 是否还活着(只读)。"""
1590
+ plan = load_plan(work_dir)
1591
+ if plan.get("_load_error"):
1592
+ return {"ok": False, "bound": False, "error": plan["_load_error"]}
1593
+ rec = plan.get("analyst") or {}
1594
+ sid = str(rec.get("session_id") or "").strip()
1595
+ # ── 【2026-10-29 修复】必须回传"还活着吗" ──────────────────────────
1596
+ # 旧实现只说 bound=True/False。而"绑定存在"≠"子代理在跑":
1597
+ # 会话 ceac3fff 里 lead 看到 bound=true 却看不到进程,只能猜;
1598
+ # 一猜错就 fork 出 3 个总计子代理同时抢身份。
1599
+ live = analyst_liveness(work_dir) if sid else {"alive": False,
1600
+ "source": "none",
1601
+ "detail": "未绑定"}
1602
+ return {
1603
+ "ok": True,
1604
+ "bound": bool(sid),
1605
+ "session_id": sid or None,
1606
+ "room_name": ANALYST_ROOM_NAME,
1607
+ "bound_at": rec.get("bound_at"),
1608
+ # ── 存活三件套(主对话据此判断"该等还是该重开")──
1609
+ "alive": bool(live.get("alive")),
1610
+ "alive_source": live.get("source"),
1611
+ "heartbeat_age_sec": live.get("age_sec"),
1612
+ "alive_sec_threshold": ANALYST_ALIVE_SEC,
1613
+ "liveness_detail": live.get("detail"),
1614
+ "current_actor": _current_actor(),
1615
+ "is_current_actor": bool(sid and _current_actor() == sid),
1616
+ "authority": SUBPLAN_AUTHORITY,
1617
+ "how_to_bind": ("在【总计子代理】里运行:python mode2_flow.py analyst-bind"
1618
+ "(自动取它自己的 DSH_SESSION_ID)"),
1619
+ "how_to_check": ("fork 之前先跑 analyst-guard;不要用 list_agents 判断 "
1620
+ "fork 出来的子代理死活(它看不到)。"),
1621
+ "note": (None if sid else
1622
+ "⚠️ 总计子代理尚未创建/绑定 —— 十阶段与子代理编制都不合法。"),
1623
+ }
1624
+
1625
+
1626
+ def _require_analyst_actor(op, actor=None, work_dir=None):
1627
+ """校验"当前调用者 == 已绑定的总计子代理"。未绑定时返回 None(由调用方决定)。
1628
+
1629
+ 返回错误 dict 或 None。这条把"总计子代理"从字符串变成真实身份。
1630
+
1631
+ ── 【2026-10-29 修复】校验通过时【顺手刷新心跳】────────────────────
1632
+ 这样总计子代理只要在干活(跑阶段/提问/登记编制),心跳就自动新鲜,
1633
+ 主对话随时能 analyst-status 看到 alive=true,不会再误判"已死"。
1634
+ 不需要模型额外记得调任何保活命令。
1635
+ """
1636
+ plan = load_plan(work_dir)
1637
+ rec = plan.get("analyst") or {}
1638
+ sid = str(rec.get("session_id") or "").strip()
1639
+ if not sid:
1640
+ return None
1641
+ cur = _current_actor(actor)
1642
+ if cur and cur == sid:
1643
+ # 身份吻合 = 它正在干活 → 自动保活
1644
+ try:
1645
+ analyst_heartbeat(cur, work_dir, note="auto:%s" % op)
1646
+ except Exception:
1647
+ pass
1648
+ return None
1649
+ return {
1650
+ "ok": False,
1651
+ "gate": "ANALYST_ACTOR_REQUIRED",
1652
+ "op": op,
1653
+ "bound_session_id": sid,
1654
+ "current_actor": cur,
1655
+ "error": ("【越权】%s 只能由【%s】执行(绑定会话 %s),"
1656
+ "当前调用者是 %s。十阶段分析必须发生在总计子代理内部,"
1657
+ "主对话直接调是违规的。"
1658
+ % (op, ANALYST_ROOM_NAME, sid, cur or "(未识别到会话)")),
1659
+ "how": [
1660
+ "1) 主对话用 subagent_fork 创建【总计子代理】;",
1661
+ "2) 在该子代理里运行:python \"<根>\\tools\\mode2_flow.py\" analyst-bind",
1662
+ "3) 之后所有十阶段 / 提问 / 编制命令都在该子代理里执行。",
1663
+ ],
1664
+ }
1665
+
1666
+
1667
+ # ═══════════════════════════════════════════════════════════════════════════
1668
+ # 七·补充、【Bug③ 修复】房间类型规范化 + rooms 快照(三处一致性)
1669
+ # ═══════════════════════════════════════════════════════════════════════════
1670
+ # 缺陷回顾(2026-10-19 实测):
1671
+ # 总计子代理在 set-subagent-plan 里把房间类型写成 drivetrain / sensor /
1672
+ # drawing,门禁只认 transmission / housing / drafting,于是:
1673
+ # · workflow_gate.select 用【新类型】路由波次 → 按 drafting 放行;
1674
+ # · 而 mode2_plan.json 的 rooms 快照从未刷新 → mode_gate.room-status /
1675
+ # defense_gate.check-room 读到旧类型 drivetrain/sensor → require_physics
1676
+ # 被算成 false → 物理/领域凭据被静默豁免(虚假 CRITICAL 也一起放行);
1677
+ # · 更早还有 drawing 导致 CONFIG_MISSING_FINAL_WAVES。
1678
+ #
1679
+ # 现在(代码强制):
1680
+ # ① 类型必须能被规范化到【规范类型表】;规范失败 = 明确报错,不静默放过;
1681
+ # ② set-subagent-plan 落盘时同步写 mode2_plan.json 的 subagent_config.rooms
1682
+ # (与 workflow_state.subagent_config.rooms 同构:[房间名, 类型] 对);
1683
+ # ③ 只读命令 room-types 输出三处 room_type 的一致性自检。
1684
+
1685
+ #: 门禁认可的规范房间类型(与 workflow_gate.WAVE1_TYPES / defense_gate
1686
+ #: .MODELING_ROOM_TYPES / mode_gate 波次路由对齐;不在此表 = 必然漏启动或免校核)。
1687
+ CANONICAL_ROOM_TYPES = (
1688
+ "structural", "transmission", "housing", "support", "spring", "thermal",
1689
+ "corrosion", "assembly", "drafting", "prime",
1690
+ )
1691
+
1692
+ #: 历史遗留 / 同义写法 → 规范类型。
1693
+ #: 这里列出的每条都对应一次真实踩坑(见 DSH插件Bug记录.md Bug③/⑥)。
1694
+ ROOM_TYPE_ALIASES = {
1695
+ # Bug③ 实测:drivetrain 是历史遗留类型,不在波1类型表 → require_physics=false
1696
+ "drivetrain": "transmission",
1697
+ "drive": "transmission",
1698
+ "gear": "transmission",
1699
+ "传动": "transmission",
1700
+ # Bug⑥ 实测:sensor 不在白名单 → 物理/领域凭据被静默豁免
1701
+ "sensor": "housing",
1702
+ "sensors": "housing",
1703
+ "electronics": "housing",
1704
+ "electronic": "housing",
1705
+ "外壳": "housing",
1706
+ "壳体": "housing",
1707
+ # Bug③ 实测:drawing 导致 CONFIG_MISSING_FINAL_WAVES(门禁只认 drafting)
1708
+ "drawing": "drafting",
1709
+ "drawings": "drafting",
1710
+ "dwg": "drafting",
1711
+ "工程图": "drafting",
1712
+ "出图": "drafting",
1713
+ # 其它常见同义写法
1714
+ "structure": "structural",
1715
+ "frame": "structural",
1716
+ "结构": "structural",
1717
+ "机架": "structural",
1718
+ "assembly_validation": "assembly",
1719
+ "assembly-validation": "assembly",
1720
+ "总装": "assembly",
1721
+ "验证": "assembly",
1722
+ "prime_agent": "prime",
1723
+ "总理": "prime",
1724
+ }
1725
+
1726
+
1727
+ def normalize_room_type(raw):
1728
+ """把房间类型规范化到 CANONICAL_ROOM_TYPES。**失败即报错**(不静默兜底)。
1729
+
1730
+ Args:
1731
+ raw: 原始类型字符串(大小写 / 空白 / 连字符 / 同义词都容忍)
1732
+
1733
+ Returns:
1734
+ {"ok": True, "type": <规范类型>, "raw":..., "normalized": bool, "alias":...}
1735
+ 或 {"ok": False, "error": "...", "raw":..., "allowed": [...]}
1736
+
1737
+ 为什么不做"猜一个最接近的"兜底:
1738
+ 静默兜底正是 Bug③/⑥ 的来源 —— drivetrain 被悄悄当成未知类型,
1739
+ 门禁于是 require_physics=false,凭据被静默豁免。宁可报错让人改。
1740
+ """
1741
+ _raw = raw
1742
+ s = str(raw or "").strip().lower().replace("-", "_").replace(" ", "_")
1743
+ if not s:
1744
+ return {"ok": False, "raw": _raw,
1745
+ "error": ("房间类型为空 —— 必须显式给 type(规范类型:%s)。"
1746
+ % "/".join(CANONICAL_ROOM_TYPES)),
1747
+ "allowed": list(CANONICAL_ROOM_TYPES)}
1748
+ if s in CANONICAL_ROOM_TYPES:
1749
+ return {"ok": True, "type": s, "raw": _raw, "normalized": (str(_raw) != s),
1750
+ "alias": (str(_raw) if str(_raw) != s else None)}
1751
+ if s in ROOM_TYPE_ALIASES:
1752
+ canon = ROOM_TYPE_ALIASES[s]
1753
+ return {"ok": True, "type": canon, "raw": _raw, "normalized": True,
1754
+ "alias": str(_raw),
1755
+ "note": ("类型 %r 不在门禁规范表内,已规范化为 %r"
1756
+ "(否则会漏启动或被免校核)。" % (str(_raw), canon))}
1757
+ return {"ok": False, "raw": _raw,
1758
+ "error": ("房间类型 %r 无法规范化到门禁认可的规范类型(%s)。"
1759
+ "未知类型会让波次路由漏启动、或 require_physics 直接豁免 —— "
1760
+ "拒绝登记。请改用规范类型,或先在此处补别名并写回归测试。"
1761
+ % (str(_raw), "/".join(CANONICAL_ROOM_TYPES))),
1762
+ "allowed": list(CANONICAL_ROOM_TYPES),
1763
+ "known_aliases": sorted(ROOM_TYPE_ALIASES)}
1764
+
1765
+
1766
+ def room_type_snapshot(rooms):
1767
+ """把编制里的 rooms 规范化为门禁可直接消费的快照([(name, type), ...])。
1768
+
1769
+ 单独抽出来是为了让 set_subagent_plan / room_types_report 共用同一套
1770
+ 规范化逻辑 —— 两处各写一份正是"口径分裂"的来源。
1771
+ """
1772
+ out, seen = [], set()
1773
+ for r in (rooms or []):
1774
+ if not isinstance(r, dict):
1775
+ continue
1776
+ nm = str(r.get("name") or "").strip()
1777
+ nz = normalize_room_type(r.get("type"))
1778
+ if not nm or not nz.get("ok"):
1779
+ continue
1780
+ if nm in seen:
1781
+ continue
1782
+ seen.add(nm)
1783
+ out.append([nm, nz["type"]])
1784
+ return out
1785
+
1786
+
1787
+ def require_stages_complete(work_dir=None):
1788
+ """【强制点】编制作出 / 开工前,十阶段必须全部完成。
1789
+
1790
+ 为什么必须有这条:
1791
+ set_subagent_plan 原本只校验 decided_by == "总计子代理" 这个**字符串**,
1792
+ 模型只要在参数里写上这几个字就能通过 —— 实测就是这样绕过的:
1793
+ 手写 4 间房、6 间房直接过 select,而 stage-status 还停在开头。
1794
+ 字符串是自证的,分析产物不是。所以这里验的是 stage 的 done 状态。
1795
+
1796
+ Returns: {"ok": True, ...} 或 {"ok": False, "gate": "ANALYSIS_INCOMPLETE", ...}
1797
+ """
1798
+ st = stage_status(work_dir)
1799
+ if not st.get("ok"):
1800
+ return st
1801
+ # ── 【Bug② 修复】stale / 等答复的阶段一律视为【未完成】(fail-closed)──
1802
+ # 原实现只看"有没有 done 记录",于是"产物早就写死在用户答复之前"也能过闸:
1803
+ # 阶段 7/10 里写的还是「底盘 100×100 / PLA」,用户确认的是「150×120 / PETG」。
1804
+ # 现在 stale 阶段(题目已作答、产物需重算)与"还挂着未答题目"的阶段
1805
+ # 都算 blocking —— 必须重做、把用户答复落进产物,才允许登记编制/开工。
1806
+ _missing = list(st.get("missing") or [])
1807
+ _stale = list(st.get("stale") or [])
1808
+ _awaiting = list(st.get("awaiting_answer") or [])
1809
+ _blocking = list(st.get("blocking") or [])
1810
+ if _blocking:
1811
+ _hints = []
1812
+ if _missing:
1813
+ _hints.append("未完成:%s" % "、".join(_missing))
1814
+ if _stale:
1815
+ _hints.append("产物过期(必须重做):%s" % "、".join(_stale))
1816
+ if _awaiting:
1817
+ _hints.append("产物依赖未答题目:%s" % "、".join(_awaiting))
1818
+ _how = []
1819
+ for _lbl in _stale + _awaiting:
1820
+ _sid = next((r["stage"] for r in (st.get("rows") or [])
1821
+ if r.get("label") == _lbl), None)
1822
+ if _sid:
1823
+ _how.append("python mode2_flow.py refresh-stage %s" % _sid)
1824
+ if _missing:
1825
+ _how.append("python mode2_flow.py begin-stage <阶段> → mark-stage-done <阶段>")
1826
+ return {
1827
+ "ok": False,
1828
+ "gate": "ANALYSIS_STALE" if (_stale or _awaiting) else "ANALYSIS_INCOMPLETE",
1829
+ "stage_index": st.get("stage_index"),
1830
+ "done_count": st.get("done_count"),
1831
+ "total": st.get("total"),
1832
+ "missing": _missing,
1833
+ "stale": _stale,
1834
+ "awaiting_answer": _awaiting,
1835
+ "blocking": _blocking,
1836
+ "error": ("十阶段分析尚未达到可交付状态(已完成 %d/%d,"
1837
+ "其中 %d 个阶段产物过期、%d 个阶段在等用户答复),"
1838
+ "禁止登记编制或开工。"
1839
+ % (st.get("done_count") or 0, st.get("total") or 10,
1840
+ len(_stale), len(_awaiting))),
1841
+ "hint": tuple(_hints + ["—— " + h for h in _how]),
1842
+ }
1843
+ return {"ok": True, "done_count": st.get("done_count"),
1844
+ "total": st.get("total")}
1845
+
1846
+
1847
+ def set_subagent_plan(rooms, rationale, decided_by, work_dir=None, actor=None):
1848
+ """由【总计子代理】登记"本轮该开几个子代理、分别做什么"。
1849
+
1850
+ ★ 强制点:`decided_by` 必须是"总计子代理"。
1851
+ 早期实现把房间表写死在代码里(固定 3 个 + 总装 + 出图),
1852
+ 此处改为"必须由总计子代理按分析结果决定"并落盘留痕。
1853
+
1854
+ Args:
1855
+ rooms: [{"name": 房间名, "type": 类型, "parts": [零件名...],
1856
+ "why": 为什么需要这个子代理, "depends_on": [房间名...]}]
1857
+ rationale: 总体分解理由(对应功能分解/机构综合的结论)
1858
+ decided_by: 必须 == "总计子代理"
1859
+
1860
+ Returns: {"ok":bool, ...}
1861
+ """
1862
+ # ── 【2026-10-28 重构】编制必须来自【真实的总计子代理】,不是字符串 ──
1863
+ # 实测缺陷:主对话自己调 set-subagent-plan 并手写 --decided-by "总计子代理",
1864
+ # 门禁只校验字符串 → 总计子代理从未被创建过。
1865
+ _an = analyst_status(work_dir)
1866
+ if not _an.get("bound"):
1867
+ return {"ok": False,
1868
+ "gate": "MODE2_ANALYST_REQUIRED",
1869
+ "error": ("【模式二强制】总计子代理尚未创建/绑定(plan 里没有 analyst.session_id)。"
1870
+ "十阶段与子代理编制必须由【真实运行的总计子代理】完成,"
1871
+ "不接受主对话自己写 --decided-by \"总计子代理\"。") ,
1872
+ "how": [
1873
+ "1) 主对话用 subagent_fork 创建【总计子代理】;",
1874
+ "2) 在它的会话里运行:python \"<根>\\tools\\mode2_flow.py\" analyst-bind",
1875
+ "3) 由它跑十阶段、提问、再执行本命令登记编制。",
1876
+ ],
1877
+ "analyst": _an}
1878
+ _guard = _require_analyst_actor("set-subagent-plan", actor, work_dir)
1879
+ if _guard:
1880
+ return _guard
1881
+ if str(decided_by or "").strip() != SUBPLAN_AUTHORITY:
1882
+ return {"ok": False,
1883
+ "error": ("【越权】子代理编制只能由【%s】决定,收到 decided_by=%r。"
1884
+ "固定房间表已被取消 —— 编制必须来自总计子代理的分析结论。"
1885
+ % (SUBPLAN_AUTHORITY, decided_by))}
1886
+ # ── 强制点 A:编制作出之前,十阶段必须真的走完 ──────────────────────
1887
+ # 只校验 decided_by 字符串是**不够的** —— 模型完全可以自己写上
1888
+ # "总计子代理"却根本没做分析(实测:手写 4 间房、6 间房绕过 select)。
1889
+ # 因此这里验的是**分析产物**:十阶段必须全部 mark_stage_done。
1890
+ _gate = require_stages_complete(work_dir)
1891
+ if not _gate.get("ok"):
1892
+ return _gate
1893
+ # ── 【BUG-3 修复】兼容 {"rooms": [...]} 这层包装 ────────────────────
1894
+ # 现场:用户照直觉写成 {"rooms":[...]} → 报"rooms 必须是非空数组",
1895
+ # 而改写成裸数组后又报缺 type —— 两步都踩坑,很难一次写对。
1896
+ # 处理:识别到"对象里只有 rooms 键"时【自动解包】,并如实回显。
1897
+ _unwrapped = False
1898
+ if isinstance(rooms, dict):
1899
+ for _k in ("rooms", "subagents", "plan"):
1900
+ if isinstance(rooms.get(_k), list):
1901
+ rooms = rooms[_k]
1902
+ _unwrapped = True
1903
+ break
1904
+ if not isinstance(rooms, list) or not rooms:
1905
+ return {"ok": False,
1906
+ "error": ("rooms 必须是非空【JSON 数组】(至少 1 个子代理)。"
1907
+ '注意:直接传数组,不要再包一层 {"rooms": [...]};'
1908
+ "若确实包了,本命令会自动解包,但推荐按示例写。"),
1909
+ "example": [{"name": "结构件", "type": "structural",
1910
+ "parts": ["DSH_车架底板"],
1911
+ "why": "承载整车与电池",
1912
+ "depends_on": []}],
1913
+ "types": ["structural", "transmission", "housing", "support",
1914
+ "spring", "thermal", "corrosion", "assembly",
1915
+ "drafting", "prime"]}
1916
+ if not str(rationale or "").strip():
1917
+ return {"ok": False,
1918
+ "error": ("必须给出 rationale(为什么是这个编制)—— "
1919
+ "没有分解理由的编制无法审计,拒绝登记。")}
1920
+ if not (MIN_SUBAGENTS <= len(rooms) <= MAX_SUBAGENTS):
1921
+ return {"ok": False,
1922
+ "error": ("子代理数量 %d 超出允许范围 [%d,%d]。"
1923
+ % (len(rooms), MIN_SUBAGENTS, MAX_SUBAGENTS))}
1924
+
1925
+ norm, seen = [], set()
1926
+ for i, r in enumerate(rooms):
1927
+ if not isinstance(r, dict):
1928
+ return {"ok": False, "error": "rooms[%d] 不是对象" % i}
1929
+ name = str(r.get("name") or "").strip()
1930
+ if not name:
1931
+ return {"ok": False, "error": "rooms[%d] 缺少 name" % i}
1932
+ if name in seen:
1933
+ return {"ok": False, "error": "rooms 里出现重复房间名: %r" % name}
1934
+ seen.add(name)
1935
+ parts = r.get("parts") or []
1936
+ if not isinstance(parts, list) or not parts:
1937
+ # ── 【BUG-6 修复】总装/出图等非建模房间也要写占位零件名 ────────
1938
+ return {"ok": False,
1939
+ "error": ("子代理【%s】没有列出 parts(该屋负责哪些零件)。"
1940
+ "编制必须写明零件范围,否则无法做归属校验。" % name),
1941
+ "hint": ("即使本屋【不建零件】(总装 / 出图 / 总理),"
1942
+ "也要写至少一个占位名:总装写 DSH_XX装配体,"
1943
+ "出图写 DSH_XX图纸,总理写 全任务零件台账。"
1944
+ "空数组 [] 会被拒绝。"),
1945
+ "example_parts": ["DSH_XX装配体"]}
1946
+ if not str(r.get("why") or "").strip():
1947
+ return {"ok": False,
1948
+ "error": ("子代理【%s】缺少 why(为什么需要它)。"
1949
+ "用户要求每个零件都能追溯到存在的理由,编制同理。" % name)}
1950
+ # ── 【Bug③ 修复】类型必须规范化;规范失败 = 明确报错(不静默放过)──
1951
+ # 实测:drivetrain/sensor/drawing 这些历史类型不在门禁波1类型表里,
1952
+ # 会导致波1漏启动、require_physics=false(凭据被静默豁免)。
1953
+ _nz = normalize_room_type(r.get("type"))
1954
+ if not _nz.get("ok"):
1955
+ # ── 【BUG-4 修复】缺 type 是最常见的第一次踩坑,必须一次说清 ──
1956
+ _raw_t = r.get("type")
1957
+ _miss = (_raw_t is None or not str(_raw_t).strip())
1958
+ _allowed = _nz.get("allowed") or [
1959
+ "structural", "transmission", "housing", "support", "spring",
1960
+ "thermal", "corrosion", "assembly", "drafting", "prime"]
1961
+ _out = {
1962
+ "ok": False,
1963
+ "error": ("子代理【%s】%s:%s"
1964
+ % (name,
1965
+ "缺少 type 字段" if _miss else "的房间类型无法规范化",
1966
+ _nz.get("error"))),
1967
+ "room": name, "raw_type": _raw_t,
1968
+ "allowed": _allowed,
1969
+ "example": {"name": name, "type": "structural",
1970
+ "parts": ["DSH_某零件"],
1971
+ "why": "该屋为什么存在",
1972
+ "depends_on": []},
1973
+ "hint": ("每个房间必须显式写 type(白名单:%s)。"
1974
+ "常见映射:drivetrain→transmission、sensor→housing、"
1975
+ "drawing→drafting;总装用 assembly,出图用 drafting。"
1976
+ "非建模房间也要列 parts(可写装配体名/图纸名作占位)。"
1977
+ % " / ".join(_allowed)),
1978
+ }
1979
+ if _miss:
1980
+ _out["hint"] = ("【最常见原因】忘写 type。请给每个房间补一个:"
1981
+ + " / ".join(_allowed) + "。") + _out["hint"]
1982
+ return _out
1983
+ _item = {
1984
+ "name": name,
1985
+ "type": _nz["type"],
1986
+ "parts": [str(p).strip() for p in parts if str(p).strip()],
1987
+ "why": str(r.get("why") or "").strip(),
1988
+ "depends_on": [str(d).strip() for d in (r.get("depends_on") or []) if str(d).strip()],
1989
+ }
1990
+ if _nz.get("normalized"):
1991
+ _item["type_raw"] = str(r.get("type"))
1992
+ _item["type_normalized"] = True
1993
+ norm.append(_item)
1994
+
1995
+ # ══ 【BUG-07 修复·自动补齐收尾房间】总装 + 出图 ══════════════════════
1996
+ # 实测(2026-10-30 四工位转台任务):
1997
+ # 总计子代理按"功能分解"只登记了 3 个建模房间,没意识到门禁还要求
1998
+ # 【总装与验证】(assembly) 与【工程图输出】(drafting) 两个收尾房间。
1999
+ # 随后 workflow_gate.select 直接返回:
2000
+ # {"gate":"CONFIG_MISSING_FINAL_WAVES","has_assembly":false,
2001
+ # "has_drafting":false,...}
2002
+ # 任务就此卡死,必须回去让总计子代理"再补两个房间"——
2003
+ # 而它可能已经上下文耗尽(同一次任务里就发生了),只能重新 fork。
2004
+ #
2005
+ # 这两个房间不属于"设计决策":它们由【流程本身】决定(任何 Mode 2 任务
2006
+ # 都必须装配 + 出图),和固定的【总理子代理】同一性质。
2007
+ # 让模型去记住这件事,属于把流程约束放在提示词里——正是本项目一直
2008
+ # 在消灭的模式。因此这里【代码级自动补齐】:
2009
+ # · 已有同 type 房间 → 不重复添加(尊重总计子代理自己的命名);
2010
+ # · 没有 → 按标准名补一条,并标注 auto_added 便于溯源;
2011
+ # · 补出来的房间 parts 写占位描述(非建模房间不需要零件清单)。
2012
+ _AUTO_FINAL_ROOMS = (
2013
+ ("总装与验证", "assembly",
2014
+ "全车装配 + 干涉检查 + 整体物理/领域验证(自动补齐)"),
2015
+ ("工程图输出", "drafting",
2016
+ "各零件三视图 + 装配图 + DWG/PDF 导出(自动补齐)"),
2017
+ )
2018
+ _auto_added = []
2019
+ for _nm, _tp, _why in _AUTO_FINAL_ROOMS:
2020
+ if any(r["type"] == _tp for r in norm):
2021
+ continue
2022
+ # 名称冲突(已有同名房间但类型不同)→ 换个后缀,避免依赖/绑定时撞名
2023
+ _use = _nm
2024
+ _exist = {r["name"] for r in norm}
2025
+ _i = 2
2026
+ while _use in _exist:
2027
+ _use = "%s%d" % (_nm, _i)
2028
+ _i += 1
2029
+ norm.append({
2030
+ "name": _use,
2031
+ "type": _tp,
2032
+ "parts": [],
2033
+ "why": _why,
2034
+ "depends_on": [],
2035
+ "auto_added": True,
2036
+ })
2037
+ _auto_added.append({"name": _use, "type": _tp, "why": _why})
2038
+
2039
+ # 依赖必须指向存在的房间(防止总计子代理写出悬空依赖)
2040
+ _names = {r["name"] for r in norm}
2041
+ for r in norm:
2042
+ _bad = [d for d in r["depends_on"] if d not in _names]
2043
+ if _bad:
2044
+ return {"ok": False,
2045
+ "error": ("子代理【%s】依赖了不存在的房间: %s"
2046
+ % (r["name"], "、".join(_bad)))}
2047
+
2048
+ plan = load_plan(work_dir)
2049
+ if plan.get("_load_error"):
2050
+ return {"ok": False, "error": plan["_load_error"]}
2051
+ # task_id 必须来自当前任务本身 —— 编制与任务绑定,
2052
+ # 否则 room-start 会因"编制不属于本任务"被误拦。
2053
+ # task_id 必须来自【权威状态目录】的 workflow_state ——
2054
+ # 不能 import workflow_gate(可能命中另一份 tools,读到另一个任务),
2055
+ # 也不能沿用 plan 里可能残留的旧 task_id(上一轮任务的编制不允许复用)。
2056
+ _tid = current_task_id(work_dir) or plan.get("task_id")
2057
+ plan["subagent_plan"] = {
2058
+ "decided_by": SUBPLAN_AUTHORITY,
2059
+ "task_id": _tid,
2060
+ "rationale": str(rationale).strip(),
2061
+ "count": len(norm),
2062
+ "rooms": norm,
2063
+ "at": time.strftime("%Y-%m-%d %H:%M:%S"),
2064
+ }
2065
+ # ══ 【Bug③ 修复】同步刷新 mode2_plan.json 的 rooms 快照 ══════════════
2066
+ # 原缺陷:set-subagent-plan 只写 subagent_plan,不碰 rooms 快照,
2067
+ # 于是 mode2_plan 里长期留着旧类型(drivetrain/sensor/drawing),
2068
+ # 而 workflow_gate.select 用的是编制里的新类型 →
2069
+ # mode_gate.room-status / defense_gate.check-room 读到旧类型 →
2070
+ # require_physics 被判 false → 物理/领域凭据被静默豁免。
2071
+ # 现在:落盘时把【规范化后的】[(房间名, 类型), ...] 原样写进
2072
+ # subagent_config.rooms,与 workflow_state.subagent_config.rooms 同构。
2073
+ _snap = room_type_snapshot(norm)
2074
+ _norm_changed = [{"room": r["name"], "raw": r.get("type_raw"),
2075
+ "type": r["type"]}
2076
+ for r in norm if r.get("type_normalized")]
2077
+ plan["subagent_config"] = {
2078
+ "rooms": _snap,
2079
+ "total_rooms": len(_snap),
2080
+ "rooms_source": "总计子代理(mode2_flow.set-subagent-plan)",
2081
+ "rooms_rationale": str(rationale).strip(),
2082
+ "room_parts": {r["name"]: r.get("parts") or [] for r in norm},
2083
+ "room_types_normalized": _norm_changed,
2084
+ "synced_at": time.strftime("%Y-%m-%d %H:%M:%S"),
2085
+ "synced_from": "subagent_plan",
2086
+ "task_id": _tid,
2087
+ }
2088
+ save_plan(plan, work_dir)
2089
+ return {"ok": True, "count": len(norm), "rooms": norm,
2090
+ "decided_by": SUBPLAN_AUTHORITY,
2091
+ # 把同步结果回传给调用方(workflow_gate.select 会据此写 workflow_state)
2092
+ "rooms_snapshot": _snap,
2093
+ "room_types_normalized": _norm_changed,
2094
+ # 【BUG-07】自动补齐的收尾房间(null = 无需补齐,编制本来就全)
2095
+ "auto_added_final_rooms": (_auto_added or None),
2096
+ "plan_file": plan_path(work_dir),
2097
+ "note": ("编制已落盘,并已同步 rooms 快照到 plan 文件"
2098
+ "(房间类型已规范化,三处门禁口径一致);"
2099
+ "建模小屋必须严格按此编制创建,数量不得固定。"
2100
+ + (("【BUG-07】已自动补齐收尾房间:%s —— 它们由流程决定,"
2101
+ "不需要总计子代理再登记。"
2102
+ % "、".join(x["name"] for x in _auto_added))
2103
+ if _auto_added else ""))}
2104
+
2105
+
2106
+ def _read_state_json(name):
2107
+ """只读:从状态目录(与门禁同一份)读某个 json;读不到返回 (None, 原因)。
2108
+
2109
+ 刻意【不 import workflow_gate / mode_gate】:
2110
+ 工程模式有"仓库 / 安装副本"两套 tools,import 可能命中另一套,
2111
+ 于是读到另一个任务的状态 —— 这里与 _plan_dir 一样按 _store 权威目录直读。
2112
+ """
2113
+ try:
2114
+ _sd = None
2115
+ try:
2116
+ import _store as _st
2117
+ _sd = _st.state_dir()
2118
+ except Exception:
2119
+ _sd = None
2120
+ if not _sd:
2121
+ _sd = BASE_DIR
2122
+ _p = os.path.join(_sd, name)
2123
+ if not os.path.exists(_p):
2124
+ return None, "文件不存在: %s" % _p
2125
+ with open(_p, "r", encoding="utf-8") as _f:
2126
+ return json.load(_f) or {}, _p
2127
+ except Exception as e:
2128
+ return None, "读取失败: %r" % (e,)
2129
+
2130
+
2131
+ def _rooms_as_pairs(raw):
2132
+ """把任意形态的 rooms 列表统一成 {房间名: 类型}(None 表示没有类型信息)。
2133
+
2134
+ 兼容三种历史写法:
2135
+ · [["结构件","structural"], ...] ← workflow_state 现行写法
2136
+ · [{"name":"结构件","type":"structural"}, ...]
2137
+ · {"结构件": {...}} ← mode_state.rooms(只有房间状态,无类型)
2138
+ """
2139
+ out = {}
2140
+ if isinstance(raw, dict):
2141
+ for k, v in raw.items():
2142
+ _t = None
2143
+ if isinstance(v, dict):
2144
+ _t = v.get("type") or v.get("room_type")
2145
+ out[str(k)] = (str(_t) if _t else None)
2146
+ return out
2147
+ for row in (raw or []):
2148
+ if isinstance(row, (list, tuple)) and len(row) >= 1:
2149
+ out[str(row[0])] = (str(row[1]) if len(row) >= 2 and row[1] else None)
2150
+ elif isinstance(row, dict):
2151
+ _n = row.get("name") or row.get("room")
2152
+ if _n:
2153
+ _t = row.get("type") or row.get("room_type")
2154
+ out[str(_n)] = (str(_t) if _t else None)
2155
+ return out
2156
+
2157
+
2158
+ def sync_rooms_snapshot(rooms, work_dir=None, source="workflow_gate.select",
2159
+ task_id=None):
2160
+ """【Bug③ 修复】把门禁侧的权威房间表回写进 mode2_plan.json 的 rooms 快照。
2161
+
2162
+ 为什么还需要"反向同步":
2163
+ set-subagent-plan 写的是【编制】的快照(不含固定追加的总理子代理);
2164
+ 而 workflow_gate.select 会追加固定的【总理子代理】并可能重建配置。
2165
+ 若只单向同步,plan 快照与 workflow_state 仍会差一个房间 → 三处一致性
2166
+ 永远判不一致(假告警),真不一致反而被淹没。
2167
+ 因此 select 落盘后调用本函数,把【最终生效的】房间表写回 plan 快照。
2168
+
2169
+ Args:
2170
+ rooms: [(房间名, 类型), ...] 或 [{"name","type"}, ...]
2171
+ work_dir: 计划文件目录
2172
+ source: 写入来源(留痕,默认 workflow_gate.select)
2173
+ task_id: 归属任务;给出时同时校正 plan.task_id(避免跨任务污染)
2174
+
2175
+ Returns: {"ok": bool, "rooms": [...], "changes": [...]}
2176
+ """
2177
+ pairs = []
2178
+ seen = set()
2179
+ for row in (rooms or []):
2180
+ if isinstance(row, (list, tuple)) and len(row) >= 1:
2181
+ _n, _t = str(row[0]), (row[1] if len(row) >= 2 else None)
2182
+ elif isinstance(row, dict):
2183
+ _n, _t = str(row.get("name") or ""), row.get("type")
2184
+ else:
2185
+ continue
2186
+ _n = _n.strip()
2187
+ if not _n or _n in seen:
2188
+ continue
2189
+ _nz = normalize_room_type(_t)
2190
+ if not _nz.get("ok"):
2191
+ return {"ok": False, "room": _n, "raw_type": _t,
2192
+ "error": ("房间【%s】的类型 %r 无法规范化:%s"
2193
+ % (_n, _t, _nz.get("error"))),
2194
+ "allowed": _nz.get("allowed")}
2195
+ seen.add(_n)
2196
+ pairs.append([_n, _nz["type"]])
2197
+ if not pairs:
2198
+ return {"ok": False, "error": "rooms 为空 —— 拒绝把空房间表写进快照。"}
2199
+
2200
+ plan = load_plan(work_dir)
2201
+ if plan.get("_load_error"):
2202
+ return {"ok": False, "error": plan["_load_error"]}
2203
+ cfg = plan.get("subagent_config") or {}
2204
+ old = [list(x) for x in (cfg.get("rooms") or []) if isinstance(x, (list, tuple))]
2205
+ changes = []
2206
+ _old_map = {x[0]: (x[1] if len(x) > 1 else None) for x in old}
2207
+ for n, t in pairs:
2208
+ if _old_map.get(n) != t:
2209
+ changes.append({"room": n, "from": _old_map.get(n), "to": t})
2210
+ for n in _old_map:
2211
+ if n not in {x[0] for x in pairs}:
2212
+ changes.append({"room": n, "from": _old_map[n], "to": None,
2213
+ "note": "已从快照移除(不在最终房间表里)"})
2214
+ cfg.update({
2215
+ "rooms": pairs,
2216
+ "total_rooms": len(pairs),
2217
+ "rooms_source": (cfg.get("rooms_source")
2218
+ or "总计子代理(mode2_flow.set-subagent-plan)"),
2219
+ "synced_at": time.strftime("%Y-%m-%d %H:%M:%S"),
2220
+ "synced_from": source,
2221
+ })
2222
+ if task_id:
2223
+ cfg["task_id"] = task_id
2224
+ plan["task_id"] = task_id
2225
+ plan["subagent_config"] = cfg
2226
+ save_plan(plan, work_dir)
2227
+ return {"ok": True, "rooms": pairs, "changes": changes,
2228
+ "path": plan_path(work_dir), "synced_from": source}
2229
+
2230
+
2231
+ def room_types_report(work_dir=None):
2232
+ """【Bug③ 修复】三处 room_type 一致性【只读】自检。
2233
+
2234
+ 背景(实测):同一个房间在三个工具里 room_type 各不相同 ——
2235
+ mode_gate.room-status → drivetrain(读 mode2_plan 的旧快照)
2236
+ defense_gate.check-room → sensor(读 workflow_state)
2237
+ workflow_gate.select → transmission(读本次编制)
2238
+ 结果是传动房按 drivetrain 判 → require_physics=false → 真实 CREDIBLE 问题
2239
+ 被静默豁免。
2240
+
2241
+ 本命令只读、不修数据,把四处来源并列出来 + 判定一致性 + 指出每个房间
2242
+ 规范化后会走哪条门禁(require_physics 是否强制),供调用方定位差异。
2243
+
2244
+ Returns:
2245
+ {"ok": 一致性通过, "consistent": bool, "sources": {...},
2246
+ "by_room": [...], "conflicts": [...], "warnings": [...]}
2247
+ """
2248
+ plan = load_plan(work_dir)
2249
+ if plan.get("_load_error"):
2250
+ return {"ok": False, "error": plan["_load_error"], "checked": False}
2251
+
2252
+ sp = plan.get("subagent_plan") or {}
2253
+ src_plan_rooms = _rooms_as_pairs(sp.get("rooms") or []) # 编制本身
2254
+ src_snapshot = _rooms_as_pairs(
2255
+ (plan.get("subagent_config") or {}).get("rooms") or []) # plan 快照
2256
+ _wf, _wf_note = _read_state_json("workflow_state.json")
2257
+ _ms, _ms_note = _read_state_json("mode_state.json")
2258
+ src_wf = _rooms_as_pairs(((_wf or {}).get("subagent_config") or {}).get("rooms") or [])
2259
+ src_ms = _rooms_as_pairs((_ms or {}).get("rooms") or {}) # 无类型,仅房间名
2260
+ # ── 任务隔离:workflow_state 若属于【另一个任务】,它的 rooms 与本编制无关 ──
2261
+ # 不隔离的话,上一轮残留的房间会被算进"三处一致性",把正常状态误报成冲突。
2262
+ _wf_task = (_wf or {}).get("task_id")
2263
+ _plan_task = plan.get("task_id")
2264
+ _wf_foreign = bool(_wf_task and _plan_task and str(_wf_task) != str(_plan_task))
2265
+ if _wf_foreign:
2266
+ _wf_note = ("workflow_state 属于另一个任务(%s),本任务为 %s —— "
2267
+ "不参与本次一致性比对。" % (_wf_task, _plan_task))
2268
+ src_wf = {}
2269
+ elif _wf is None and _wf_note:
2270
+ pass
2271
+
2272
+ # 门禁实际会怎么判:defense_gate 读 workflow_state 的 rooms;
2273
+ # 波1 建模类类型(workflow_gate.WAVE1_TYPES / defense_gate.MODELING_ROOM_TYPES)。
2274
+ _wave1 = ("structural", "transmission", "housing", "support", "spring",
2275
+ "thermal", "corrosion")
2276
+ _finite = ("assembly", "drafting", "prime")
2277
+
2278
+ # 参与比对的房间 = 编制 / 快照 / workflow_state 的并集。
2279
+ # mode_state.rooms 是【历史遗留台账】(可能还是上一轮任务的房间),
2280
+ # 只能作为"额外告警"列出,不能算进三处一致性 —— 否则会拿旧任务房间
2281
+ # 去比对本任务的编制,永远假报冲突。
2282
+ names = []
2283
+ for _d in (src_plan_rooms, src_snapshot, src_wf):
2284
+ for _n in _d:
2285
+ if _n not in names:
2286
+ names.append(_n)
2287
+ names.sort()
2288
+ _ms_only = sorted(n for n in src_ms if n not in names)
2289
+
2290
+ by_room, conflicts, warnings = [], [], []
2291
+ for n in names:
2292
+ raw = {k: v.get(n) for k, v in (
2293
+ ("subagent_plan", src_plan_rooms),
2294
+ ("plan_snapshot", src_snapshot),
2295
+ ("workflow_state", src_wf),
2296
+ ("mode_state", src_ms))}
2297
+ # 规范化每个来源
2298
+ norm = {}
2299
+ bad = {}
2300
+ for k, v in raw.items():
2301
+ if v is None:
2302
+ norm[k] = None
2303
+ continue
2304
+ _nz = normalize_room_type(v)
2305
+ if _nz.get("ok"):
2306
+ norm[k] = _nz["type"]
2307
+ else:
2308
+ norm[k] = "<非法:%s>" % v
2309
+ bad[k] = _nz.get("error")
2310
+ _eff = None
2311
+ for k in ("subagent_plan", "plan_snapshot", "workflow_state"):
2312
+ if norm.get(k) and not str(norm[k]).startswith("<非法"):
2313
+ _eff = norm[k]
2314
+ break
2315
+ _distinct = sorted({v for v in norm.values()
2316
+ if v and not str(v).startswith("<非法")})
2317
+ _ok = len(_distinct) <= 1 and not bad
2318
+ row = {"room": n, "raw": raw, "normalized": norm,
2319
+ "effective_type": _eff,
2320
+ "require_physics": bool(_eff in _wave1),
2321
+ "wave": ("wave1" if _eff in _wave1 else
2322
+ ("final" if _eff in _finite else None)),
2323
+ "consistent": _ok}
2324
+ if bad:
2325
+ row["type_errors"] = bad
2326
+ if not _ok:
2327
+ conflicts.append({"room": n, "distinct_types": _distinct,
2328
+ "raw": raw, "normalized": norm})
2329
+ if _eff and _eff not in CANONICAL_ROOM_TYPES:
2330
+ warnings.append("房间【%s】的类型 %r 不是规范类型。" % (n, _eff))
2331
+ by_room.append(row)
2332
+
2333
+ # 快照 vs 编制:这是 Bug③ 的直接判据
2334
+ def _norm_or_none(v):
2335
+ return normalize_room_type(v).get("type") if v else None
2336
+
2337
+ # 快照 vs 编制 —— Bug③ 的直接判据(含 raw,便于一眼看出旧类型)
2338
+ # 例外:固定的总理子代理只存在于快照(select 追加),编制里没有它属正常,
2339
+ # 不算冲突 —— 否则每次都会假告警,把真不一致淹没。
2340
+ _snap_diff, _snap_fixed_only = [], []
2341
+ for n in sorted(set(src_plan_rooms) | set(src_snapshot)):
2342
+ a, b = _norm_or_none(src_plan_rooms.get(n)), _norm_or_none(src_snapshot.get(n))
2343
+ if a == b:
2344
+ continue
2345
+ if a is None and b == PRIME_ROOM_TYPE:
2346
+ _snap_fixed_only.append(n)
2347
+ continue
2348
+ _snap_diff.append({"room": n,
2349
+ "subagent_plan": a,
2350
+ "subagent_plan_raw": src_plan_rooms.get(n),
2351
+ "plan_snapshot": b,
2352
+ "plan_snapshot_raw": src_snapshot.get(n)})
2353
+ if _snap_diff:
2354
+ conflicts.append({"scope": "plan_snapshot_vs_subagent_plan",
2355
+ "detail": _snap_diff,
2356
+ "fix": ("重新执行 set-subagent-plan 同步快照,"
2357
+ "或 workflow_gate.py select 重建配置。")})
2358
+
2359
+ # workflow_state 侧要分两种情况 —— 混在一起会把"还没 select"误报成"不一致":
2360
+ # · 编制里有、workflow_state 还没有 → 正常时序(set-subagent-plan 在 select 之前),
2361
+ # 只做提示,不算冲突;
2362
+ # · 两边都有但类型不同 → 真不一致,报警告(select 后应自动对齐)。
2363
+ _wf_diff, _wf_pending = [], []
2364
+ for n in sorted(set(src_plan_rooms) | set(src_wf)):
2365
+ a, b = _norm_or_none(src_plan_rooms.get(n)), _norm_or_none(src_wf.get(n))
2366
+ if a != b and b is None and a is not None:
2367
+ _wf_pending.append({"room": n, "subagent_plan": a})
2368
+ elif a != b:
2369
+ _wf_diff.append({"room": n, "subagent_plan": a,
2370
+ "subagent_plan_raw": src_plan_rooms.get(n),
2371
+ "workflow_state": b,
2372
+ "workflow_state_raw": src_wf.get(n)})
2373
+ if _ms_only:
2374
+ warnings.append("mode_state.json 里有 %d 个本任务编制里没有的房间(%s)—— "
2375
+ "属历史残留台账,未参与一致性比对;如确认无关可用 "
2376
+ "mode_gate.py rooms-reset 清理。" % (len(_ms_only), "、".join(_ms_only)))
2377
+ if _wf_foreign:
2378
+ warnings.append(_wf_note)
2379
+ if _wf_pending:
2380
+ warnings.append("有 %d 个房间尚未写入 workflow_state.subagent_config.rooms —— "
2381
+ "执行 workflow_gate.py select 后会自动同步。"
2382
+ % len(_wf_pending))
2383
+ if _wf_diff:
2384
+ warnings.append("workflow_state.subagent_config.rooms 与编制不一致(%d 个房间)—— "
2385
+ "请重新执行 workflow_gate.py select 重建配置。"
2386
+ % len(_wf_diff))
2387
+
2388
+ if not sp and not src_snapshot:
2389
+ return {"ok": False, "checked": True,
2390
+ "error": ("尚无子代理编制与 rooms 快照 —— 先由总计子代理登记编制"
2391
+ "(set-subagent-plan 会同步写快照)。"),
2392
+ "sources": {"workflow_state_file": _wf_note,
2393
+ "mode_state_file": _ms_note},
2394
+ "by_room": [], "conflicts": [], "warnings": warnings}
2395
+
2396
+ return {"ok": not conflicts, "checked": True,
2397
+ "consistent": not conflicts,
2398
+ "plan_file": plan_path(work_dir),
2399
+ "sources": {
2400
+ "subagent_plan": src_plan_rooms,
2401
+ "plan_snapshot": src_snapshot,
2402
+ "workflow_state": src_wf,
2403
+ "mode_state_rooms": sorted(src_ms),
2404
+ "workflow_state_file": _wf_note,
2405
+ "mode_state_file": _ms_note,
2406
+ },
2407
+ "snapshot_synced": not _snap_diff,
2408
+ "snapshot_fixed_only_rooms": _snap_fixed_only,
2409
+ "workflow_state_synced": not _wf_diff,
2410
+ "workflow_state_pending": _wf_pending,
2411
+ "by_room": by_room,
2412
+ "conflicts": conflicts,
2413
+ "warnings": warnings,
2414
+ "normalized_types": [{"room": r["name"], "raw": r.get("type_raw"),
2415
+ "type": r["type"]}
2416
+ for r in (sp.get("rooms") or [])
2417
+ if r.get("type_normalized")],
2418
+ "wave1_types": list(_wave1),
2419
+ "note": ("三处 room_type 必须一致:不一致会让 defense_gate 用旧类型"
2420
+ "判 require_physics(drivetrain/sensor 直接被豁免校核)。")}
2421
+
2422
+
2423
+ def get_subagent_plan(work_dir=None):
2424
+ """读回子代理编制,并附带【阶段新鲜度】结论(只读,不改变 ok 语义)。
2425
+
2426
+ ── 【Bug② 修复】为什么这里也要带 stale 信息 ────────────────────────
2427
+ 编制是在"十阶段分析完成"时登记的。但用户【之后】仍可能改答题目 →
2428
+ 阶段产物过期 → 编制所依据的结论已失效。此时若只看 ok=True 就开房间,
2429
+ 等于拿着过期结论去建模。
2430
+ 本函数把 stages_stale / stages_ready / stale_hint 一并给出,让 mode_gate /
2431
+ workflow_gate 的调用方(以及人)一眼看到"编制还成立吗";真正的拦截由
2432
+ require_stages_complete(set-subagent-plan 的强制前置)负责。
2433
+ """
2434
+ plan = load_plan(work_dir)
2435
+ sp = plan.get("subagent_plan")
2436
+ if not sp:
2437
+ return {"ok": False,
2438
+ "error": ("尚无子代理编制 —— 必须先由【总计子代理】调 "
2439
+ "set-subagent-plan 登记,禁止使用固定房间表。")}
2440
+ out = {"ok": True, **sp}
2441
+ try:
2442
+ st = stage_status(work_dir)
2443
+ _stale = st.get("stale") or []
2444
+ _awaiting = st.get("awaiting_answer") or []
2445
+ out.update({
2446
+ "stages_ready": bool(st.get("complete")),
2447
+ "stages_stale": _stale,
2448
+ "stages_awaiting_answer": _awaiting,
2449
+ "stale_hint": (("编制所依据的十阶段产物已过期(%s)—— "
2450
+ "必须 refresh-stage 重做后才能据此开工。"
2451
+ % "、".join(_stale)) if _stale else None),
2452
+ })
2453
+ except Exception as _e:
2454
+ # 阶段信息读不出来时不得假装"没问题"
2455
+ out["stages_ready"] = None
2456
+ out["stages_status_error"] = repr(_e)
2457
+ return out
2458
+
2459
+
2460
+ # ═══════════════════════════════════════════════════════════════════════════
2461
+ # 八、★ 总理子代理:固定存在 + 上报契约
2462
+ # ═══════════════════════════════════════════════════════════════════════════
2463
+ PRIME_ROOM_NAME = "总理子代理"
2464
+ PRIME_ROOM_TYPE = "prime"
2465
+
2466
+ #: 建模小屋向总理子代理上报时【必须】携带的字段。
2467
+ #: 用户原话:「都要给总理指代理发三道防线验证的文件链接,尺寸,名字,
2468
+ #: 上下左右等会直接接触的零件配合的零件。」
2469
+ REQUIRED_REPORT_FIELDS = (
2470
+ "part_name", # 名字
2471
+ "room", # 哪个小屋上报的
2472
+ "file", # 零件文件路径(链接)
2473
+ "dims", # 尺寸
2474
+ "credentials", # 三道防线凭据(材料/物理/领域)
2475
+ "adjacent", # 上下左右等直接接触/配合的零件
2476
+ )
2477
+
2478
+ #: 三道防线凭据的三条线(缺一条即不合格)
2479
+ REQUIRED_CREDENTIAL_KEYS = ("material", "physics", "domain")
2480
+
2481
+
2482
+ def prime_room_definition(work_dir=None):
2483
+ """总理子代理的房间定义(固定存在,不参与"数量由总计子代理决定")。"""
2484
+ return {
2485
+ "name": PRIME_ROOM_NAME,
2486
+ "type": PRIME_ROOM_TYPE,
2487
+ "fixed": True,
2488
+ "why": ("【固定角色】统一规划尺寸、校验相邻零件尺寸一致性、"
2489
+ "汇总全部零件数据为 docx 交付;不一致时升级父代理干涉。"),
2490
+ "duties": [
2491
+ "① 统一规划关键尺寸(给各建模小屋下发同一套尺寸口径)",
2492
+ "② 接收各建模小屋上报:三道防线凭据链接 / 尺寸 / 名称 / 相邻配合零件",
2493
+ "③ 校验相邻零件尺寸是否一致(调 part_registry.check_adjacency_consistency)",
2494
+ "④ 发现不一致 → 标记 escalate_to_parent=true,交父代理做干涉处理",
2495
+ "⑤ 持久维护零件数据台账(part_registry),支持按零件反查关联",
2496
+ "⑥ 全部完成后汇总导出 docx,交主对话",
2497
+ # ── 【2026-10-28 重构】用户要求:"总理子代理要一直盯着其他子代理"──
2498
+ "⑦ 【监工】建模期间每轮读 mode_gate.py room-board --json,盯住每个建模小屋的 state:"
2499
+ "running/waiting_sw/idle 都算活着(严禁判死);unbound 让父代理补 subagent-assign;"
2500
+ "dead 才上报父代理走 recover;done 则核对它的 prime-report 是否齐全。",
2501
+ "⑧ 【收件缺口】用 mode2_flow.py prime-board 看还有哪些零件没上报,"
2502
+ "缺口必须催办,不能等到 room-end 才发现。",
2503
+ ],
2504
+ "spawn_timing": ("与 wave1 全部建模小屋【同一条回复内同时 fork】(不得等建模完成后再开);"
2505
+ "长生命周期,不阻塞波次推进;wave3 出图完成后 room-end。"),
2506
+ "watch_commands": [
2507
+ "python \"<根>\\tools\\mode_gate.py\" room-board --json",
2508
+ "python \"<根>\\tools\\mode2_flow.py\" prime-board --json",
2509
+ ],
2510
+ }
2511
+
2512
+
2513
+ def record_prime_report(report, work_dir=None):
2514
+ """建模小屋 → 总理子代理 的上报登记。**缺字段即拒绝。**
2515
+
2516
+ 这是"三道防线凭据 + 尺寸 + 名字 + 相邻配合零件必须上报"的强制点:
2517
+ 任何一个字段缺失,本函数返回 ok=False,房间无法完成上报闭环。
2518
+ """
2519
+ if not isinstance(report, dict):
2520
+ return {"ok": False, "error": "上报必须是对象"}
2521
+ # ── 【语义修正】必填校验必须区分"字段缺失"与"字段为空值" ──────────────
2522
+ # 坑:`adjacent` 的合法取值【包含空数组】—— 它表示"该零件确实不与任何
2523
+ # 零件接触"。若用 `not report.get(k)` 一把梭,空数组会被当成"缺字段",
2524
+ # 于是"独立件"永远上报不了,流程被误锁死。
2525
+ # 现在:adjacent 只要求"键存在且是 list"(空 list 合法);
2526
+ # 其余字段要求"非空"。
2527
+ missing = []
2528
+ for k in REQUIRED_REPORT_FIELDS:
2529
+ if k == "adjacent":
2530
+ if k not in report:
2531
+ missing.append(k)
2532
+ continue
2533
+ if not report.get(k):
2534
+ missing.append(k)
2535
+ if missing:
2536
+ return {"ok": False,
2537
+ "error": ("【上报不合格】缺少必填字段:%s。"
2538
+ "总理子代理需要:名字 / 尺寸 / 三道防线凭据链接 / "
2539
+ "直接接触配合的零件。" % "、".join(missing)),
2540
+ "required": list(REQUIRED_REPORT_FIELDS),
2541
+ "got": sorted(report.keys())}
2542
+
2543
+ # 三道防线凭据必须三条线齐全(允许值为"未产出",但要显式说明,不能凭空缺)
2544
+ cred = report.get("credentials") or {}
2545
+ if not isinstance(cred, dict):
2546
+ return {"ok": False, "error": "credentials 必须是对象(含 material/physics/domain)"}
2547
+ _miss_cred = [k for k in REQUIRED_CREDENTIAL_KEYS if k not in cred]
2548
+ if _miss_cred:
2549
+ return {"ok": False,
2550
+ "error": ("【三道防线凭据不全】缺少:%s。"
2551
+ "三条线(材料/物理/领域)都必须给出链接或明确标注未产出。"
2552
+ % "、".join(_miss_cred)),
2553
+ "required": list(REQUIRED_CREDENTIAL_KEYS)}
2554
+
2555
+ # 尺寸必须有内容
2556
+ dims = report.get("dims")
2557
+ if not dims:
2558
+ return {"ok": False, "error": "dims(尺寸)为空 —— 上报必须带尺寸。"}
2559
+ if isinstance(dims, dict) and not dims:
2560
+ return {"ok": False, "error": "dims 是空对象 —— 上报必须带具体尺寸。"}
2561
+
2562
+ # 相邻零件必须是数组(可以为空数组,表示"确实不与任何零件接触",
2563
+ # 但要显式给出;缺失已在上面被 missing 拦掉)
2564
+ adj = report.get("adjacent")
2565
+ if not isinstance(adj, list):
2566
+ return {"ok": False, "error": "adjacent 必须是数组(无接触件时写 [])"}
2567
+
2568
+ plan = load_plan(work_dir)
2569
+ if plan.get("_load_error"):
2570
+ return {"ok": False, "error": plan["_load_error"]}
2571
+ rec = {
2572
+ "part_name": str(report.get("part_name")).strip(),
2573
+ "room": str(report.get("room")).strip(),
2574
+ "file": str(report.get("file")).strip(),
2575
+ "dims": dims,
2576
+ "material": report.get("material"),
2577
+ "credentials": {k: cred.get(k) for k in REQUIRED_CREDENTIAL_KEYS},
2578
+ "adjacent": [str(a).strip() for a in adj if str(a).strip()],
2579
+ "interfaces": report.get("interfaces") or [],
2580
+ "at": time.strftime("%Y-%m-%d %H:%M:%S"),
2581
+ "ts": time.time(),
2582
+ }
2583
+ # 同一零件重复上报 → 覆盖(保留最新),保证总理看到的是终态
2584
+ lst = [x for x in plan.get("prime_reports", [])
2585
+ if not (isinstance(x, dict) and x.get("part_name") == rec["part_name"])]
2586
+ lst.append(rec)
2587
+ plan["prime_reports"] = lst
2588
+ save_plan(plan, work_dir)
2589
+ return {"ok": True, "part_name": rec["part_name"],
2590
+ "total_reports": len(lst),
2591
+ "note": "已登记到总理子代理台账。"}
2592
+
2593
+
2594
+ def prime_reports(work_dir=None):
2595
+ plan = load_plan(work_dir)
2596
+ if plan.get("_load_error"):
2597
+ return {"ok": False, "error": plan["_load_error"]}
2598
+ reps = plan.get("prime_reports", [])
2599
+ return {"ok": True, "count": len(reps), "reports": reps}
2600
+
2601
+ def prime_board(work_dir=None):
2602
+ """【2026-10-28 重构】总理子代理的监工看板(只读)。
2603
+
2604
+ 用户要求:"总理子代理要一直盯着第二的其他子代理,去干活"。
2605
+ 本命令把"该盯什么"变成一份可直接判断的清单:
2606
+ · rooms:每个建模小屋的实时 state(来自 mode_gate.room-board,权威)
2607
+ · report_gaps:编制里列了零件、但还没 prime-report 的零件
2608
+ · consistency:最近一次相邻尺寸一致性结论
2609
+ · actions:总理现在就该做的事(催报 / 等锁 / 报死 / 收尾)
2610
+ """
2611
+ plan = load_plan(work_dir)
2612
+ if plan.get("_load_error"):
2613
+ return {"ok": False, "error": plan["_load_error"]}
2614
+ sp = plan.get("subagent_plan") or {}
2615
+ rooms = sp.get("rooms") or []
2616
+ reports = plan.get("prime_reports") or []
2617
+ reported = {str(r.get("part_name") or "").strip() for r in reports}
2618
+ expected = []
2619
+ for r in rooms:
2620
+ for p in (r.get("parts") or []):
2621
+ p = str(p).strip()
2622
+ if p and p not in expected:
2623
+ expected.append(p)
2624
+ gaps = [p for p in expected if p not in reported]
2625
+
2626
+ # 房间实时状态:委托 mode_gate 的权威看板(拿不到就显式降级,不猜)
2627
+ live = None
2628
+ live_error = None
2629
+ try:
2630
+ import mode_gate as _mg
2631
+ if hasattr(_mg, "cmd_room_board"):
2632
+ live = _mg.cmd_room_board(as_json=True)
2633
+ else:
2634
+ live_error = "mode_gate.cmd_room_board 不存在(版本不一致)"
2635
+ except Exception as e:
2636
+ live_error = repr(e)
2637
+
2638
+ actions = []
2639
+ if live and isinstance(live, dict):
2640
+ for room in (live.get("rooms") or []):
2641
+ st = str(room.get("state") or "")
2642
+ nm = room.get("room")
2643
+ if st in ("waiting_sw", "running", "idle", "starting"):
2644
+ continue
2645
+ if st == "unbound":
2646
+ actions.append({"room": nm, "action": "父代理补 subagent-assign",
2647
+ "why": "平台侧存活但未绑定,禁止 reset/restart"})
2648
+ elif st == "dead":
2649
+ actions.append({"room": nm, "action": "上报父代理走 recover/restart",
2650
+ "why": "平台确认已停止且无近期产物"})
2651
+ elif st == "failed":
2652
+ actions.append({"room": nm, "action": "待父代理重开该房间", "why": "房间已 fail"})
2653
+ if gaps:
2654
+ actions.append({"room": "*", "action": "催办未上报零件", "parts": gaps[:20],
2655
+ "why": "编制里有、总理台账没有"})
2656
+ return {
2657
+ "ok": True,
2658
+ "prime_room": PRIME_ROOM_NAME,
2659
+ "rooms": (live or {}).get("rooms") or [],
2660
+ "live_error": live_error,
2661
+ "expected_parts": expected,
2662
+ "reported_parts": sorted(p for p in reported if p),
2663
+ "report_gaps": gaps,
2664
+ "consistency": plan.get("prime_consistency"),
2665
+ "actions": actions,
2666
+ "hint": ("state=waiting_sw/running/idle/starting 都是活着,严禁判死;"
2667
+ "只有 dead 才允许 recover/restart。"),
2668
+ }
2669
+
2670
+
2671
+
2672
+ # ═══════════════════════════════════════════════════════════════════════════
2673
+ # 九、★ 相邻尺寸一致性 → 不一致必须升级父代理
2674
+ # ═══════════════════════════════════════════════════════════════════════════
2675
+ def check_prime_consistency(work_dir=None, registry_work_dir=None):
2676
+ """总理子代理的一致性校验入口。
2677
+
2678
+ 优先委托 part_registry.check_adjacency_consistency(几何/接口级比对),
2679
+ 并把结果**固化到计划文件**,供交付前总闸复查。
2680
+
2681
+ ★ 强制点:只要存在 mismatch,就返回 ok=False 且
2682
+ `escalate_to_parent=True` —— 不允许"发现了不一致还继续往下走"。
2683
+ """
2684
+ out = {"ok": False, "checked_pairs": 0, "mismatches": [],
2685
+ "escalate_to_parent": False, "source": None}
2686
+
2687
+ reg = None
2688
+ try:
2689
+ import part_registry as _pr
2690
+ reg = _pr
2691
+ except Exception as e:
2692
+ out["error"] = ("part_registry 不可用,无法做相邻尺寸一致性校验: %r。"
2693
+ "按 fail-closed 处理,视为【未评估】。" % (e,))
2694
+ out["evaluated"] = False
2695
+ return out
2696
+
2697
+ try:
2698
+ res = reg.check_adjacency_consistency(work_dir=registry_work_dir or work_dir)
2699
+ except TypeError:
2700
+ # 兼容旧签名(无 work_dir 关键字)
2701
+ try:
2702
+ res = reg.check_adjacency_consistency()
2703
+ except Exception as e:
2704
+ out["error"] = "一致性校验抛异常: %r" % (e,)
2705
+ out["evaluated"] = False
2706
+ return out
2707
+ except Exception as e:
2708
+ out["error"] = "一致性校验抛异常: %r" % (e,)
2709
+ out["evaluated"] = False
2710
+ return out
2711
+
2712
+ out["source"] = "part_registry.check_adjacency_consistency"
2713
+ out["checked_pairs"] = res.get("checked_pairs", 0)
2714
+ out["mismatches"] = res.get("mismatches", []) or []
2715
+ out["evaluated"] = bool(res.get("evaluated", True))
2716
+ out["summary"] = res.get("summary")
2717
+
2718
+ if not out["evaluated"]:
2719
+ # 没数据可查 ≠ 通过。保持 ok=False 且不给 escalate(不是"不一致",是"查不了")
2720
+ out["error"] = ("无法评估相邻尺寸一致性(缺少邻接或接口数据)—— "
2721
+ "不得当作通过。请先补齐零件台账/接口信息。")
2722
+ _persist_consistency(out, work_dir)
2723
+ return out
2724
+
2725
+ if out["mismatches"]:
2726
+ out["ok"] = False
2727
+ out["escalate_to_parent"] = True
2728
+ out["error"] = ("【相邻零件尺寸不一致】共 %d 处,必须交父代理做干涉处理,"
2729
+ "不得继续进入总装/交付。" % len(out["mismatches"]))
2730
+ else:
2731
+ out["ok"] = True
2732
+ out["summary"] = out.get("summary") or ("共检查 %d 对相邻零件,尺寸一致。"
2733
+ % out["checked_pairs"])
2734
+ _persist_consistency(out, work_dir)
2735
+ return out
2736
+
2737
+
2738
+ def _persist_consistency(out, work_dir):
2739
+ try:
2740
+ plan = load_plan(work_dir)
2741
+ if plan.get("_load_error"):
2742
+ return
2743
+ plan["prime_consistency"] = dict(out, at=time.strftime("%Y-%m-%d %H:%M:%S"))
2744
+ save_plan(plan, work_dir)
2745
+ except Exception:
2746
+ pass
2747
+
2748
+
2749
+ # ═══════════════════════════════════════════════════════════════════════════
2750
+ # 九·补充、★ 总理子代理【统一核实】闸门(用户新增要求)
2751
+ # ═══════════════════════════════════════════════════════════════════════════
2752
+ # 用户原话:
2753
+ # 「总理子代理要统一核实过数据没有错误了,才能结束」
2754
+ # 「其总理子代理也要核实的相关数据是否一致,必须要保证真实」
2755
+ #
2756
+ # 关键点:**"核实"不能是自我声明**。若只让总理子代理调用一句
2757
+ # `confirm(verified=True)`,那谁都能写 True —— 等于没有核实。
2758
+ #
2759
+ # 因此本闸门【自己从磁盘重算】,不采信任何调用方传入的"我已核实":
2760
+ # ① 真读台账文件(part_registry.json),算出每个零件与其邻接件的尺寸一致性;
2761
+ # ② 用规范化 JSON 的 SHA-256 作为"当时到底核对了哪些数据"的指纹;
2762
+ # ③ 与 part_registry 的 audit_verification 交叉验证 —— 证明数据没被绕过模块手改;
2763
+ # ④ 只要还有 mismatch / open 的 unresolved / 指纹失配 → **拒绝结束**。
2764
+ #
2765
+ # 「才能结束」的落地:pre_delivery_check 会把本闸门作为**硬性前置**,
2766
+ # 未通过则 workflow_gate._archive_finished 拒绝归档(拒绝宣告完成)。
2767
+
2768
+ #: 核实结论的落盘键(写在计划文件里,便于审计)
2769
+ PRIME_VERIFY_KEY = "prime_verification"
2770
+
2771
+
2772
+ def _sha256_json(obj):
2773
+ """稳定的 SHA-256(键排序、紧凑分隔符)——同数据必得同指纹。"""
2774
+ import hashlib
2775
+ try:
2776
+ blob = json.dumps(obj, ensure_ascii=False, sort_keys=True,
2777
+ separators=(",", ":"), default=str)
2778
+ except Exception:
2779
+ blob = repr(obj)
2780
+ return hashlib.sha256(blob.encode("utf-8", "replace")).hexdigest()
2781
+
2782
+
2783
+ def prime_verify_all(work_dir=None, registry_work_dir=None, verified_by=None):
2784
+ """★ 总理子代理统一核实:**从磁盘重算**,不采信自我声明。
2785
+
2786
+ 做四件事(任一不过 → ok=False,禁止结束):
2787
+ 1. 读真实台账,做相邻零件尺寸一致性比对;
2788
+ 2. 检查 unresolved 台账里是否还有 open 的已知不一致;
2789
+ 3. 用 part_registry.audit_verification 验证数据未被绕过模块手改;
2790
+ 4. 用当前磁盘数据算指纹并落盘(供事后重算复现)。
2791
+
2792
+ Args:
2793
+ work_dir: 模式二计划文件目录
2794
+ registry_work_dir: 零件台账目录(默认同 work_dir)
2795
+ verified_by: 核实者身份;默认 PRIME_ROOM_NAME("总理子代理")
2796
+
2797
+ Returns:
2798
+ {"ok","verified","verified_by","verified_at","digest",
2799
+ "parts_count","checked_pairs","mismatches","open_unresolved",
2800
+ "tampered","blockers", ...}
2801
+ """
2802
+ _by = str(verified_by or PRIME_ROOM_NAME).strip() or PRIME_ROOM_NAME
2803
+ out = {
2804
+ "ok": False, "verified": False, "verified_by": _by,
2805
+ "verified_at": time.strftime("%Y-%m-%d %H:%M:%S"),
2806
+ "digest": None, "parts_count": 0, "checked_pairs": 0,
2807
+ "mismatches": [], "open_unresolved": [], "tampered": [],
2808
+ "blockers": [], "enforced_by": "mode2_flow.prime_verify_all",
2809
+ }
2810
+ _reg_wd = registry_work_dir or work_dir
2811
+
2812
+ # ── 载入台账模块(不可用则 fail-closed,不得当作"已核实")────────────
2813
+ reg = None
2814
+ try:
2815
+ import part_registry as _pr
2816
+ reg = _pr
2817
+ except Exception as e:
2818
+ out["blockers"].append(
2819
+ "part_registry 不可用(%r)—— 无法核实数据真实性,按 fail-closed "
2820
+ "视为【未核实】。" % (e,))
2821
+ out["error"] = out["blockers"][0]
2822
+ _persist_verify(out, work_dir)
2823
+ return out
2824
+
2825
+ # ── ① 真读磁盘:零件总数 ─────────────────────────────────────────────
2826
+ parts = {}
2827
+ try:
2828
+ lp = reg.list_parts(work_dir=_reg_wd)
2829
+ if isinstance(lp, dict) and lp.get("ok"):
2830
+ _items = lp.get("parts") or lp.get("items") or []
2831
+ if isinstance(_items, dict):
2832
+ parts = _items
2833
+ else:
2834
+ for _p in _items:
2835
+ if isinstance(_p, dict) and _p.get("name"):
2836
+ parts[_p["name"]] = _p
2837
+ except Exception as e:
2838
+ out["blockers"].append("读取零件台账失败:%r" % (e,))
2839
+ out["parts_count"] = len(parts)
2840
+ if not parts:
2841
+ out["blockers"].append(
2842
+ "零件台账为空 —— 没有任何可核实的数据("
2843
+ "总理子代理必须先收到各小屋上报并落账)。")
2844
+
2845
+ # ── ② 真读磁盘:相邻尺寸一致性 ───────────────────────────────────────
2846
+ try:
2847
+ cons = reg.check_adjacency_consistency(work_dir=_reg_wd)
2848
+ except Exception as e:
2849
+ cons = {"ok": False, "evaluated": False,
2850
+ "error": "一致性校验异常:%r" % (e,)}
2851
+ out["checked_pairs"] = cons.get("checked_pairs", 0)
2852
+ out["mismatches"] = cons.get("mismatches") or []
2853
+ out["consistency_evaluated"] = bool(cons.get("evaluated", True))
2854
+ if out["mismatches"]:
2855
+ out["blockers"].append(
2856
+ "存在 %d 处相邻零件尺寸不一致,必须处理(改一致或用 "
2857
+ "allow_mismatch + 理由显式放行并记入 unresolved):"
2858
+ % len(out["mismatches"]))
2859
+ for _m in out["mismatches"][:5]:
2860
+ _d = _m.get("detail") if isinstance(_m, dict) else str(_m)
2861
+ out["blockers"].append(" · %s" % _d)
2862
+ elif not out["consistency_evaluated"]:
2863
+ # 没数据可查 ≠ 通过(与全仓 fail-closed 口径一致)
2864
+ out["blockers"].append(
2865
+ "相邻尺寸一致性【未评估】(缺邻接或接口数据)—— 不得当作通过。"
2866
+ " 请补齐各零件的 adjacent 与 interfaces(nominal_mm)。")
2867
+
2868
+ # ── ③ unresolved:还有 open 的已知不一致 → 不许结束 ──────────────────
2869
+ try:
2870
+ ul = reg.list_unresolved(work_dir=_reg_wd)
2871
+ if isinstance(ul, dict) and ul.get("ok"):
2872
+ out["open_unresolved"] = ul.get("items") or []
2873
+ except Exception as e:
2874
+ out["blockers"].append("读取 unresolved 台账失败:%r" % (e,))
2875
+ if out["open_unresolved"]:
2876
+ out["blockers"].append(
2877
+ "unresolved 台账仍有 %d 处【未决】的已知不一致 —— "
2878
+ "必须改一致,或显式 resolve-unresolved 并写明理由。"
2879
+ % len(out["open_unresolved"]))
2880
+
2881
+ # ── ④ 防篡改:数据是否被绕过模块手改 ─────────────────────────────────
2882
+ # ⚠️ 字段名以 part_registry 的**实际返回**为准(实测):
2883
+ # parts_with_fingerprint / intact_count / tampered / parts_without_fingerprint,
2884
+ # 而不是早期设计的 checked / intact / orphans。用错字段会导致
2885
+ # "明明有指纹却报没有指纹"的假阴性(实测踩到)。
2886
+ # ⚠️ 特别注意:`orphans` 是 part_registry【函数内部】的局部变量名,
2887
+ # **不是**返回体的键;返回体的对应键是 `parts_without_fingerprint`。
2888
+ # 写 av.get("orphans") 会静默拿到 None(审计脚本帮我抓到了这一处)。
2889
+ try:
2890
+ av = reg.audit_verification(work_dir=_reg_wd)
2891
+ if isinstance(av, dict) and av.get("ok"):
2892
+ out["tampered"] = av.get("tampered") or []
2893
+ out["audit_checked"] = av.get("parts_with_fingerprint", 0)
2894
+ out["audit_intact"] = av.get("intact_count", 0)
2895
+ # 容错读:新键优先,旧/别名键兜底,两者都无则空表(不静默当"没问题")
2896
+ out["audit_orphans"] = (av.get("parts_without_fingerprint")
2897
+ or av.get("records_without_digest")
2898
+ or [])
2899
+ if out["tampered"]:
2900
+ out["blockers"].append(
2901
+ "★ 检出 %d 处数据在核实之后被改动(疑似绕过模块直接编辑 "
2902
+ "JSON)—— 真实性无法保证,必须重新走模块登记。"
2903
+ % len(out["tampered"]))
2904
+ for _t in out["tampered"][:5]:
2905
+ _nm = _t.get("part") if isinstance(_t, dict) else str(_t)
2906
+ out["blockers"].append(
2907
+ " · %s(记录指纹 %s… 现算 %s…)"
2908
+ % (_nm,
2909
+ str((_t or {}).get("digest"))[:10] if isinstance(_t, dict) else "",
2910
+ str((_t or {}).get("digest_now"))[:10] if isinstance(_t, dict) else ""))
2911
+ elif not out["audit_checked"]:
2912
+ out["blockers"].append(
2913
+ "台账里没有任何核实指纹 —— 无法证明数据真实性。"
2914
+ "请通过 part_registry 正常路径登记(会自动留下指纹)。")
2915
+ except Exception as e:
2916
+ out["blockers"].append("审计核实指纹失败:%r" % (e,))
2917
+
2918
+ # ── ⑤ 指纹落盘(可事后重算复现)─────────────────────────────────────
2919
+ _payload = {
2920
+ "parts": parts,
2921
+ "mismatches": out["mismatches"],
2922
+ "open_unresolved": out["open_unresolved"],
2923
+ "registry": getattr(reg, "registry_path", lambda **k: None)(work_dir=_reg_wd)
2924
+ if hasattr(reg, "registry_path") else None,
2925
+ }
2926
+ out["digest"] = _sha256_json(_payload)
2927
+ out["verified"] = not out["blockers"]
2928
+ out["ok"] = out["verified"]
2929
+ if out["verified"]:
2930
+ out["summary"] = (
2931
+ "已核实:%d 个零件、%d 对邻接、无尺寸不一致、无未决记录、"
2932
+ "指纹 %s…(%s)"
2933
+ % (out["parts_count"], out["checked_pairs"], out["digest"][:12], _by))
2934
+ else:
2935
+ out["error"] = ("【总理子代理未通过统一核实】%d 项问题,禁止结束任务。"
2936
+ % len(out["blockers"]))
2937
+ _persist_verify(out, work_dir)
2938
+ return out
2939
+
2940
+
2941
+ def _persist_verify(out, work_dir):
2942
+ """把核实结论固化到计划文件(供交付总闸与事后审计复查)。"""
2943
+ try:
2944
+ plan = load_plan(work_dir)
2945
+ if plan.get("_load_error"):
2946
+ return
2947
+ plan[PRIME_VERIFY_KEY] = dict(out, at=time.strftime("%Y-%m-%d %H:%M:%S"))
2948
+ save_plan(plan, work_dir)
2949
+ except Exception:
2950
+ pass
2951
+
2952
+
2953
+ def prime_verify_status(work_dir=None):
2954
+ """读回最近一次核实结论(不重算)。"""
2955
+ plan = load_plan(work_dir)
2956
+ if plan.get("_load_error"):
2957
+ return {"ok": False, "error": plan["_load_error"]}
2958
+ rec = plan.get(PRIME_VERIFY_KEY)
2959
+ if not rec:
2960
+ return {"ok": False,
2961
+ "error": ("尚无核实记录 —— 总理子代理必须统一核实数据后才能结束"
2962
+ "(调 prime-verify)。")}
2963
+ return {"ok": True, **rec}
2964
+
2965
+
2966
+ # ═══════════════════════════════════════════════════════════════════════════
2967
+ # 十、交付前总闸
2968
+ # ═══════════════════════════════════════════════════════════════════════════
2969
+ def pre_delivery_check(work_dir=None, registry_work_dir=None, require_primes=True):
2970
+ """宣告任务完成之前的**强制总闸**。任一不满足 → ok=False,禁止交付。
2971
+
2972
+ 检查项:
2973
+ ① 十阶段是否都留下产出;
2974
+ ② 提问数是否达到该深度的下界(强 ≥10 / 弱 ≥5);
2975
+ ③ 子代理编制是否存在且由总计子代理决定;
2976
+ ④ 总理子代理是否存在;
2977
+ ⑤ 报告数量是否 ≥ 编制里的零件数(每个零件都要上报);
2978
+ ⑥ 相邻尺寸一致性是否通过(不一致必须已升级处理);
2979
+ ⑦ ★【总理子代理统一核实】是否已通过(用户要求"核实过数据没有错误
2980
+ 了,才能结束")—— 见 prime_verify_all,**从磁盘重算**而非自我声明。
2981
+ """
2982
+ blockers = []
2983
+ plan = load_plan(work_dir)
2984
+ if plan.get("_load_error"):
2985
+ return {"ok": False, "blockers": [plan["_load_error"]], "checked": False}
2986
+
2987
+ st = stage_status(work_dir)
2988
+ if not st.get("complete"):
2989
+ # 【Bug② 修复】不能只说"缺少产出" —— stale 阶段的 missing 是空的,
2990
+ # 那样会给出"十阶段未走完,缺少产出:"这种空信息,排障时无从下手。
2991
+ _why = []
2992
+ if st.get("missing"):
2993
+ _why.append("缺少产出:%s" % "、".join(st.get("missing")))
2994
+ if st.get("stale"):
2995
+ _why.append("产物过期(用户答复后未重算,必须 refresh-stage):%s"
2996
+ % "、".join(st.get("stale")))
2997
+ if st.get("awaiting_answer"):
2998
+ _why.append("产物依赖未答题目:%s" % "、".join(st.get("awaiting_answer")))
2999
+ blockers.append("十阶段未达到可交付状态 —— " +
3000
+ (";".join(_why) if _why else "十阶段未走完"))
3001
+
3002
+ qr = question_report(work_dir, final=True)
3003
+ if not qr.get("ok"):
3004
+ blockers.append(qr.get("error") or "提问数量不达标")
3005
+
3006
+ # ── 【2026-10-28 重构】总计子代理必须是真实的(analyst-bind)──
3007
+ _an = plan.get("analyst") or {}
3008
+ if not str(_an.get("session_id") or "").strip():
3009
+ blockers.append("总计子代理从未真实创建/绑定(analyst-bind 未执行)——\n"
3010
+ "十阶段与子代理编制必须由它完成,主对话自写 --decided-by 不算。")
3011
+
3012
+ sp = plan.get("subagent_plan")
3013
+ if not sp:
3014
+ blockers.append("缺少子代理编制(必须由总计子代理决定,禁止固定房间表)")
3015
+ elif sp.get("decided_by") != SUBPLAN_AUTHORITY:
3016
+ blockers.append("子代理编制不是由【%s】决定的(实际 %r)"
3017
+ % (SUBPLAN_AUTHORITY, sp.get("decided_by")))
3018
+
3019
+ reports = plan.get("prime_reports", [])
3020
+ if require_primes:
3021
+ if not reports:
3022
+ blockers.append("总理子代理没有任何零件上报(三道防线凭据/尺寸/相邻件未上报)")
3023
+ else:
3024
+ # 每个零件都要上报:编制里列出的零件名应能在上报里找到
3025
+ want = set()
3026
+ for r in (sp or {}).get("rooms", []):
3027
+ for p in r.get("parts", []):
3028
+ want.add(p)
3029
+ got = {r.get("part_name") for r in reports}
3030
+ missing = sorted(want - got)
3031
+ if missing:
3032
+ blockers.append("以下零件未向总理子代理上报:%s" % "、".join(missing))
3033
+
3034
+ cons = plan.get("prime_consistency")
3035
+ if cons is None:
3036
+ blockers.append("未做相邻零件尺寸一致性校验(总理子代理必须执行)")
3037
+ elif cons.get("mismatches"):
3038
+ blockers.append("存在未处理的相邻尺寸不一致 %d 处(必须由父代理干涉处理)"
3039
+ % len(cons["mismatches"]))
3040
+ elif not cons.get("evaluated", True):
3041
+ blockers.append("相邻尺寸一致性【未评估】(缺数据),不得当作通过")
3042
+
3043
+ # ── ⑦ ★ 总理子代理统一核实(用户新增要求:"核实过数据没有错误了,才能结束")──
3044
+ # 这里**重算**一遍(不采信之前落盘的结论),确保结束时的数据就是被核实的数据。
3045
+ # 若磁盘数据在核实之后被改过,重算会立刻发现并拒绝结束。
3046
+ _pv = None
3047
+ if require_primes:
3048
+ try:
3049
+ _pv = prime_verify_all(work_dir=work_dir,
3050
+ registry_work_dir=registry_work_dir)
3051
+ except Exception as e:
3052
+ blockers.append("总理子代理统一核实执行异常:%r(按 fail-closed 拒绝结束)"
3053
+ % (e,))
3054
+ if _pv is not None and not _pv.get("ok"):
3055
+ blockers.append("★ 总理子代理【尚未通过统一核实】—— 数据可能有误,禁止结束任务:")
3056
+ for _b in (_pv.get("blockers") or [])[:8]:
3057
+ blockers.append(" · %s" % _b)
3058
+
3059
+ # ── ⑧ ★ 总理子代理必须汇总出 docx 交付物 ─────────────────────────────
3060
+ # 用户原话(需求2):「最终汇总为 docx 等文档在最后结束的时候,再发给主对话」。
3061
+ #
3062
+ # 【为什么补这一条】原先 `part_registry.export_docx` 虽然有实现(三条降级路径
3063
+ # 都验证过),却**没有任何地方强制它被调用** —— 与用户"所有改动都要写在代码里
3064
+ # 强制执行"的要求不符:不导出 docx 也能宣告完成。
3065
+ # 现在:交付前总闸会**核实磁盘上真的存在一份由本任务台账导出的 docx**
3066
+ # (存在 + 非空 + 是合法 zip 且含 word/document.xml),否则拒绝结束。
3067
+ _docx = None
3068
+ if require_primes:
3069
+ _docx = _check_docx_deliverable(work_dir=work_dir,
3070
+ registry_work_dir=registry_work_dir)
3071
+ if not _docx.get("ok"):
3072
+ blockers.append("★ 缺少总理子代理的 docx 汇总交付物 —— 禁止结束任务:")
3073
+ blockers.append(" · %s" % (_docx.get("error") or "未找到 docx"))
3074
+ blockers.append(" · 导出方式:"
3075
+ "python part_registry.py export-docx --out \"<交付目录>/零件台账.docx\""
3076
+ " --work-dir \"<交付目录>\"")
3077
+
3078
+ return {"ok": not blockers, "blockers": blockers, "checked": True,
3079
+ "stages": {"done": st.get("done_count"), "total": st.get("total")},
3080
+ "questions": {"asked": qr.get("asked"), "min": qr.get("min"),
3081
+ "max": qr.get("max")},
3082
+ "subagents": ((sp or {}).get("count")),
3083
+ "prime_reports": len(reports),
3084
+ "prime_verification": (None if _pv is None else {
3085
+ "verified": _pv.get("verified"),
3086
+ "verified_by": _pv.get("verified_by"),
3087
+ "digest": _pv.get("digest"),
3088
+ "parts_count": _pv.get("parts_count"),
3089
+ "checked_pairs": _pv.get("checked_pairs"),
3090
+ }),
3091
+ "docx_deliverable": _docx}
3092
+
3093
+
3094
+ def _check_docx_deliverable(work_dir=None, registry_work_dir=None):
3095
+ """核实"总理子代理的 docx 汇总"真的存在且合法。
3096
+
3097
+ 判定(缺一不可):
3098
+ ① 在交付目录(及台账目录)下能找到 **.docx**;
3099
+ ② 文件非空(> MIN_DOCX_BYTES);
3100
+ ③ 是**合法 zip**(docx 本质是 zip)且**含 word/document.xml** ——
3101
+ 这一条能挡住"随便写个空文件改名成 .docx"的假交付。
3102
+
3103
+ 为什么在校验里做 zip 检查而不是只看扩展名:
3104
+ 只看扩展名等于"改名即通过",与全仓 fail-closed 的口径不符。
3105
+ """
3106
+ MIN_DOCX_BYTES = 1024 # 真 docx 至少有 1KB;纯 stdlib 降级版约 5KB
3107
+ out = {"ok": False, "path": None, "size_bytes": 0, "candidates": [],
3108
+ "checked_dirs": [], "error": None}
3109
+ _roots = []
3110
+ for _d in (work_dir, registry_work_dir):
3111
+ if _d:
3112
+ _roots.append(os.path.abspath(str(_d)))
3113
+ # ══ ★【实测缺陷修复 · 2026-10-13】先认【导出时记录的路径】══════════════
3114
+ # 现象:`export-docx --out <任意路径>` 成功后,`check-docx` 却报
3115
+ # "在 <work_dir>、<load_cases> 下未找到任何 .docx" → 交付总闸误拦。
3116
+ # 根因:本函数只在【固定目录】里 walk,而总理子代理完全可以把 docx
3117
+ # 导出到别处(--out 是允许的),于是"真导出了却查不到"。
3118
+ # 修法:把 export_docx_and_record 记录在计划文件里的路径优先纳入检查,
3119
+ # 但仍**自己重查磁盘**(不采信记录本身),保持 fail-closed 语义不变。
3120
+ _recorded = None
3121
+ try:
3122
+ _plan = load_plan(work_dir)
3123
+ _de = (_plan or {}).get("docx_export") or {}
3124
+ _rp = _de.get("path")
3125
+ if _rp and os.path.isfile(str(_rp)):
3126
+ _recorded = os.path.abspath(str(_rp))
3127
+ except Exception:
3128
+ _recorded = None
3129
+ if _recorded:
3130
+ _roots.insert(0, os.path.dirname(_recorded))
3131
+ out["recorded_path"] = _recorded
3132
+ try:
3133
+ import part_registry as _pr
3134
+ try:
3135
+ _p = _pr.registry_path(work_dir=registry_work_dir or work_dir)
3136
+ if _p:
3137
+ _roots.append(os.path.dirname(os.path.abspath(_p)))
3138
+ except Exception:
3139
+ pass
3140
+ except Exception:
3141
+ pass
3142
+ # 去重保序
3143
+ _seen = set()
3144
+ _roots = [r for r in _roots
3145
+ if not (os.path.normcase(r) in _seen or _seen.add(os.path.normcase(r)))]
3146
+ out["checked_dirs"] = _roots
3147
+
3148
+ _cands = []
3149
+ # ★ 记录路径本身作为第一候选(已 isfile,且随后仍要过 zip 校验)
3150
+ if _recorded:
3151
+ try:
3152
+ _cands.append((os.path.getmtime(_recorded), _recorded))
3153
+ except Exception:
3154
+ pass
3155
+ for _r in _roots:
3156
+ if not os.path.isdir(_r):
3157
+ continue
3158
+ try:
3159
+ for _dirpath, _dirs, _files in os.walk(_r):
3160
+ # 跳过明显的无关目录,避免把测试产物当交付物
3161
+ if any(_s in _dirpath for _s in ("__pycache__", ".git")):
3162
+ continue
3163
+ for _f in _files:
3164
+ if _f.lower().endswith(".docx"):
3165
+ _fp = os.path.join(_dirpath, _f)
3166
+ try:
3167
+ _cands.append((os.path.getmtime(_fp), _fp))
3168
+ except Exception:
3169
+ _cands.append((0.0, _fp))
3170
+ except Exception:
3171
+ continue
3172
+ _cands.sort(reverse=True)
3173
+ out["candidates"] = [_p for _m, _p in _cands][:20]
3174
+ if not _cands:
3175
+ out["error"] = ("在 %s 下未找到任何 .docx —— 总理子代理必须汇总导出。"
3176
+ % "、".join(_roots) or "交付目录")
3177
+ return out
3178
+
3179
+ # 逐个候选校验,取第一个合法的(最新的优先)
3180
+ for _m, _fp in _cands:
3181
+ try:
3182
+ _sz = os.path.getsize(_fp)
3183
+ except Exception:
3184
+ continue
3185
+ if _sz < MIN_DOCX_BYTES:
3186
+ out["error"] = ("找到的 docx 过小(%s,%d 字节 < %d)—— 疑似空文件。"
3187
+ % (os.path.basename(_fp), _sz, MIN_DOCX_BYTES))
3188
+ continue
3189
+ # ★ 必须是真的 zip 且含 word/document.xml
3190
+ try:
3191
+ import zipfile
3192
+ with zipfile.ZipFile(_fp) as _z:
3193
+ _names = _z.namelist()
3194
+ if "word/document.xml" not in _names:
3195
+ out["error"] = ("%s 不是合法 docx(zip 内缺少 word/document.xml)。"
3196
+ % os.path.basename(_fp))
3197
+ continue
3198
+ except Exception as e:
3199
+ out["error"] = ("%s 不是合法 zip/docx(%r)—— 疑似改名冒充。"
3200
+ % (os.path.basename(_fp), e))
3201
+ continue
3202
+ out.update({"ok": True, "path": _fp, "size_bytes": _sz,
3203
+ "error": None,
3204
+ "note": "docx 汇总交付物已核实(合法 zip + 含 word/document.xml)。"})
3205
+ return out
3206
+
3207
+ if not out["error"]:
3208
+ out["error"] = "找到 .docx 但都不合格(详见 candidates)。"
3209
+ return out
3210
+
3211
+
3212
+ def export_docx_and_record(out_path=None, work_dir=None, registry_work_dir=None):
3213
+ """总理子代理的 docx 汇总入口:导出并回写计划文件(供总闸核实)。
3214
+
3215
+ 这是"把 docx 变成流程一等公民"的落点:
3216
+ · 导出成功后把路径/大小/时间写进计划文件;
3217
+ · 交付前总闸会**自己重查磁盘**(不采信这里的记录),
3218
+ 因此即使有人手改计划文件也骗不过去。
3219
+ """
3220
+ try:
3221
+ import part_registry as _pr
3222
+ except Exception as e:
3223
+ return {"ok": False, "error": "part_registry 不可用:%r" % (e,)}
3224
+ _wd = registry_work_dir or work_dir
3225
+ _out = out_path
3226
+ if not _out:
3227
+ _base = os.path.abspath(str(work_dir or _wd or "."))
3228
+ _out = os.path.join(_base, "零件台账_总理汇总.docx")
3229
+ try:
3230
+ r = _pr.export_docx(_out, work_dir=_wd)
3231
+ except TypeError:
3232
+ r = _pr.export_docx(_out)
3233
+ except Exception as e:
3234
+ return {"ok": False, "error": "导出 docx 异常:%r" % (e,)}
3235
+ _existed = os.path.exists(_out)
3236
+ _sz = os.path.getsize(_out) if _existed else 0
3237
+ _res = {"ok": bool(r.get("ok")) if isinstance(r, dict) else bool(r),
3238
+ "path": _out, "size_bytes": _sz,
3239
+ "detail": r if isinstance(r, dict) else None}
3240
+ # 回写计划文件(仅作记录;总闸仍会自己重查磁盘)
3241
+ try:
3242
+ plan = load_plan(work_dir)
3243
+ if not plan.get("_load_error"):
3244
+ plan["docx_export"] = {"path": _out, "size_bytes": _sz,
3245
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")}
3246
+ save_plan(plan, work_dir)
3247
+ except Exception:
3248
+ pass
3249
+ return _res
3250
+
3251
+
3252
+ # ═══════════════════════════════════════════════════════════════════════════
3253
+ # 十一、CLI
3254
+ # ═══════════════════════════════════════════════════════════════════════════
3255
+ def _print(obj):
3256
+ print(json.dumps(obj, ensure_ascii=False, indent=2))
3257
+
3258
+
3259
+ def _main_body(argv):
3260
+ import argparse
3261
+ ap = argparse.ArgumentParser(
3262
+ prog="mode2_flow.py",
3263
+ description="模式二(大型器械装配)流程强制契约:动态题数 / 按需提问 / "
3264
+ "十阶段 / 总计子代理编制 / 总理子代理上报")
3265
+ sub = ap.add_subparsers(dest="cmd")
3266
+
3267
+ p = sub.add_parser("set-depth", help="登记参数需求强度(full 强需求 / lite 弱需求)")
3268
+ p.add_argument("depth", choices=["full", "lite"])
3269
+ p.add_argument("--work-dir", default=None)
3270
+
3271
+ p = sub.add_parser("begin-stage", help="开始一个分析阶段(必须按序)")
3272
+ p.add_argument("stage", choices=STAGE_IDS)
3273
+ p.add_argument("--note", default="")
3274
+ p.add_argument("--actor", default=None, help="调用者 sessionId(默认取 DSH_SESSION_ID)")
3275
+ p.add_argument("--work-dir", default=None)
3276
+
3277
+ p = sub.add_parser("mark-stage-done", help="标记阶段完成并登记产出")
3278
+ p.add_argument("stage", choices=STAGE_IDS)
3279
+ p.add_argument("--artifact", default="")
3280
+ p.add_argument("--actor", default=None, help="调用者 sessionId(默认取 DSH_SESSION_ID)")
3281
+ p.add_argument("--work-dir", default=None)
3282
+
3283
+ p = sub.add_parser("stage-status", help="查看十阶段推进情况")
3284
+ p.add_argument("--work-dir", default=None)
3285
+
3286
+ p = sub.add_parser("ask", help="登记一道按需提问(必须挂阶段 + 写理由)")
3287
+ p.add_argument("topic")
3288
+ p.add_argument("--stage", required=True, choices=STAGE_IDS)
3289
+ p.add_argument("--reason", required=True)
3290
+ p.add_argument("--actor", default=None, help="调用者 sessionId(默认取 DSH_SESSION_ID)")
3291
+ p.add_argument("--work-dir", default=None)
3292
+
3293
+ p = sub.add_parser("answer-question", help="★记录某题的用户真实答复")
3294
+ p.add_argument("seq", type=int, help="题号(record_question 返回的 seq)")
3295
+ p.add_argument("answer", help="用户答复原文")
3296
+ p.add_argument("--actor", default=None, help="调用者 sessionId(默认取 DSH_SESSION_ID)")
3297
+ p.add_argument("--work-dir", default=None)
3298
+
3299
+ p = sub.add_parser("import-answers",
3300
+ help="★【BUG-05】批量导入用户答复(一次写多条,省上下文)")
3301
+ p.add_argument("--json", default="", help="内联 JSON:[{seq,answer},...] 或 {seq:answer}")
3302
+ p.add_argument("--json-file", default="", help="JSON 文件路径(推荐,免转义)")
3303
+ p.add_argument("--overwrite", action="store_true", help="覆盖已答过的题")
3304
+ p.add_argument("--actor", default=None, help="调用者 sessionId(默认取 DSH_SESSION_ID)")
3305
+ p.add_argument("--work-dir", default=None)
3306
+
3307
+ p = sub.add_parser("questions", help="查看提问台账与题数校验")
3308
+ p.add_argument("--final", action="store_true", help="按收尾口径校验下界")
3309
+ p.add_argument("--work-dir", default=None)
3310
+
3311
+ # ── 【Bug② 修复】重做过期阶段(清除 stale 的唯一合法入口)──────────
3312
+ p = sub.add_parser("refresh-stage",
3313
+ help="★重做某阶段:进入重做态(产物过期/用户改答后必须执行)")
3314
+ p.add_argument("stage", choices=STAGE_IDS)
3315
+ p.add_argument("--reason", default="", help="为什么重做(留痕;默认沿用 stale 原因)")
3316
+ p.add_argument("--artifact", default="",
3317
+ help="可选:同时提交重算后的新产物(= refresh + mark-stage-done)")
3318
+ p.add_argument("--actor", default=None, help="调用者 sessionId(默认取 DSH_SESSION_ID)")
3319
+ p.add_argument("--work-dir", default=None)
3320
+ # 别名(用户口径里的 redo-stage 与 refresh-stage 等价)
3321
+ p = sub.add_parser("redo-stage", help="★refresh-stage 的别名")
3322
+ p.add_argument("stage", choices=STAGE_IDS)
3323
+ p.add_argument("--reason", default="")
3324
+ p.add_argument("--artifact", default="")
3325
+ p.add_argument("--actor", default=None, help="调用者 sessionId(默认取 DSH_SESSION_ID)")
3326
+ p.add_argument("--work-dir", default=None)
3327
+
3328
+ # ── 【Bug③ 修复】三处 room_type 一致性自检(只读)──────────────────
3329
+ p = sub.add_parser("room-types",
3330
+ help="★只读自检:编制 / plan 快照 / workflow_state 的 room_type 是否一致")
3331
+ p.add_argument("--work-dir", default=None)
3332
+
3333
+ p = sub.add_parser("sync-rooms",
3334
+ help="★把门禁侧最终房间表回写 plan 快照(后置对账;select 已自动调用)")
3335
+ p.add_argument("--rooms-json", required=True, help="JSON 数组或 @文件路径")
3336
+ p.add_argument("--source", default="cli.sync-rooms")
3337
+ p.add_argument("--task-id", default=None)
3338
+ p.add_argument("--work-dir", default=None)
3339
+
3340
+ p = sub.add_parser("set-subagent-plan", help="★由总计子代理登记子代理编制")
3341
+ p.add_argument("--rooms-json", required=True, help="JSON 数组或 @文件路径")
3342
+ p.add_argument("--rationale", required=True)
3343
+ p.add_argument("--decided-by", default=SUBPLAN_AUTHORITY)
3344
+ p.add_argument("--actor", default=None, help="调用者 sessionId(默认取 DSH_SESSION_ID)")
3345
+ p.add_argument("--work-dir", default=None)
3346
+
3347
+ p = sub.add_parser("subagent-plan", help="查看当前子代理编制")
3348
+ p.add_argument("--work-dir", default=None)
3349
+
3350
+ p = sub.add_parser("prime-room", help="查看总理子代理定义")
3351
+ p.add_argument("--work-dir", default=None)
3352
+
3353
+ p = sub.add_parser("prime-report", help="★建模小屋向总理子代理上报")
3354
+ p.add_argument("--json", required=True, help="JSON 对象或 @文件路径")
3355
+ p.add_argument("--work-dir", default=None)
3356
+
3357
+ p = sub.add_parser("prime-reports", help="列出总理子代理收到的全部上报")
3358
+ p.add_argument("--work-dir", default=None)
3359
+
3360
+ p = sub.add_parser("check-consistency", help="★相邻零件尺寸一致性(不一致→升级父代理)")
3361
+ p.add_argument("--registry-work-dir", default=None)
3362
+ p.add_argument("--work-dir", default=None)
3363
+
3364
+ p = sub.add_parser("prime-verify",
3365
+ help="★★总理子代理统一核实数据(从磁盘重算,未通过禁止结束)")
3366
+ p.add_argument("--registry-work-dir", default=None)
3367
+ p.add_argument("--verified-by", default=None,
3368
+ help="核实者身份,默认「总理子代理」")
3369
+ p.add_argument("--work-dir", default=None)
3370
+
3371
+ p = sub.add_parser("prime-verify-status", help="读回最近一次核实结论")
3372
+ p.add_argument("--work-dir", default=None)
3373
+
3374
+ p = sub.add_parser("export-docx",
3375
+ help="★总理子代理汇总导出 docx(交付前总闸会核实它存在)")
3376
+ p.add_argument("--out", default=None, help="输出路径(默认 <交付目录>/零件台账_总理汇总.docx)")
3377
+ p.add_argument("--registry-work-dir", default=None)
3378
+ p.add_argument("--work-dir", default=None)
3379
+
3380
+ p = sub.add_parser("check-docx", help="核实 docx 交付物是否存在且合法")
3381
+ p.add_argument("--registry-work-dir", default=None)
3382
+ p.add_argument("--work-dir", default=None)
3383
+
3384
+ p = sub.add_parser("pre-delivery", help="★交付前总闸")
3385
+ p.add_argument("--work-dir", default=None)
3386
+
3387
+ p = sub.add_parser("plan", help="打印完整计划文件")
3388
+ p.add_argument("--work-dir", default=None)
3389
+
3390
+ # ── 【2026-10-28 重构】总计子代理身份 + 总理监工看板 ──────────────
3391
+ p = sub.add_parser("analyst-bind",
3392
+ help="★在【总计子代理】里运行:绑定它自己的 sessionId")
3393
+ p.add_argument("--session-id", default=None,
3394
+ help="显式指定;默认读 DSH_SESSION_ID")
3395
+ p.add_argument("--force", action="store_true", help="已有绑定也要改绑")
3396
+ p.add_argument("--work-dir", default=None)
3397
+
3398
+ p = sub.add_parser("analyst-status",
3399
+ help="查看总计子代理是否已绑定 + 是否还活着(alive)")
3400
+ p.add_argument("--work-dir", default=None)
3401
+
3402
+ # ── 【2026-10-29 修复】fork 前置闸门 + 心跳 ──────────────────────
3403
+ p = sub.add_parser("analyst-guard",
3404
+ help="★fork 之前必须先问:总计子代理是否还活着(活着则拒绝重复创建)")
3405
+ p.add_argument("--work-dir", default=None)
3406
+
3407
+ p = sub.add_parser("analyst-heartbeat",
3408
+ help="刷新总计子代理存活心跳(阶段动作已自动刷新,一般不用手调)")
3409
+ p.add_argument("--note", default="")
3410
+ p.add_argument("--actor", default=None, help="调用者 sessionId(默认取 DSH_SESSION_ID)")
3411
+ p.add_argument("--work-dir", default=None)
3412
+
3413
+ p = sub.add_parser("prime-board", help="★总理子代理监工看板(房间状态+上报缺口+待办)")
3414
+ p.add_argument("--json", action="store_true", help="强制 JSON 输出(默认即 JSON)")
3415
+ p.add_argument("--work-dir", default=None)
3416
+
3417
+ args = ap.parse_args(argv)
3418
+ if not args.cmd:
3419
+ ap.print_help()
3420
+ return 2
3421
+
3422
+ wd = getattr(args, "work_dir", None)
3423
+
3424
+ def _rooms_json_error(exc):
3425
+ """--rooms-json 解析失败的【可操作】报错(BUG-2)。"""
3426
+ return {
3427
+ "ok": False,
3428
+ "error": "rooms-json 解析失败: %r" % (exc,),
3429
+ "hint": ("三种写法都支持,请在 PowerShell 里优先用 ②:",
3430
+ " ① 内联 JSON:--rooms-json '[{\"name\":\"结构件\","
3431
+ "\"type\":\"structural\",\"parts\":[\"DSH_x\"],"
3432
+ "\"why\":\"…\"}]'",
3433
+ " ② 文件路径(推荐):--rooms-json C:\\path\\rooms.json"
3434
+ "(不带 @,避免 PowerShell 把 @ 当 splatting 吃掉)",
3435
+ " ③ 兼容写法:--rooms-json \"@C:\\path\\rooms.json\""
3436
+ "(bash / cmd / Python 下可用)"),
3437
+ "note": "文件内容必须是【JSON 数组】,不要包一层 {\"rooms\": [...]}。",
3438
+ }
3439
+
3440
+ def _load_json_arg(v):
3441
+ r"""支持 --rooms-json '[{...}]' 或 --rooms-json @file.json(便于长内容)。
3442
+
3443
+ ── 【BUG-2 修复】PowerShell 的 @ 会被吃掉 ─────────────────────────
3444
+ 现场:在 PowerShell 里写 --rooms-json "@C:\path
3445
+ ooms.json",
3446
+ @ 被 PS 当成 splatting/数组符号解释,参数被拆散,命令直接失败。
3447
+ 处理(三重,任一即可用):
3448
+ ① 仍支持 @file.json(bash / cmd / Python 直接调用时最自然);
3449
+ ② 支持不带 @ 的 .json 文件路径(推荐 PS 用户这样写);
3450
+ ③ 支持把 JSON 直接内联传入。
3451
+ 另外把"解析失败"的报错换成可操作的:告诉用户三种写法都行。
3452
+ """
3453
+ s = v
3454
+ _is_path = False
3455
+ if isinstance(v, str):
3456
+ _raw = v.strip()
3457
+ # ① @file.json
3458
+ if _raw.startswith("@"):
3459
+ _is_path = True
3460
+ _raw = _raw[1:].strip().strip("'\"")
3461
+ # ② 裸路径:以 .json 结尾且文件真的存在(PS 下 @ 被吃掉后就是这个形态)
3462
+ elif _raw.lower().endswith(".json") and os.path.isfile(_raw):
3463
+ _is_path = True
3464
+ if _is_path:
3465
+ if not os.path.isfile(_raw):
3466
+ raise IOError("文件不存在: %s" % _raw)
3467
+ with open(_raw, "r", encoding="utf-8-sig") as f:
3468
+ s = f.read()
3469
+ elif isinstance(v, (list, dict)):
3470
+ return v
3471
+ return json.loads(s)
3472
+
3473
+ _actor = getattr(args, "actor", None)
3474
+ if args.cmd == "set-depth":
3475
+ r = set_depth(args.depth, wd)
3476
+ elif args.cmd == "analyst-bind":
3477
+ r = analyst_bind(getattr(args, "session_id", None), wd,
3478
+ force=bool(getattr(args, "force", False)))
3479
+ elif args.cmd == "analyst-guard":
3480
+ r = analyst_guard(wd)
3481
+ elif args.cmd == "analyst-heartbeat":
3482
+ r = analyst_heartbeat(getattr(args, "actor", None), wd,
3483
+ note=getattr(args, "note", ""))
3484
+ elif args.cmd == "analyst-status":
3485
+ r = analyst_status(wd)
3486
+ elif args.cmd == "prime-board":
3487
+ r = prime_board(wd)
3488
+ elif args.cmd == "begin-stage":
3489
+ r = begin_stage(args.stage, wd, args.note, actor=_actor)
3490
+ elif args.cmd == "mark-stage-done":
3491
+ r = mark_stage_done(args.stage, args.artifact, wd, actor=_actor)
3492
+ elif args.cmd == "stage-status":
3493
+ r = stage_status(wd)
3494
+ elif args.cmd == "ask":
3495
+ r = record_question(args.topic, args.stage, args.reason, wd, actor=_actor)
3496
+ elif args.cmd == "answer-question":
3497
+ r = answer_question(args.seq, args.answer, wd, actor=_actor)
3498
+ elif args.cmd == "import-answers":
3499
+ _payload = None
3500
+ if getattr(args, "json_file", ""):
3501
+ try:
3502
+ with open(args.json_file, "r", encoding="utf-8-sig") as _f:
3503
+ _payload = json.load(_f)
3504
+ except Exception as _e:
3505
+ r = {"ok": False, "error": "读取 --json-file 失败: %r" % (_e,)}
3506
+ _payload = None
3507
+ elif getattr(args, "json", ""):
3508
+ try:
3509
+ _payload = json.loads(args.json)
3510
+ except Exception as _e:
3511
+ r = {"ok": False, "error": "解析 --json 失败: %r" % (_e,)}
3512
+ _payload = None
3513
+ if _payload is not None:
3514
+ r = import_answers(_payload, wd, actor=_actor,
3515
+ overwrite=bool(getattr(args, "overwrite", False)))
3516
+ elif args.cmd == "questions":
3517
+ r = question_report(wd, final=args.final)
3518
+ elif args.cmd in ("refresh-stage", "redo-stage"):
3519
+ r = refresh_stage(args.stage, wd, getattr(args, "reason", ""),
3520
+ getattr(args, "artifact", ""), actor=_actor)
3521
+ elif args.cmd == "room-types":
3522
+ r = room_types_report(wd)
3523
+ elif args.cmd == "sync-rooms":
3524
+ try:
3525
+ _rooms = _load_json_arg(args.rooms_json)
3526
+ except Exception as e:
3527
+ _print(_rooms_json_error(e))
3528
+ return 1
3529
+ r = sync_rooms_snapshot(_rooms, wd, source=args.source,
3530
+ task_id=getattr(args, "task_id", None))
3531
+ elif args.cmd == "set-subagent-plan":
3532
+ try:
3533
+ rooms = _load_json_arg(args.rooms_json)
3534
+ except Exception as e:
3535
+ _print({"ok": False, "error": "rooms-json 解析失败: %r" % (e,)})
3536
+ return 1
3537
+ r = set_subagent_plan(rooms, args.rationale, args.decided_by, wd, actor=_actor)
3538
+ elif args.cmd == "subagent-plan":
3539
+ r = get_subagent_plan(wd)
3540
+ elif args.cmd == "prime-room":
3541
+ r = {"ok": True, **prime_room_definition(wd)}
3542
+ elif args.cmd == "prime-report":
3543
+ try:
3544
+ rep = _load_json_arg(args.json)
3545
+ except Exception as e:
3546
+ _print({"ok": False, "error": "json 解析失败: %r" % (e,)})
3547
+ return 1
3548
+ r = record_prime_report(rep, wd)
3549
+ elif args.cmd == "prime-reports":
3550
+ r = prime_reports(wd)
3551
+ elif args.cmd == "check-consistency":
3552
+ r = check_prime_consistency(wd, getattr(args, "registry_work_dir", None))
3553
+ elif args.cmd == "prime-verify":
3554
+ r = prime_verify_all(wd, getattr(args, "registry_work_dir", None),
3555
+ getattr(args, "verified_by", None))
3556
+ elif args.cmd == "prime-verify-status":
3557
+ r = prime_verify_status(wd)
3558
+ elif args.cmd == "export-docx":
3559
+ r = export_docx_and_record(getattr(args, "out", None), wd,
3560
+ getattr(args, "registry_work_dir", None))
3561
+ elif args.cmd == "check-docx":
3562
+ r = _check_docx_deliverable(wd, getattr(args, "registry_work_dir", None))
3563
+ elif args.cmd == "pre-delivery":
3564
+ r = pre_delivery_check(wd)
3565
+ elif args.cmd == "plan":
3566
+ r = load_plan(wd)
3567
+ else:
3568
+ ap.print_help()
3569
+ return 2
3570
+
3571
+ _print(r)
3572
+ return 0 if r.get("ok") else 1
3573
+
3574
+
3575
+ def main(argv=None):
3576
+ argv = list(sys.argv[1:] if argv is None else argv)
3577
+ try:
3578
+ return _main_body(argv)
3579
+ except SystemExit:
3580
+ raise
3581
+ except Exception as e:
3582
+ import traceback
3583
+ _print({"ok": False, "error": "内部异常: %r" % (e,),
3584
+ "traceback": traceback.format_exc()[-1200:]})
3585
+ return 1
3586
+
3587
+
3588
+ if __name__ == "__main__":
3589
+ sys.exit(main())