eduevidence 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +506 -0
  3. package/README.zh-CN.md +434 -0
  4. package/SKILL.md +195 -0
  5. package/bin/eduevidence.js +127 -0
  6. package/domains/education/manifest.json +183 -0
  7. package/domains/education/outcome_taxonomy.json +127 -0
  8. package/domains/manifest.json +26 -0
  9. package/domains/policy/frame.schema.json +234 -0
  10. package/domains/policy/manifest.json +10 -0
  11. package/domains/policy/methodology_checklist.json +109 -0
  12. package/domains/policy/outcome_taxonomy.json +53 -0
  13. package/domains/policy/references/causal-identification.md +45 -0
  14. package/domains/policy/references/cost-evidence.md +44 -0
  15. package/domains/policy/references/equity.md +42 -0
  16. package/domains/policy/references/evidence-hierarchy.md +41 -0
  17. package/domains/policy/references/implementation-evidence.md +47 -0
  18. package/eduevidence_cli.py +26 -0
  19. package/engine/__init__.py +11 -0
  20. package/engine/__pycache__/__init__.cpython-312.pyc +0 -0
  21. package/engine/__pycache__/analysis.cpython-312.pyc +0 -0
  22. package/engine/__pycache__/bias.cpython-312.pyc +0 -0
  23. package/engine/__pycache__/briefs.cpython-312.pyc +0 -0
  24. package/engine/__pycache__/capabilities.cpython-312.pyc +0 -0
  25. package/engine/__pycache__/citation_check.cpython-312.pyc +0 -0
  26. package/engine/__pycache__/contracts.cpython-312.pyc +0 -0
  27. package/engine/__pycache__/datasets.cpython-312.pyc +0 -0
  28. package/engine/__pycache__/events.cpython-312.pyc +0 -0
  29. package/engine/__pycache__/evidence_graph.cpython-312.pyc +0 -0
  30. package/engine/__pycache__/evidence_review.cpython-312.pyc +0 -0
  31. package/engine/__pycache__/evidencecore.cpython-312.pyc +0 -0
  32. package/engine/__pycache__/gap_lens.cpython-312.pyc +0 -0
  33. package/engine/__pycache__/gaps.cpython-312.pyc +0 -0
  34. package/engine/__pycache__/graph_store.cpython-312.pyc +0 -0
  35. package/engine/__pycache__/graph_validate.cpython-312.pyc +0 -0
  36. package/engine/__pycache__/ids.cpython-312.pyc +0 -0
  37. package/engine/__pycache__/library.cpython-312.pyc +0 -0
  38. package/engine/__pycache__/library_builtin.cpython-312.pyc +0 -0
  39. package/engine/__pycache__/living.cpython-312.pyc +0 -0
  40. package/engine/__pycache__/log.cpython-312.pyc +0 -0
  41. package/engine/__pycache__/meta_analysis.cpython-312.pyc +0 -0
  42. package/engine/__pycache__/meta_synthesis.cpython-312.pyc +0 -0
  43. package/engine/__pycache__/migration.cpython-312.pyc +0 -0
  44. package/engine/__pycache__/mode_router.cpython-312.pyc +0 -0
  45. package/engine/__pycache__/paths.cpython-312.pyc +0 -0
  46. package/engine/__pycache__/pilot.cpython-312.pyc +0 -0
  47. package/engine/__pycache__/planner.cpython-312.pyc +0 -0
  48. package/engine/__pycache__/project.cpython-312.pyc +0 -0
  49. package/engine/__pycache__/projections.cpython-312.pyc +0 -0
  50. package/engine/__pycache__/robustness.cpython-312.pyc +0 -0
  51. package/engine/__pycache__/run.cpython-312.pyc +0 -0
  52. package/engine/__pycache__/semantics.cpython-312.pyc +0 -0
  53. package/engine/__pycache__/study_design.cpython-312.pyc +0 -0
  54. package/engine/__pycache__/synthesis.cpython-312.pyc +0 -0
  55. package/engine/__pycache__/tribunal.cpython-312.pyc +0 -0
  56. package/engine/__pycache__/update.cpython-312.pyc +0 -0
  57. package/engine/__pycache__/versions.cpython-312.pyc +0 -0
  58. package/engine/analysis.py +308 -0
  59. package/engine/bias.py +178 -0
  60. package/engine/briefs.py +106 -0
  61. package/engine/capabilities.py +99 -0
  62. package/engine/citation_check.py +192 -0
  63. package/engine/contracts.py +117 -0
  64. package/engine/datasets.py +165 -0
  65. package/engine/events.py +67 -0
  66. package/engine/evidence_graph.py +571 -0
  67. package/engine/evidence_review.py +88 -0
  68. package/engine/evidencecore.py +182 -0
  69. package/engine/gap_lens.py +132 -0
  70. package/engine/gaps.py +169 -0
  71. package/engine/graph_store.py +335 -0
  72. package/engine/graph_validate.py +87 -0
  73. package/engine/ids.py +77 -0
  74. package/engine/library.py +268 -0
  75. package/engine/library_builtin.py +301 -0
  76. package/engine/living.py +671 -0
  77. package/engine/log.py +39 -0
  78. package/engine/meta_analysis.py +333 -0
  79. package/engine/meta_synthesis.py +111 -0
  80. package/engine/migration.py +397 -0
  81. package/engine/mode_router.py +72 -0
  82. package/engine/paths.py +15 -0
  83. package/engine/pilot.py +368 -0
  84. package/engine/planner.py +126 -0
  85. package/engine/project.py +118 -0
  86. package/engine/projections.py +240 -0
  87. package/engine/robustness.py +109 -0
  88. package/engine/run.py +85 -0
  89. package/engine/semantics.py +135 -0
  90. package/engine/study_design.py +87 -0
  91. package/engine/synthesis.py +187 -0
  92. package/engine/tribunal.py +408 -0
  93. package/engine/update.py +113 -0
  94. package/engine/versions.py +12 -0
  95. package/install.sh +510 -0
  96. package/integrations/__init__.py +1 -0
  97. package/integrations/__pycache__/__init__.cpython-312.pyc +0 -0
  98. package/integrations/__pycache__/agent_mcp.cpython-312.pyc +0 -0
  99. package/integrations/__pycache__/smart_web_fetch.cpython-312.pyc +0 -0
  100. package/integrations/agent_mcp.py +856 -0
  101. package/integrations/smart_web_fetch.py +59 -0
  102. package/package.json +50 -0
  103. package/pyproject.toml +55 -0
  104. package/references/applicability-policy.md +88 -0
  105. package/references/education-framing.md +132 -0
  106. package/references/effect_size_formulas.md +35 -0
  107. package/references/evaluation-design.md +111 -0
  108. package/references/evidence-quality.md +79 -0
  109. package/references/grade_framework.md +29 -0
  110. package/references/intervention-design.md +98 -0
  111. package/references/methodology-audit.md +103 -0
  112. package/references/outcome-taxonomy.md +106 -0
  113. package/references/retrieval-protocol.md +142 -0
  114. package/references/skeptic-protocol.md +93 -0
  115. package/references/social_science_pitfalls.md +48 -0
  116. package/references/source-validity.md +140 -0
  117. package/references/tribunal-policy.md +112 -0
  118. package/references/wwc_standards.md +29 -0
  119. package/retrieval/__init__.py +1 -0
  120. package/retrieval/__pycache__/__init__.cpython-312.pyc +0 -0
  121. package/retrieval/__pycache__/corpus_store.cpython-312.pyc +0 -0
  122. package/retrieval/__pycache__/dedupe.cpython-312.pyc +0 -0
  123. package/retrieval/__pycache__/failures.cpython-312.pyc +0 -0
  124. package/retrieval/__pycache__/fetch.cpython-312.pyc +0 -0
  125. package/retrieval/__pycache__/search.cpython-312.pyc +0 -0
  126. package/retrieval/__pycache__/source.cpython-312.pyc +0 -0
  127. package/retrieval/__pycache__/validate.cpython-312.pyc +0 -0
  128. package/retrieval/corpus_store.py +181 -0
  129. package/retrieval/dedupe.py +127 -0
  130. package/retrieval/failures.py +90 -0
  131. package/retrieval/fetch.py +435 -0
  132. package/retrieval/search.py +493 -0
  133. package/retrieval/source.py +160 -0
  134. package/retrieval/validate.py +257 -0
  135. package/schemas/agent-mcp-approval.schema.json +57 -0
  136. package/schemas/chart-spec.schema.json +88 -0
  137. package/schemas/cross-model-review.schema.json +28 -0
  138. package/schemas/education-frame.schema.json +106 -0
  139. package/schemas/evaluation.schema.json +35 -0
  140. package/schemas/evidence.schema.json +81 -0
  141. package/schemas/fetch-result.schema.json +119 -0
  142. package/schemas/intervention.schema.json +46 -0
  143. package/schemas/methodology.schema.json +102 -0
  144. package/schemas/report-result.schema.json +381 -0
  145. package/schemas/report-spec.schema.json +130 -0
  146. package/schemas/source.schema.json +311 -0
  147. package/schemas/v2/analysis-plan.schema.json +28 -0
  148. package/schemas/v2/analysis-run.schema.json +33 -0
  149. package/schemas/v2/claim.schema.json +26 -0
  150. package/schemas/v2/dataset-asset.schema.json +40 -0
  151. package/schemas/v2/decision-snapshot.schema.json +53 -0
  152. package/schemas/v2/evidence-link.schema.json +38 -0
  153. package/schemas/v2/finding.schema.json +47 -0
  154. package/schemas/v2/graph-revision.schema.json +30 -0
  155. package/schemas/v2/knowledge-gap.schema.json +40 -0
  156. package/schemas/v2/methodology-audit.schema.json +30 -0
  157. package/schemas/v2/outcome.schema.json +18 -0
  158. package/schemas/v2/project.schema.json +31 -0
  159. package/schemas/v2/research-intent.schema.json +24 -0
  160. package/schemas/v2/run.schema.json +43 -0
  161. package/schemas/v2/source.schema.json +24 -0
  162. package/schemas/v2/study-design.schema.json +67 -0
  163. package/schemas/v2/study.schema.json +37 -0
  164. package/schemas/v3/pilot-outcome.schema.json +132 -0
  165. package/schemas/v3/run-manifest.schema.json +193 -0
  166. package/schemas/v3/synthesis.schema.json +86 -0
  167. package/schemas/v4/drift-report.schema.json +66 -0
  168. package/schemas/v4/evidence-library.schema.json +42 -0
  169. package/schemas/v4/living-subscription.schema.json +55 -0
  170. package/schemas/v4/meta-analysis.schema.json +152 -0
  171. package/schemas/verdict.schema.json +56 -0
  172. package/scripts/__init__.py +0 -0
  173. package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
  174. package/scripts/__pycache__/benchmark.cpython-312.pyc +0 -0
  175. package/scripts/__pycache__/benchmark_evaluator.cpython-312.pyc +0 -0
  176. package/scripts/__pycache__/benchmark_judge.cpython-312.pyc +0 -0
  177. package/scripts/__pycache__/benchmark_routing.cpython-312.pyc +0 -0
  178. package/scripts/__pycache__/benchmark_v2.cpython-312.pyc +0 -0
  179. package/scripts/__pycache__/benchmark_v3.cpython-312.pyc +0 -0
  180. package/scripts/__pycache__/build_result.cpython-312.pyc +0 -0
  181. package/scripts/__pycache__/claim_audit.cpython-312.pyc +0 -0
  182. package/scripts/__pycache__/complexity_gate.cpython-312.pyc +0 -0
  183. package/scripts/__pycache__/compute_confidence.cpython-312.pyc +0 -0
  184. package/scripts/__pycache__/dashboard_server.cpython-312.pyc +0 -0
  185. package/scripts/__pycache__/did_regression.cpython-312.pyc +0 -0
  186. package/scripts/__pycache__/effect_calculator.cpython-312.pyc +0 -0
  187. package/scripts/__pycache__/evidence_matrix.cpython-312.pyc +0 -0
  188. package/scripts/__pycache__/evidence_score.cpython-312.pyc +0 -0
  189. package/scripts/__pycache__/evidence_semantics.cpython-312.pyc +0 -0
  190. package/scripts/__pycache__/fetch_benchmark.cpython-312.pyc +0 -0
  191. package/scripts/__pycache__/lint_report_layout.cpython-312.pyc +0 -0
  192. package/scripts/__pycache__/orchestrator.cpython-312.pyc +0 -0
  193. package/scripts/__pycache__/pre_verdict_gate.cpython-312.pyc +0 -0
  194. package/scripts/__pycache__/recompute_demo_quality.cpython-312.pyc +0 -0
  195. package/scripts/__pycache__/render_report.cpython-312.pyc +0 -0
  196. package/scripts/__pycache__/render_report_html.cpython-312.pyc +0 -0
  197. package/scripts/__pycache__/run_workspace.cpython-312.pyc +0 -0
  198. package/scripts/__pycache__/skill_lint.cpython-312.pyc +0 -0
  199. package/scripts/__pycache__/startup_probe.cpython-312.pyc +0 -0
  200. package/scripts/__pycache__/sync_killer_demo_report.cpython-312.pyc +0 -0
  201. package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.0.2.pyc +0 -0
  202. package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.1.1.pyc +0 -0
  203. package/scripts/__pycache__/validate_schema.cpython-312.pyc +0 -0
  204. package/scripts/audit_dois.py +292 -0
  205. package/scripts/bake_pack.sh +37 -0
  206. package/scripts/benchmark.py +183 -0
  207. package/scripts/benchmark_evaluator.py +371 -0
  208. package/scripts/benchmark_judge.py +535 -0
  209. package/scripts/benchmark_routing.py +120 -0
  210. package/scripts/benchmark_v2.py +304 -0
  211. package/scripts/benchmark_v3.py +552 -0
  212. package/scripts/build_esl_artifacts.py +1921 -0
  213. package/scripts/build_evidence_library.py +307 -0
  214. package/scripts/build_killer_demo.py +295 -0
  215. package/scripts/build_result.py +311 -0
  216. package/scripts/check_version_consistency.py +96 -0
  217. package/scripts/citation_check.py +123 -0
  218. package/scripts/claim_audit.py +157 -0
  219. package/scripts/complexity_gate.py +180 -0
  220. package/scripts/compute_confidence.py +176 -0
  221. package/scripts/dashboard_server.py +536 -0
  222. package/scripts/did_regression.py +315 -0
  223. package/scripts/effect_calculator.py +99 -0
  224. package/scripts/enrich_projects_human_and_lieflat.py +315 -0
  225. package/scripts/evidence_matrix.py +129 -0
  226. package/scripts/evidence_score.py +234 -0
  227. package/scripts/evidence_semantics.py +87 -0
  228. package/scripts/fetch_benchmark.py +177 -0
  229. package/scripts/generate_metrics.py +99 -0
  230. package/scripts/generate_new_projects.py +686 -0
  231. package/scripts/generate_promo.py +192 -0
  232. package/scripts/lint_report_layout.py +182 -0
  233. package/scripts/orchestrator.py +1456 -0
  234. package/scripts/pre_verdict_gate.py +513 -0
  235. package/scripts/quickstart.py +121 -0
  236. package/scripts/rebake_all_5themes.py +88 -0
  237. package/scripts/recompute_demo_quality.py +205 -0
  238. package/scripts/render_report.py +270 -0
  239. package/scripts/render_report_html.py +356 -0
  240. package/scripts/retraction_watch.py +110 -0
  241. package/scripts/run_workspace.py +337 -0
  242. package/scripts/serve_web.py +54 -0
  243. package/scripts/skill_lint.py +150 -0
  244. package/scripts/startup_probe.py +265 -0
  245. package/scripts/sync_killer_demo_report.py +270 -0
  246. package/scripts/test_adversarial_empirical.py +541 -0
  247. package/scripts/validate_schema.py +256 -0
  248. package/skill/agents/education-planner.md +80 -0
  249. package/skill/agents/evaluation-designer.md +74 -0
  250. package/skill/agents/evidence-analyst.md +106 -0
  251. package/skill/agents/evidence-judge.md +111 -0
  252. package/skill/agents/evidence-retriever.md +80 -0
  253. package/skill/agents/intervention-designer.md +82 -0
  254. package/skill/agents/method-reviewer.md +104 -0
  255. package/skill/agents/skeptic.md +89 -0
  256. package/skill/sub-skills/aihot-trend-analysis/SKILL.md +31 -0
  257. package/skill/sub-skills/contradiction-analysis/SKILL.md +17 -0
  258. package/skill/sub-skills/data-analysis/SKILL.md +23 -0
  259. package/skill/sub-skills/ethics-review/SKILL.md +25 -0
  260. package/skill/sub-skills/evidence-extraction/SKILL.md +19 -0
  261. package/skill/sub-skills/evidence-review/SKILL.md +18 -0
  262. package/skill/sub-skills/gap-analysis/SKILL.md +25 -0
  263. package/skill/sub-skills/literature-review/SKILL.md +21 -0
  264. package/skill/sub-skills/methodology-audit/SKILL.md +20 -0
  265. package/skill/sub-skills/report-generation/SKILL.md +51 -0
  266. package/skill/sub-skills/research-planning/SKILL.md +21 -0
  267. package/skill/sub-skills/study-design/SKILL.md +16 -0
  268. package/skill/task-briefs/adjudicate.md +17 -0
  269. package/skill/task-briefs/audit.md +15 -0
  270. package/skill/task-briefs/challenge.md +15 -0
  271. package/skill/task-briefs/evaluate.md +13 -0
  272. package/skill/task-briefs/extract.md +16 -0
  273. package/skill/task-briefs/frame.md +17 -0
  274. package/skill/task-briefs/intervene.md +14 -0
  275. package/skill/task-briefs/present.md +16 -0
  276. package/skill/task-briefs/retrieve.md +15 -0
  277. package/visualization/eduevidence-report/assets/base.css +337 -0
  278. package/visualization/eduevidence-report/motion/motion.css +157 -0
  279. package/visualization/eduevidence-report/motion/motion.js +107 -0
  280. package/visualization/eduevidence-report/references/bilingual-style.md +7 -0
  281. package/visualization/eduevidence-report/references/component-catalog.md +145 -0
  282. package/visualization/eduevidence-report/references/evidence-expansion.md +65 -0
  283. package/visualization/eduevidence-report/references/full-report-outline.md +86 -0
  284. package/visualization/eduevidence-report/references/layout-constraints.md +63 -0
  285. package/visualization/eduevidence-report/references/lieflat-composition.md +79 -0
  286. package/visualization/eduevidence-report/references/motion-system.md +31 -0
  287. package/visualization/eduevidence-report/schemas/adapter-envelope.schema.json +22 -0
  288. package/visualization/eduevidence-report/schemas/visual-layout.schema.json +87 -0
  289. package/visualization/eduevidence-report/scripts/__pycache__/adapter_contract.cpython-312.pyc +0 -0
  290. package/visualization/eduevidence-report/scripts/__pycache__/build_artifact_manifest.cpython-312.pyc +0 -0
  291. package/visualization/eduevidence-report/scripts/__pycache__/build_charts.cpython-312.pyc +0 -0
  292. package/visualization/eduevidence-report/scripts/__pycache__/build_figures.cpython-312.pyc +0 -0
  293. package/visualization/eduevidence-report/scripts/__pycache__/build_infographics.cpython-312.pyc +0 -0
  294. package/visualization/eduevidence-report/scripts/__pycache__/build_report.cpython-312.pyc +0 -0
  295. package/visualization/eduevidence-report/scripts/__pycache__/charts_data.cpython-312.pyc +0 -0
  296. package/visualization/eduevidence-report/scripts/__pycache__/lieflat_engine.cpython-312.pyc +0 -0
  297. package/visualization/eduevidence-report/scripts/__pycache__/zh_labels.cpython-312.pyc +0 -0
  298. package/visualization/eduevidence-report/scripts/adapter_contract.py +72 -0
  299. package/visualization/eduevidence-report/scripts/build_artifact_manifest.py +70 -0
  300. package/visualization/eduevidence-report/scripts/build_charts.py +283 -0
  301. package/visualization/eduevidence-report/scripts/build_figures.py +515 -0
  302. package/visualization/eduevidence-report/scripts/build_infographics.py +268 -0
  303. package/visualization/eduevidence-report/scripts/build_report.py +3211 -0
  304. package/visualization/eduevidence-report/scripts/charts_data.py +617 -0
  305. package/visualization/eduevidence-report/scripts/check_mobile_layout.js +220 -0
  306. package/visualization/eduevidence-report/scripts/lieflat_engine.py +852 -0
  307. package/visualization/eduevidence-report/scripts/zh_labels.py +245 -0
  308. package/visualization/eduevidence-report/themes/academic.css +94 -0
  309. package/visualization/eduevidence-report/themes/claude.css +96 -0
  310. package/visualization/eduevidence-report/themes/datalab-dark.css +147 -0
  311. package/visualization/eduevidence-report/themes/datalab.css +151 -0
  312. package/visualization/eduevidence-report/themes/presentation.css +140 -0
@@ -0,0 +1,856 @@
1
+ #!/usr/bin/env python3
2
+ """agent_mcp.py — Conditional Agent MCP integration with Mandatory Confirmation Gate.
3
+
4
+ Agent MCP is **directly installed, never migrated**. EduEvidence only does:
5
+
6
+ scan -> recommend -> ask the user -> execute after explicit confirmation
7
+
8
+ Gate principle (Phase 5-9): Scan first. Recommend second. Ask the user.
9
+ Execute only after explicit confirmation.
10
+
11
+ No hardcoded model names: ROLE_REQUIREMENTS describes role *capability*
12
+ requirements only. Concrete CLI/model choices come from the user-approved
13
+ mapping stored in agent_mcp_approval.json — anything unverifiable is reported
14
+ as `unknown` and never guessed.
15
+
16
+ When agent-mcp is installed and its daemon is reachable, advanced features
17
+ become available:
18
+ - multi-CLI dispatch (fast / strong / independent model routing)
19
+ - Cross-Model Review (an independent model verifies the draft verdict)
20
+ - Memory Bank (memory_store / memory_recall for long-running research)
21
+
22
+ When unavailable, everything degrades to Platform Native Mode (single-agent
23
+ serial execution of the 8-role protocol) with no behavioral break.
24
+
25
+ Spawning is only reachable through safe_spawn(): it verifies that an explicit
26
+ user approval exists, that the approval hash is intact, that the requested
27
+ CLI / model / role are exactly the approved ones, and only then builds the
28
+ spawn payload. Any failure returns AGENT_MCP_APPROVAL_REQUIRED — business
29
+ code MUST NOT call spawn directly.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import json
35
+ import os
36
+ import re
37
+ import shutil
38
+ import socket
39
+ import subprocess
40
+ from datetime import datetime, timezone
41
+ from pathlib import Path
42
+ from typing import Any
43
+
44
+ AGENT_MCP_ENV_FILE = os.environ.get("AGENT_MCP_ENV_FILE", "~/.eduevidence/env")
45
+
46
+
47
+ def _env_file_values(path: str | Path | None = None) -> dict[str, str]:
48
+ """Parse KEY=VALUE lines from ~/.eduevidence/env (best-effort).
49
+
50
+ install.sh writes AGENT_MCP_INSTALLED=1 there after installation; it is a
51
+ *fallback* env source — real environment variables always win
52
+ (see _effective_env).
53
+ """
54
+ try:
55
+ text = Path(os.path.expanduser(path or AGENT_MCP_ENV_FILE)).read_text(encoding="utf-8")
56
+ except OSError:
57
+ return {}
58
+ values: dict[str, str] = {}
59
+ for line in text.splitlines():
60
+ line = line.strip()
61
+ if not line or line.startswith("#") or "=" not in line:
62
+ continue
63
+ key, _, value = line.partition("=")
64
+ values[key.strip()] = value.strip().strip("\"'")
65
+ return values
66
+
67
+
68
+ def _effective_env(file_values: dict[str, str], key: str, default: str = "") -> str:
69
+ """Resolve one setting: real env var > ~/.eduevidence/env > default."""
70
+ return os.environ.get(key) or file_values.get(key) or default
71
+
72
+
73
+ _ENV_FILE_VALUES = _env_file_values()
74
+ AGENT_MCP_PORT = int(_effective_env(_ENV_FILE_VALUES, "AGENT_MCP_PORT", "8765"))
75
+ AGENT_MCP_HOME = _effective_env(_ENV_FILE_VALUES, "AGENT_MCP_HOME",
76
+ os.environ.get("CODEX_HOME", "~/.codex"))
77
+ AGENT_MCP_INSTALLED = _effective_env(_ENV_FILE_VALUES, "AGENT_MCP_INSTALLED",
78
+ "").lower() in ("1", "true", "yes")
79
+
80
+ # Failure states per 总体实施计划 §54 + Phase 8 Approval Gate.
81
+ AGENT_MCP_UNAVAILABLE = "AGENT_MCP_UNAVAILABLE"
82
+ AGENT_MCP_APPROVAL_REQUIRED = "AGENT_MCP_APPROVAL_REQUIRED"
83
+
84
+ # Role capability requirements shipped with this repo (skill/agents/*.md).
85
+ # Capabilities only — NO model names, NO CLI names. `None` means the role has
86
+ # no requirement for that dimension (unknown capability is acceptable).
87
+ # skeptic requires a *different model family* than the primary analysis;
88
+ # that can never be satisfied by spawning the same model in another session.
89
+ ROLE_REQUIREMENTS: dict[str, dict[str, Any]] = {
90
+ "education-planner": {
91
+ "reasoning": "high", "speed": None, "cost": None,
92
+ "structured_output": None, "context": None, "tool_use": None,
93
+ "multimodal": None,
94
+ },
95
+ "evidence-retriever": {
96
+ "reasoning": None, "speed": "high", "cost": "low",
97
+ "structured_output": None, "context": None, "tool_use": "strong",
98
+ "multimodal": None,
99
+ },
100
+ "evidence-analyst": {
101
+ "reasoning": "medium+", "speed": None, "cost": None,
102
+ "structured_output": "strong", "context": None, "tool_use": None,
103
+ "multimodal": None,
104
+ },
105
+ "skeptic": {
106
+ "reasoning": "high", "speed": None, "cost": None,
107
+ "structured_output": None, "context": None, "tool_use": None,
108
+ "multimodal": None,
109
+ "independence": "different-model-family",
110
+ },
111
+ "method-reviewer": {
112
+ "reasoning": "high", "speed": None, "cost": None,
113
+ "structured_output": None, "context": "high", "tool_use": None,
114
+ "multimodal": None,
115
+ },
116
+ "evidence-judge": {
117
+ "reasoning": "highest", "speed": None, "cost": None,
118
+ "structured_output": "strong", "context": None, "tool_use": None,
119
+ "multimodal": None,
120
+ },
121
+ "intervention-designer": {
122
+ "reasoning": "high", "speed": None, "cost": None,
123
+ "structured_output": None, "context": None, "tool_use": None,
124
+ "multimodal": None,
125
+ },
126
+ "evaluation-designer": {
127
+ "reasoning": "high", "speed": None, "cost": None,
128
+ "structured_output": None, "context": None, "tool_use": None,
129
+ "multimodal": None,
130
+ "quantitative": "preferred",
131
+ },
132
+ }
133
+
134
+ # Human-readable one-line task per role (for the user-facing recommendation
135
+ # table). Display metadata only — not a routing decision.
136
+ ROLE_TASKS: dict[str, str] = {
137
+ "education-planner": "Framing:把教学问题转成 EducationResearchFrame",
138
+ "evidence-retriever": "检索支持与反方证据,去重初筛",
139
+ "evidence-analyst": "证据结构化抽取为 Evidence Objects",
140
+ "skeptic": "独立反证:9 项检查,找 null/negative/contradictory 证据",
141
+ "method-reviewer": "方法学审计(样本/测量/结论范围)",
142
+ "evidence-judge": "Tribunal 裁决四态结论",
143
+ "intervention-designer": "干预方案设计(适用性/成本/风险)",
144
+ "evaluation-designer": "评估设计(对照组/指标/定量分析)",
145
+ }
146
+
147
+ # CLI discovery commands (best-effort; CLIs without a working discovery
148
+ # command report models: [] — never guessed).
149
+ _CLI_MODEL_COMMANDS: dict[str, tuple[list[str], str]] = {
150
+ "opencode": (["opencode", "models"], "slash_lines"),
151
+ "omp": (["omp", "models"], "omp_table"),
152
+ "codex": (["codex", "models"], "slash_lines"),
153
+ "grok": (["grok", "models"], "slash_lines"),
154
+ }
155
+
156
+ # Model-family heuristics for the skeptic independence check (family names
157
+ # are coarse identifiers, not capability claims).
158
+ _FAMILY_RULES: tuple[tuple[str, str], ...] = (
159
+ ("claude", "anthropic"),
160
+ ("fable", "anthropic"),
161
+ ("gemini", "google"),
162
+ ("gpt-", "openai"),
163
+ ("luna", "openai"),
164
+ ("deepseek", "deepseek"),
165
+ ("dsh", "deepseek"),
166
+ ("ds-flash", "deepseek"),
167
+ ("glm", "zhipu"),
168
+ ("kimi", "moonshot"),
169
+ ("moonshot", "moonshot"),
170
+ ("grok", "xai"),
171
+ ("qwen", "alibaba"),
172
+ ("llama", "meta"),
173
+ ("mimo", "minimax"),
174
+ ("nemotron", "nvidia"),
175
+ )
176
+
177
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
178
+
179
+ _REASONING_RANK = {"highest": 5, "high": 4, "medium+": 3, "medium": 2, "low": 1}
180
+ _COST_RANK = {"low": 3, "medium": 2, "high": 1}
181
+ _QUAL_RANK = {"strong": 3, "medium": 2, "weak": 1}
182
+ _CONTEXT_TIEBREAK = {"high": 3, "medium": 2, "low": 1, "unknown": 0}
183
+
184
+
185
+ class AgentMCPUnavailable(RuntimeError):
186
+ """Raised when agent-mcp is not installed or its daemon is not reachable."""
187
+
188
+
189
+ # --------------------------------------------------------------------------
190
+ # detect / require — availability probe (tri-state, OPEN-5)
191
+ # --------------------------------------------------------------------------
192
+
193
+ def _daemon_reachable(port: int | None = None) -> bool:
194
+ """Best-effort probe of the agent-mcp daemon on 127.0.0.1:<port>."""
195
+ try:
196
+ with socket.create_connection(("127.0.0.1", AGENT_MCP_PORT if port is None else port),
197
+ timeout=0.5):
198
+ return True
199
+ except OSError:
200
+ return False
201
+
202
+
203
+ def detect_agent_mcp() -> dict[str, Any]:
204
+ """Probe availability: env marker + daemon health endpoint.
205
+
206
+ Tri-state detection (OPEN-5 — a running daemon must not look like a hard
207
+ failure when the env marker is missing):
208
+
209
+ - "available": env declares AGENT_MCP_INSTALLED AND the daemon is reachable
210
+ - "daemon_reachable_undeclared": daemon reachable but env not declared —
211
+ the host is running agent-mcp; set AGENT_MCP_INSTALLED=1 (via
212
+ ~/.eduevidence/env, shell profile, or host MCP injection) to enable it
213
+ - "unavailable": not installed / fully unavailable (env declares but
214
+ daemon down is also reported here, with the daemon reason)
215
+
216
+ Backward compatible: available / mode / port / home / reasons /
217
+ enhanced_features keep their prior meaning; state / reason / hint are
218
+ additive. Returns an availability report (never raises).
219
+ """
220
+ declared = AGENT_MCP_INSTALLED
221
+ daemon_reachable = _daemon_reachable()
222
+ reasons: list[str] = []
223
+
224
+ if not declared:
225
+ reasons.append("AGENT_MCP_INSTALLED env not set")
226
+ if not daemon_reachable:
227
+ reasons.append(f"daemon not reachable on 127.0.0.1:{AGENT_MCP_PORT}")
228
+
229
+ if declared and daemon_reachable:
230
+ state = "available"
231
+ elif daemon_reachable:
232
+ state = "daemon_reachable_undeclared"
233
+ else:
234
+ state = "unavailable"
235
+
236
+ available = state == "available"
237
+ if state == "daemon_reachable_undeclared":
238
+ reason = (f"daemon reachable on 127.0.0.1:{AGENT_MCP_PORT} but "
239
+ "AGENT_MCP_INSTALLED is not declared")
240
+ hint = ("Agent MCP daemon 可达但未声明安装:设置 AGENT_MCP_INSTALLED=1 "
241
+ "(写入 ~/.eduevidence/env、shell profile,或由宿主 MCP 层注入)"
242
+ "即可启用 agent_mcp_enhanced")
243
+ elif available:
244
+ reason = "agent-mcp installed and daemon reachable"
245
+ hint = ""
246
+ else:
247
+ reason = "agent-mcp not available (see reasons)"
248
+ hint = ""
249
+
250
+ return {
251
+ "available": available,
252
+ "state": state,
253
+ "mode": "agent_mcp_enhanced" if available else "platform_native",
254
+ "port": AGENT_MCP_PORT,
255
+ "home": os.path.expanduser(AGENT_MCP_HOME),
256
+ "reason": reason,
257
+ "reasons": reasons,
258
+ "hint": hint,
259
+ "enhanced_features": {
260
+ "multi_cli_dispatch": available,
261
+ "cross_model_review": available,
262
+ "memory_bank": available,
263
+ } if available else {
264
+ "multi_cli_dispatch": False,
265
+ "cross_model_review": False,
266
+ "memory_bank": False,
267
+ },
268
+ }
269
+
270
+
271
+ def require_agent_mcp() -> dict[str, Any]:
272
+ """Return the availability report, raising if agent-mcp is unavailable."""
273
+ report = detect_agent_mcp()
274
+ if not report["available"]:
275
+ raise AgentMCPUnavailable(AGENT_MCP_UNAVAILABLE)
276
+ return report
277
+
278
+
279
+ # --------------------------------------------------------------------------
280
+ # Phase 5.3 — Model Inventory (scan only the user-approved CLI set)
281
+ # --------------------------------------------------------------------------
282
+
283
+ def _run_cli_cmd(argv: list[str], timeout: int = 20) -> str:
284
+ """Run a CLI discovery command, returning combined stdout+stderr."""
285
+ proc = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
286
+ return (proc.stdout or "") + (proc.stderr or "")
287
+
288
+
289
+ def _parse_slash_lines(text: str) -> list[str]:
290
+ """Parse `opencode models`-style output: one 'provider/model' per line."""
291
+ out: list[str] = []
292
+ for line in text.splitlines():
293
+ line = _ANSI_RE.sub("", line).strip()
294
+ if "/" in line and not line.startswith(("┌", "└", "│", "├", "─")):
295
+ out.append(line)
296
+ return out
297
+
298
+
299
+ def _parse_omp_table(text: str) -> list[dict[str, str]]:
300
+ """Parse `omp models` box table into {model, context, images} records."""
301
+ provider: str | None = None
302
+ records: list[dict[str, str]] = []
303
+ for raw in text.splitlines():
304
+ line = _ANSI_RE.sub("", raw).strip()
305
+ m = re.match(r"^(\S+)\s+\(\d+\)$", line)
306
+ if m:
307
+ provider = m.group(1)
308
+ continue
309
+ if "│" not in line:
310
+ continue
311
+ cells = [c.strip() for c in line.split("│")]
312
+ cells = [c for c in cells if c and not c.startswith(("─", "┌", "└", "├"))]
313
+ if len(cells) < 2 or cells[0] in ("model", "name"):
314
+ continue
315
+ records.append({
316
+ "model": f"{provider}/{cells[0]}" if provider else cells[0],
317
+ "context": cells[1] if len(cells) > 1 else "-",
318
+ "images": cells[-1] if len(cells) > 3 else "-",
319
+ })
320
+ return records
321
+
322
+
323
+ def scan_cli_models(cli: str, *, timeout: int = 20) -> dict[str, Any]:
324
+ """Scan one CLI for models reachable via its own discovery command.
325
+
326
+ Returns {"available": bool, "models": [str], "model_details": {model: {...}}}.
327
+ Unverifiable CLIs report models: [] — never guesses.
328
+ """
329
+ entry: dict[str, Any] = {
330
+ "available": shutil.which(cli) is not None,
331
+ "models": [],
332
+ "model_details": {},
333
+ }
334
+ command = _CLI_MODEL_COMMANDS.get(cli)
335
+ if not entry["available"] or command is None:
336
+ return entry
337
+ argv, kind = command
338
+ try:
339
+ text = _run_cli_cmd(argv, timeout=timeout)
340
+ except (OSError, subprocess.TimeoutExpired):
341
+ return entry
342
+ if kind == "omp_table":
343
+ for rec in _parse_omp_table(text):
344
+ entry["models"].append(rec["model"])
345
+ entry["model_details"][rec["model"]] = {
346
+ "context": rec["context"], "images": rec["images"]}
347
+ else:
348
+ entry["models"] = _parse_slash_lines(text)
349
+ return entry
350
+
351
+
352
+ def scan_available_models(allowed_clis: list[str], *, timeout: int = 20) -> dict[str, Any]:
353
+ """Scan the user-approved CLI set (never the whole machine)."""
354
+ return {cli: scan_cli_models(cli, timeout=timeout) for cli in allowed_clis}
355
+
356
+
357
+ def write_model_inventory(allowed_clis: list[str], runs_dir: str = "runs",
358
+ run_id: str | None = None) -> tuple[Path, dict[str, Any]]:
359
+ """Write runs/<run_id>/model_inventory.json (Phase 5.3).
360
+
361
+ Shape: {"scanned_at": iso8601, "clis": {cli: {"available": bool,
362
+ "models": [str], "model_details": {...}}}}.
363
+ """
364
+ run_id = run_id or os.environ.get("RUN_ID") or datetime.now().strftime("%Y%m%d-%H%M%S")
365
+ out_dir = Path(runs_dir) / run_id
366
+ out_dir.mkdir(parents=True, exist_ok=True)
367
+ inventory: dict[str, Any] = {
368
+ "scanned_at": datetime.now(timezone.utc).isoformat(),
369
+ "clis": scan_available_models(allowed_clis),
370
+ }
371
+ path = out_dir / "model_inventory.json"
372
+ path.write_text(json.dumps(inventory, ensure_ascii=False, indent=2) + "\n",
373
+ encoding="utf-8")
374
+ return path, inventory
375
+
376
+
377
+ # --------------------------------------------------------------------------
378
+ # Phase 5.4 — Model Capability Profile (verifiable facts only; else unknown)
379
+ # --------------------------------------------------------------------------
380
+
381
+ def _family(model: str) -> str:
382
+ """Coarse model family from the model name (for independence checks)."""
383
+ name = model.split("/")[-1].lower()
384
+ for token, family in _FAMILY_RULES:
385
+ if token in name:
386
+ return family
387
+ return "unknown"
388
+
389
+
390
+ def _context_from_label(context: str) -> str | None:
391
+ """'1M'/'203K'/'-' -> 'high'|'medium'|'low'|None."""
392
+ m = re.match(r"([\d.]+)([KM])", context)
393
+ if not m:
394
+ return None
395
+ tokens = int(float(m.group(1)) * (1000 if m.group(2) == "K" else 1_000_000))
396
+ if tokens >= 200_000:
397
+ return "high"
398
+ if tokens >= 32_000:
399
+ return "medium"
400
+ return "low"
401
+
402
+
403
+ def capability_profile(model: str, inventory: dict[str, Any] | None = None) -> dict[str, Any]:
404
+ """Verified capability profile for one model.
405
+
406
+ Every dimension is 'unknown' unless a discovery command provided
407
+ verifiable facts (context window, image input). reasoning/speed/cost/
408
+ structured_output/tool_use are NOT guessed.
409
+ """
410
+ detail: dict[str, Any] = {}
411
+ if inventory:
412
+ for cli_entry in inventory.get("clis", {}).values():
413
+ detail = cli_entry.get("model_details", {}).get(model, {})
414
+ if detail:
415
+ break
416
+ profile: dict[str, Any] = {
417
+ dim: "unknown" for dim in ("reasoning", "speed", "cost",
418
+ "structured_output", "context",
419
+ "tool_use", "multimodal")
420
+ }
421
+ context = detail.get("context")
422
+ if context:
423
+ level = _context_from_label(context)
424
+ if level:
425
+ profile["context"] = level
426
+ if detail.get("images") in ("yes", "no"):
427
+ profile["multimodal"] = detail["images"]
428
+ profile["family"] = _family(model)
429
+ profile["verified_sources"] = sorted(k for k in detail if detail[k] not in ("", "-"))
430
+ return profile
431
+
432
+
433
+ # --------------------------------------------------------------------------
434
+ # Phase 6-7 — Model Recommender (Role Requirements x Capability Profile)
435
+ # --------------------------------------------------------------------------
436
+
437
+ def _score_dimension(dimension: str, requirement: Any, capability: str) -> float:
438
+ """1.0 meets / 0.5 one level below or unknown / 0.0 clearly below."""
439
+ if requirement is None:
440
+ return 1.0
441
+ if capability == "unknown":
442
+ return 0.5
443
+ rank = {"reasoning": _REASONING_RANK, "speed": _REASONING_RANK,
444
+ "context": _REASONING_RANK, "cost": _COST_RANK,
445
+ "structured_output": _QUAL_RANK, "tool_use": _QUAL_RANK,
446
+ "multimodal": _QUAL_RANK}[dimension]
447
+ req_rank = rank.get(requirement, 0)
448
+ cap_rank = rank.get(capability, 0)
449
+ if cap_rank >= req_rank:
450
+ return 1.0
451
+ if cap_rank >= req_rank - 1:
452
+ return 0.5
453
+ return 0.0
454
+
455
+
456
+ def score_role_proposals(role: str, clis: list[str],
457
+ inventory: dict[str, Any]) -> list[dict[str, Any]]:
458
+ """Score every (cli, model) candidate for one role; best first."""
459
+ reqs = ROLE_REQUIREMENTS[role]
460
+ dims = ("reasoning", "speed", "cost", "structured_output",
461
+ "context", "tool_use", "multimodal")
462
+ proposals: list[dict[str, Any]] = []
463
+ for cli in clis:
464
+ entry = inventory.get("clis", {}).get(cli, {})
465
+ for model in entry.get("models", []):
466
+ cap = capability_profile(model, inventory)
467
+ score = sum(_score_dimension(d, reqs[d], cap[d]) for d in dims) / len(dims)
468
+ notes: list[str] = []
469
+ if cap["context"] != "unknown":
470
+ notes.append(f"verified context {cap['context']}")
471
+ if cap["multimodal"] != "unknown":
472
+ notes.append(f"verified multimodal {cap['multimodal']}")
473
+ if reqs.get("independence"):
474
+ notes.append(f"family {cap['family']}")
475
+ if reqs.get("quantitative") == "preferred":
476
+ notes.append("quantitative output preferred (unverified)")
477
+ proposals.append({
478
+ "role": role, "cli": cli, "model": model, "family": cap["family"],
479
+ "score": round(score, 3), "notes": notes, "capabilities": cap,
480
+ })
481
+ proposals.sort(key=lambda p: (-p["score"],
482
+ -_CONTEXT_TIEBREAK[p["capabilities"]["context"]],
483
+ p["model"]))
484
+ return proposals
485
+
486
+
487
+ def _cost_class(model: str | None) -> str:
488
+ """Cost class of a model; nothing verifiable -> 'unknown'."""
489
+ return "unknown"
490
+
491
+
492
+ def build_recommendation_table(allowed_clis: list[str],
493
+ inventory: dict[str, Any]) -> dict[str, Any]:
494
+ """User-facing recommendation table (Phase 6-7).
495
+
496
+ One row per role: {role, cli, model, reason, task}. Also summary:
497
+ role_count, concurrency, cross_model_review, memory_bank, cost_class.
498
+ """
499
+ recommendations: list[dict[str, Any]] = []
500
+ for role in ROLE_REQUIREMENTS:
501
+ proposals = score_role_proposals(role, allowed_clis, inventory)
502
+ if not proposals:
503
+ recommendations.append({
504
+ "role": role, "cli": None, "model": None, "family": "unknown",
505
+ "score": None,
506
+ "reason": "no verified models for allowed CLIs; user must "
507
+ "specify cli+model in the approval",
508
+ "task": ROLE_TASKS[role],
509
+ })
510
+ continue
511
+ best = proposals[0]
512
+ reasons = [f"score {best['score']:.2f} over verified capabilities"]
513
+ reasons.extend(best["notes"])
514
+ recommendations.append({
515
+ "role": role, "cli": best["cli"], "model": best["model"],
516
+ "family": best["family"], "score": best["score"],
517
+ "reason": "; ".join(reasons), "task": ROLE_TASKS[role],
518
+ })
519
+
520
+ families = {r["role"]: r.get("family") for r in recommendations}
521
+ skeptic_family = families.get("skeptic")
522
+ judge_family = families.get("evidence-judge")
523
+ cross_model = bool(skeptic_family and judge_family
524
+ and skeptic_family != judge_family)
525
+ cost_classes = {_cost_class(r["model"]) for r in recommendations if r["model"]}
526
+ cost_class = "Unknown" if not cost_classes or cost_classes == {"unknown"} \
527
+ else "/".join(sorted(cost_classes))
528
+
529
+ return {
530
+ "recommendations": recommendations,
531
+ "summary": {
532
+ "role_count": len(recommendations),
533
+ "concurrency": len(recommendations),
534
+ "cross_model_review": cross_model,
535
+ "memory_bank": True,
536
+ "cost_class": cost_class,
537
+ "note": "scores use only verified facts; unknown capabilities "
538
+ "score 0.5 and never disqualify — the final cli/model "
539
+ "requires explicit user confirmation (safe_spawn gate).",
540
+ },
541
+ }
542
+
543
+
544
+ # --------------------------------------------------------------------------
545
+ # Phase 8 — Approval Gate (agent_mcp_approval.json)
546
+ # --------------------------------------------------------------------------
547
+
548
+ def _role_mapping_hash(roles: dict[str, Any]) -> str:
549
+ """Stable SHA-256 of the canonical role->{cli, model} mapping."""
550
+ payload = json.dumps(roles, sort_keys=True, ensure_ascii=False,
551
+ separators=(",", ":"))
552
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
553
+
554
+
555
+ def build_approval_record(roles: dict[str, Any], allowed_clis: list[str],
556
+ *, budget: dict[str, Any] | None = None,
557
+ provider: str | None = None) -> dict[str, Any]:
558
+ """Build the approval record the user confirms.
559
+
560
+ roles: {role: {"cli": str, "model": str}} — the exact mapping that will
561
+ be enforced by safe_spawn(). Any later change to this mapping (new CLI,
562
+ replaced model, new role, modified mapping) invalidates the hash and
563
+ requires re-confirmation.
564
+ """
565
+ canonical = json.loads(json.dumps(roles, sort_keys=True))
566
+ return {
567
+ "approved": True,
568
+ "approved_at": datetime.now(timezone.utc).isoformat(),
569
+ "allowed_clis": sorted(allowed_clis),
570
+ "role_mapping_hash": _role_mapping_hash(canonical),
571
+ "roles": canonical,
572
+ "budget": budget or {},
573
+ "provider": provider,
574
+ "schema_version": 1,
575
+ }
576
+
577
+
578
+ def write_approval(path: str | Path, roles: dict[str, Any], allowed_clis: list[str],
579
+ *, budget: dict[str, Any] | None = None,
580
+ provider: str | None = None) -> dict[str, Any]:
581
+ """Persist the user-approved mapping to agent_mcp_approval.json."""
582
+ record = build_approval_record(roles, allowed_clis, budget=budget, provider=provider)
583
+ Path(path).write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n",
584
+ encoding="utf-8")
585
+ return record
586
+
587
+
588
+ def load_approval(path: str | Path) -> dict[str, Any] | None:
589
+ """Load an approval file; missing/corrupt -> None (gate closed)."""
590
+ try:
591
+ return json.loads(Path(path).read_text(encoding="utf-8"))
592
+ except (OSError, json.JSONDecodeError):
593
+ return None
594
+
595
+
596
+ def is_approval_current(approval: dict[str, Any] | None, roles: dict[str, Any],
597
+ allowed_clis: list[str], *, budget: dict[str, Any] | None = None,
598
+ provider: str | None = None) -> tuple[bool, list[str]]:
599
+ """True when the approval still matches the current proposal.
600
+
601
+ Returns (ok, changes); any change requires re-confirmation: 新增 CLI /
602
+ 替换模型 / 新增角色 / 修改映射 / 提高 budget / 新 provider.
603
+ """
604
+ changes: list[str] = []
605
+ if not approval or not approval.get("approved"):
606
+ changes.append("approval missing or not approved")
607
+ else:
608
+ stored = approval.get("roles") or {}
609
+ if approval.get("role_mapping_hash") != _role_mapping_hash(stored):
610
+ changes.append("approval file tampered (role_mapping_hash mismatch)")
611
+ if _role_mapping_hash(roles) != approval.get("role_mapping_hash"):
612
+ changes.append("role mapping changed (new CLI / replaced model / "
613
+ "new role / modified mapping)")
614
+ if sorted(approval.get("allowed_clis", [])) != sorted(allowed_clis):
615
+ changes.append("allowed_clis changed")
616
+ if budget is not None and approval.get("budget") != budget:
617
+ changes.append("budget changed")
618
+ if provider is not None and approval.get("provider") != provider:
619
+ changes.append("provider changed")
620
+ return (not changes, changes)
621
+
622
+
623
+ def _check_approval(approval: dict[str, Any] | None, role: str, *,
624
+ target_cli: str | None = None, model: str | None = None,
625
+ allowed_clis: list[str] | None = None) -> str | None:
626
+ """Gate check: None when allowed, else AGENT_MCP_APPROVAL_REQUIRED.
627
+
628
+ Order: approval exists -> hash matches -> CLI allowed -> model approved
629
+ -> role approved.
630
+ """
631
+ if not approval or not approval.get("approved"):
632
+ return AGENT_MCP_APPROVAL_REQUIRED
633
+ stored = approval.get("roles") or {}
634
+ if approval.get("role_mapping_hash") != _role_mapping_hash(stored):
635
+ return AGENT_MCP_APPROVAL_REQUIRED
636
+ mapping = stored.get(role)
637
+ if mapping is None:
638
+ return AGENT_MCP_APPROVAL_REQUIRED
639
+ cli = mapping["cli"] if target_cli is None else target_cli
640
+ mdl = mapping["model"] if model is None else model
641
+ if not cli or not mdl:
642
+ return AGENT_MCP_APPROVAL_REQUIRED
643
+ if allowed_clis is not None and cli not in allowed_clis:
644
+ return AGENT_MCP_APPROVAL_REQUIRED
645
+ if cli != mapping.get("cli") or mdl != mapping.get("model"):
646
+ return AGENT_MCP_APPROVAL_REQUIRED
647
+ return None
648
+
649
+
650
+ # --------------------------------------------------------------------------
651
+ # Phase 9 — Spawn Guard: the ONLY spawn entry point
652
+ # --------------------------------------------------------------------------
653
+
654
+ def safe_spawn(role: str, prompt: str, approval: dict[str, Any] | None, *,
655
+ target_cli: str | None = None, model: str | None = None,
656
+ allowed_clis: list[str] | None = None,
657
+ cwd: str = ".", permission_mode: str = "plan",
658
+ context_mode: str = "compact", summary_chars: int | None = None,
659
+ timeout_seconds: int = 1800, cache_ttl: int = 0,
660
+ token_budget: int | None = None) -> dict[str, Any]:
661
+ """UNIFIED spawn entry. Business code MUST NOT call spawn directly.
662
+
663
+ Gate order: approval exists -> proposal hash matches -> CLI allowed ->
664
+ model approved -> role approved -> build spawn payload. Any failure
665
+ returns {"status": AGENT_MCP_APPROVAL_REQUIRED, "spawn_call": None}.
666
+ CLI/model default to the approved role mapping; explicit values must
667
+ equal the approved ones.
668
+ """
669
+ failure = _check_approval(approval, role, target_cli=target_cli, model=model,
670
+ allowed_clis=allowed_clis)
671
+ if failure is not None:
672
+ return {"status": failure, "spawn_call": None}
673
+ mapping = approval["roles"][role] # type: ignore[index]
674
+ return {
675
+ "status": "READY",
676
+ "spawn_call": build_spawn_call(
677
+ role, prompt, target_cli=mapping["cli"], model=mapping["model"],
678
+ cwd=cwd, permission_mode=permission_mode, context_mode=context_mode,
679
+ summary_chars=summary_chars, timeout_seconds=timeout_seconds,
680
+ cache_ttl=cache_ttl, token_budget=token_budget),
681
+ }
682
+
683
+
684
+ # --------------------------------------------------------------------------
685
+ # Payload builders (no hardcoded defaults — cli/model always explicit)
686
+ # --------------------------------------------------------------------------
687
+
688
+ def _routing_class(role: str) -> str:
689
+ """fast / strong / independent class derived from role requirements."""
690
+ reqs = ROLE_REQUIREMENTS.get(role, {})
691
+ if reqs.get("independence"):
692
+ return "independent"
693
+ if reqs.get("speed") == "high":
694
+ return "fast"
695
+ return "strong"
696
+
697
+
698
+ def build_spawn_call(role: str, prompt: str, *, target_cli: str, model: str,
699
+ cwd: str = ".", permission_mode: str = "plan",
700
+ context_mode: str = "compact", summary_chars: int | None = None,
701
+ timeout_seconds: int = 1800, cache_ttl: int = 0,
702
+ token_budget: int | None = None) -> dict[str, Any]:
703
+ """Build a spawn_agent tool-call payload matching the agent-mcp MCP contract.
704
+
705
+ target_cli and model are REQUIRED — there are no hardcoded defaults.
706
+ Approved values come from the user-confirmed mapping via safe_spawn().
707
+ """
708
+ if role not in ROLE_REQUIREMENTS:
709
+ raise ValueError(f"unknown role {role!r}; known: {sorted(ROLE_REQUIREMENTS)}")
710
+ if not target_cli or not model:
711
+ raise ValueError("target_cli and model are required (no hardcoded "
712
+ "defaults); use safe_spawn() with a user approval")
713
+ routing_role = _routing_class(role)
714
+ if summary_chars is None:
715
+ summary_chars = 600 if routing_role == "fast" else 2000
716
+ return {
717
+ "tool": "spawn_agent",
718
+ "arguments": {
719
+ "task_name": role,
720
+ "prompt": prompt,
721
+ "target_cli": target_cli,
722
+ "model": model,
723
+ "cwd": cwd,
724
+ "permission_mode": permission_mode,
725
+ "context_mode": context_mode,
726
+ "summary_chars": summary_chars,
727
+ "timeout_seconds": timeout_seconds,
728
+ "cache_ttl": cache_ttl,
729
+ "token_budget": token_budget,
730
+ },
731
+ "routing_role": routing_role,
732
+ }
733
+
734
+
735
+ def build_memory_store_call(content: str, *, kind: str = "research", key: str = "",
736
+ tags: list[str] | None = None) -> dict[str, Any]:
737
+ """Build a memory_store payload (Memory Bank, 总体实施计划 §26 + v2 方案 §26)."""
738
+ return {
739
+ "tool": "memory_store",
740
+ "arguments": {
741
+ "content": content,
742
+ "kind": kind,
743
+ "key": key,
744
+ "tags": tags or [],
745
+ },
746
+ }
747
+
748
+
749
+ def build_memory_recall_call(query: str, *, kind: str = "research", limit: int = 5) -> dict[str, Any]:
750
+ """Build a memory_recall payload (Memory Bank)."""
751
+ return {
752
+ "tool": "memory_recall",
753
+ "arguments": {
754
+ "query": query,
755
+ "kind": kind,
756
+ "limit": limit,
757
+ },
758
+ }
759
+
760
+
761
+ def cross_model_review(draft_verdict: dict[str, Any], *, target_cli: str, model: str,
762
+ approval: dict[str, Any] | None = None,
763
+ **_: Any) -> dict[str, Any]:
764
+ """Cross-Model Review orchestration (总体实施计划 §25).
765
+
766
+ Flow: Primary Analysis -> Draft Verdict -> Independent Review -> Judge ->
767
+ Final Verdict. The independent reviewer runs under the 'skeptic' role with
768
+ the cli/model the user approved for it. Without explicit user approval the
769
+ review is NOT spawned: status AGENT_MCP_APPROVAL_REQUIRED. When agent-mcp
770
+ is unavailable, degrades to a native self-review plan marked
771
+ AGENT_MCP_UNAVAILABLE (no hard failure).
772
+ """
773
+ report = detect_agent_mcp()
774
+ reviewer_prompt = (
775
+ "你是 EduEvidence 的独立交叉审核者(Independent Reviewer)。"
776
+ "以下是一份 Draft Verdict。请以独立模型视角审查:"
777
+ "agreement / disagreements / unsupported_claims / missed_counterevidence / "
778
+ "scope_violations / methodology_issues / confidence_adjustment / "
779
+ "required_revision / final_recommendation。"
780
+ "输出 CrossModelReview JSON(见 schemas/cross-model-review.schema.json)。\n\n"
781
+ f"DRAFT VERDICT:\n{json.dumps(draft_verdict, ensure_ascii=False, indent=2)}"
782
+ )
783
+ if not report["available"]:
784
+ return {
785
+ "status": AGENT_MCP_UNAVAILABLE,
786
+ "degraded_to": "native_self_review",
787
+ "note": "agent-mcp 未安装/不可达:退化为单 Agent 自审(Platform Native Mode),"
788
+ "不产生独立模型交叉审核。",
789
+ "review_plan": {"reviewer_prompt": reviewer_prompt,
790
+ "independent_model": model},
791
+ }
792
+ if approval is None:
793
+ return {
794
+ "status": AGENT_MCP_APPROVAL_REQUIRED,
795
+ "note": "cross-model review needs explicit user approval "
796
+ "(safe_spawn gate); nothing was spawned.",
797
+ "review_plan": {"reviewer_prompt": reviewer_prompt,
798
+ "independent_model": model},
799
+ }
800
+ result = safe_spawn("skeptic", reviewer_prompt, approval,
801
+ target_cli=target_cli, model=model,
802
+ context_mode="full", summary_chars=2000)
803
+ if result["status"] != "READY":
804
+ return {
805
+ "status": result["status"],
806
+ "review_plan": {"reviewer_prompt": reviewer_prompt,
807
+ "independent_model": model},
808
+ }
809
+ return {
810
+ "status": "READY",
811
+ "spawn_call": result["spawn_call"],
812
+ "flow": ["primary_analysis", "draft_verdict", "independent_review",
813
+ "judge", "final_verdict"],
814
+ "report": report,
815
+ }
816
+
817
+
818
+ def memory_bank_guide() -> dict[str, str]:
819
+ """Memory Bank fields for long-running teaching research (v2 方案 §26)."""
820
+ return {
821
+ "course_profile": "Course Profile",
822
+ "learner_profile": "Learner Profile",
823
+ "research_questions": "Research Questions",
824
+ "reviewed_sources": "Reviewed Sources",
825
+ "accepted_evidence": "Accepted Evidence",
826
+ "rejected_evidence": "Rejected Evidence",
827
+ "previous_verdict": "Previous Verdict",
828
+ "pilot_design": "Pilot Design",
829
+ "pilot_results": "Pilot Results",
830
+ "open_questions": "Open Questions",
831
+ }
832
+
833
+
834
+ if __name__ == "__main__":
835
+ import argparse
836
+ import sys
837
+
838
+ parser = argparse.ArgumentParser(description="Agent MCP detect / inventory / recommend")
839
+ parser.add_argument("--inventory", nargs="*", default=None, metavar="CLI",
840
+ help="scan these CLIs and write runs/<run_id>/model_inventory.json")
841
+ parser.add_argument("--recommend", nargs="*", default=None, metavar="CLI",
842
+ help="print the user-facing recommendation table for these CLIs")
843
+ args = parser.parse_args()
844
+
845
+ out: dict[str, Any] = {"detect": detect_agent_mcp()}
846
+ if args.inventory is not None:
847
+ path, inventory = write_model_inventory(args.inventory)
848
+ out["model_inventory"] = {"path": str(path),
849
+ "clis": {c: {"available": e["available"],
850
+ "models": e["models"]}
851
+ for c, e in inventory["clis"].items()}}
852
+ if args.recommend is not None:
853
+ _, inventory = write_model_inventory(args.recommend)
854
+ out["recommendation"] = build_recommendation_table(args.recommend, inventory)
855
+ print(json.dumps(out, ensure_ascii=False, indent=2))
856
+ sys.exit(0)