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,1456 @@
1
+ #!/usr/bin/env python3
2
+ """orchestrator.py — Run Orchestrator (Phase 11) + Resume (Phase 32) + Failure Matrix (Phase 33).
3
+
4
+ The orchestrator owns the RUN mechanics only — stage routing, workspace state,
5
+ schema gates, resource routing, execution backend selection, artifacts and
6
+ failure handling. It never performs domain reasoning (framing, retrieval,
7
+ extraction, skepticism, audit or intervention design are external-agent
8
+ stages); the two deterministic stages it executes locally are:
9
+
10
+ adjudicate — Pre-Verdict Gate (scripts/pre_verdict_gate.py) + deterministic
11
+ confidence (scripts/compute_confidence.py) producing
12
+ final_verdict.json from raw_verdict.json + evidence.jsonl
13
+ present — assemble result.json from the workspace artifacts
14
+ (decision = final_verdict.json; claims carry claim_id per
15
+ report-result.schema.json)
16
+
17
+ Stage machine (execution_plan.json / state.json):
18
+
19
+ frame -> retrieve -> extract -> challenge -> audit -> adjudicate
20
+ -> intervene -> evaluate -> present
21
+
22
+ Each stage writes exactly one primary artifact and is schema-gated against
23
+ schemas/*. When the artifact is missing the orchestrator either seeds it from
24
+ a demo pack (--demo-pack, tests/demo mode) or leaves a task brief for an
25
+ external agent and marks the stage pending. Resume (Phase 32) continues from
26
+ the first non-completed stage using state.json; failures are mapped through
27
+ the FAILURE_MATRIX (Phase 33).
28
+
29
+ CLI (entry points `eduevidence` via eduevidence_cli.py, or directly):
30
+
31
+ python scripts/orchestrator.py run --question "..." --depth deep
32
+ python scripts/orchestrator.py run --question "..." --demo-pack examples/ai-coding-assistant
33
+ python scripts/orchestrator.py resume --run-id 20260812-103000
34
+ python scripts/orchestrator.py status --run-id 20260812-103000
35
+ python scripts/orchestrator.py list
36
+ python scripts/orchestrator.py gate --run-id 20260812-103000
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import copy
42
+ import json
43
+ import logging
44
+ import os
45
+ import sys
46
+ from pathlib import Path
47
+ from typing import Any
48
+
49
+ ROOT = Path(__file__).resolve().parent.parent
50
+ for _p in (str(ROOT), str(ROOT / "scripts")):
51
+ if _p not in sys.path:
52
+ sys.path.insert(0, _p)
53
+
54
+ from run_workspace import (RESOURCE_POLICY_VERSION, STAGES, RunWorkspace, # noqa: E402
55
+ load_json, load_jsonl, next_run_id, save_jsonl)
56
+ from pre_verdict_gate import apply_enforcement, evaluate_workspace # noqa: E402
57
+ from engine.versions import ENGINE_VERSION # noqa: E402
58
+ from engine.log import enable_console_logging, get_log # noqa: E402
59
+
60
+ log = get_log("orchestrator")
61
+
62
+ DEPTH_ALIASES = {"quick": "S", "standard": "M", "deep": "L"}
63
+ DEPTHS = ("S", "M", "L")
64
+
65
+ #: Stage -> primary artifact + schema gate + whether it is locally executable.
66
+ STAGE_SPEC: dict[str, dict[str, Any]] = {
67
+ "frame": {"artifact": "frame.json", "schema": "education-frame.schema.json", "jsonl": False, "local": False},
68
+ "retrieve": {"artifact": "sources.jsonl", "schema": "source.schema.json", "jsonl": True, "local": False},
69
+ "extract": {"artifact": "evidence.jsonl", "schema": "evidence.schema.json", "jsonl": True, "local": False},
70
+ "challenge": {"artifact": "skeptic.json", "schema": None, "jsonl": False, "local": False},
71
+ "audit": {"artifact": "methodology.json", "schema": "methodology.schema.json", "jsonl": False, "local": False},
72
+ "adjudicate": {"artifact": "final_verdict.json", "schema": "verdict.schema.json", "jsonl": False, "local": True},
73
+ "intervene": {"artifact": "intervention.json", "schema": "intervention.schema.json", "jsonl": False, "local": False},
74
+ "evaluate": {"artifact": "evaluation.json", "schema": "evaluation.schema.json", "jsonl": False, "local": False},
75
+ "present": {"artifact": "result.json", "schema": "report-result.schema.json", "jsonl": False, "local": True},
76
+ }
77
+
78
+ #: Phase 33 — canonical failure -> handling-action mapping. Extends the
79
+ #: retrieval-layer taxonomy (retrieval/failures.py) with orchestration states.
80
+ FAILURE_MATRIX: dict[str, dict[str, Any]] = {
81
+ "TOOL_FAILURE": {"action": "retry_with_fallback_tool", "retry": True,
82
+ "note": "a tool invocation failed; retry with an alternate tool before aborting"},
83
+ "SCHEMA_INVALID": {"action": "block_stage_advance_and_fix_artifact", "retry": False,
84
+ "note": "stage artifact violates its schema gate; regenerate or repair the artifact"},
85
+ "STAGE_ARTIFACT_MISSING": {"action": "write_brief_and_mark_pending", "retry": False,
86
+ "note": "stage waits for an external agent; resume continues when the artifact appears"},
87
+ "GATE_CRITICAL_FAILURE": {"action": "cap_confidence_and_force_pilot_or_insufficient", "retry": False,
88
+ "note": "Pre-Verdict Gate critical failures forbid high confidence; verdict is capped"},
89
+ "SEARCH_NO_RESULT": {"action": "rerun_search_with_broader_terms", "retry": True,
90
+ "note": "no results for the query; broaden terms or switch discovery provider"},
91
+ "SEARCH_LOW_QUALITY": {"action": "widen_query_or_accept_lower_authority_tier", "retry": True,
92
+ "note": "results are low quality; widen the query or accept a lower authority tier"},
93
+ "FETCH_FAILED": {"action": "alternate_fetch_provider_then_alternate_source", "retry": True,
94
+ "note": "degradation chain exhausted; do not retry the same URL, return to Discovery"},
95
+ "FETCH_PARTIAL": {"action": "rule_confirm_or_human_confirm_before_extraction", "retry": False,
96
+ "note": "content partially readable; require rule/human confirmation before extraction"},
97
+ "SOURCE_INVALID": {"action": "discard_and_find_alternate_source", "retry": False,
98
+ "note": "source does not validate; discard and find an alternate source"},
99
+ "SOURCE_DUPLICATE": {"action": "merge_keep_highest_authority", "retry": False,
100
+ "note": "same paper behind mirror URLs; merge and keep the highest-authority entry"},
101
+ "UNSUPPORTED_CLAIM": {"action": "downgrade_claim_or_drop", "retry": False,
102
+ "note": "claim cannot be bound to a verifiable source; downgrade or drop"},
103
+ "CONFLICT_UNRESOLVED": {"action": "stay_uncertain_do_not_force_adjudication", "retry": False,
104
+ "note": "conflicting evidence without resolution; remain uncertain"},
105
+ "SCOPE_MISMATCH": {"action": "shrink_conclusion_scope", "retry": False,
106
+ "note": "conclusion scope exceeds evidence scope; shrink the conclusion"},
107
+ "METHODOLOGY_TOO_WEAK": {"action": "do_not_use_as_support", "retry": False,
108
+ "note": "methodology audit fails; the study cannot support claims"},
109
+ "INSUFFICIENT_EVIDENCE": {"action": "mark_insufficient_evidence", "retry": False,
110
+ "note": "evidence base is too thin; output INSUFFICIENT EVIDENCE"},
111
+ "AGENT_MCP_UNAVAILABLE": {"action": "degrade_to_platform_native_mode", "retry": False,
112
+ "note": "agent-mcp not reachable; fall back to Mode A semantics"},
113
+ "REPORT_INVALID": {"action": "block_publish_rerun_render", "retry": True,
114
+ "note": "rendered report fails validation; block publishing and rerun the render"},
115
+ "DEMO_PACK_MISSING": {"action": "write_brief_and_mark_pending", "retry": False,
116
+ "note": "requested demo seed not in the demo pack; treat as a normal pending stage"},
117
+ # -- states documented in docs/failure-matrix.md (Phase 33), kept as aliases
118
+ # so the code matrix is a superset of the documented failure taxonomy.
119
+ "INSUFFICIENT_SOURCES": {"action": "supplement_search_or_mark_insufficient", "retry": True,
120
+ "note": "too few/direct/strong sources to support a conclusion; supplement search or output INSUFFICIENT"},
121
+ "NEEDS_USER_CONTEXT": {"action": "request_user_context_before_continuing", "retry": False,
122
+ "note": "minimal learner/course/intervention/outcome inputs missing; ask the user, never guess defaults"},
123
+ "AGENT_MCP_APPROVAL_REQUIRED": {"action": "do_not_spawn_confirm_model_table_first", "retry": False,
124
+ "note": "agent-mcp installed but model table not user-confirmed; no spawn until approved"},
125
+ "PRE_VERDICT_FAILED": {"action": "fix_pre_verdict_artifacts_rerun_gate", "retry": True,
126
+ "note": "pre-verdict prerequisites failed (verdict schema / cross-model review / methodology); fix and re-run the gate"},
127
+ }
128
+
129
+ _STAGE_BRIEFS: dict[str, str] = {
130
+ "frame": ("Structure the raw education question into an Education Research Frame "
131
+ "(learner/course/intervention/comparison/outcomes/context/scope). "
132
+ "Write frame.json."),
133
+ "retrieve": ("Search for candidate sources within the frame scope; record Source Objects "
134
+ "(source_id, title, canonical_url, authority_level) in sources.jsonl "
135
+ "(one JSON object per line) and store fetched content under fetch/."),
136
+ "extract": ("Extract claim-level Evidence Objects from the retrieved sources "
137
+ "(evidence_id, source_id, claim, outcome_type, direction, source_location) "
138
+ "into evidence.jsonl."),
139
+ "challenge": ("Act as the Skeptic: actively search for counter-evidence, null results and "
140
+ "confounders; write skeptic.json with search_performed=true and the findings."),
141
+ "audit": ("Method-review every study: methodology.json with audit_items, "
142
+ "task_vs_learning_guard and a PASS/CONCERN/FAIL verdict."),
143
+ "adjudicate": ("Judge the evidence: write raw_verdict.json (model verdict). The orchestrator "
144
+ "then runs the Pre-Verdict Gate and deterministic confidence to produce "
145
+ "final_verdict.json."),
146
+ "intervene": ("Design the minimal verifiable teaching intervention (phased pilot, "
147
+ "stop conditions, evidence alignment); write intervention.json."),
148
+ "evaluate": ("Design the evaluation plan (baseline/post/retention/transfer, task vs learning "
149
+ "separation); write evaluation.json."),
150
+ "present": ("Translate result.json into result.zh.json and render report_spec.json / "
151
+ "report.html via the visualization layer."),
152
+ }
153
+
154
+
155
+ def _utc_now() -> str:
156
+ from run_workspace import utc_now
157
+ return utc_now()
158
+
159
+
160
+ def handle_failure(token: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
161
+ """Phase 33 failure routing: canonical token -> handling action."""
162
+ entry = FAILURE_MATRIX.get(token)
163
+ if entry is None:
164
+ raise ValueError(f"unknown failure state {token!r}; known: {sorted(FAILURE_MATRIX)}")
165
+ plan = {"state": token, **entry}
166
+ if context:
167
+ plan["context"] = context
168
+ return plan
169
+
170
+
171
+ # ---------------------------------------------------------- workspace plans
172
+
173
+
174
+ def init_run(
175
+ runs_dir: Path,
176
+ question: str,
177
+ *,
178
+ depth: str = "M",
179
+ run_id: str | None = None,
180
+ approve_agent_mcp: bool = False,
181
+ scp_available: bool | None = None,
182
+ ) -> RunWorkspace:
183
+ """Create the run workspace + manifest + planning artifacts (Phase 11-13)."""
184
+ depth = DEPTH_ALIASES.get(depth, depth)
185
+ if depth not in DEPTHS:
186
+ raise ValueError(f"unknown depth {depth!r}; use quick/standard/deep or S/M/L")
187
+
188
+ try:
189
+ from integrations.agent_mcp import detect_agent_mcp
190
+ detection = detect_agent_mcp()
191
+ agent_mode = detection["mode"]
192
+ agent_available = detection["available"]
193
+ except Exception:
194
+ agent_mode, agent_available = "platform_native", False
195
+
196
+ from run_workspace import build_manifest
197
+
198
+ if run_id is None:
199
+ run_id = next_run_id(runs_dir)
200
+ ws = RunWorkspace(runs_dir, run_id)
201
+ ws.create()
202
+
203
+ manifest = build_manifest(
204
+ run_id, question,
205
+ execution_mode=agent_mode,
206
+ agent_mcp_available=agent_available,
207
+ agent_mcp_approved=approve_agent_mcp,
208
+ root=ROOT,
209
+ )
210
+ ws.save_manifest(manifest)
211
+
212
+ state = ws.load_state()
213
+ state.update({"run_id": run_id, "question": question, "depth": depth,
214
+ "status": "running", "current_stage": STAGES[0]})
215
+ ws.save_state(state)
216
+
217
+ # -- planning artifacts ------------------------------------------------
218
+ capability_plan = {
219
+ "run_id": run_id,
220
+ "depth": depth,
221
+ "required_capabilities": [
222
+ "education_framing", "evidence_retrieval", "evidence_extraction",
223
+ "skeptic_review", "methodology_audit", "adjudication",
224
+ "intervention_design", "evaluation_design", "bilingual_reporting"],
225
+ "local_capabilities": {
226
+ "schema_validation": True, "deterministic_confidence": True,
227
+ "claim_audit": True, "evidence_matrix": True,
228
+ "pre_verdict_gate": True, "result_assembly": True,
229
+ "complexity_gate": True},
230
+ "external_capabilities": {
231
+ "web_fetch": True, "smart_web_fetch": True, "search": True,
232
+ "agent_mcp_dispatch": agent_available},
233
+ }
234
+ resource_plan = {
235
+ "run_id": run_id,
236
+ "execution_mode": agent_mode,
237
+ "resource_policy_version": RESOURCE_POLICY_VERSION,
238
+ "token_budget_per_stage": {
239
+ "frame": 4000, "retrieve": 8000, "extract": 12000, "challenge": 8000,
240
+ "audit": 8000, "adjudicate": 8000, "intervene": 6000,
241
+ "evaluate": 6000, "present": 6000},
242
+ "max_concurrent_agents": {"S": 0, "M": 2, "L": 4}[depth],
243
+ "timeouts_s": {"fetch": 20, "agent": 1800},
244
+ }
245
+ execution_plan = {
246
+ "run_id": run_id,
247
+ "depth": depth,
248
+ "stages": [
249
+ {"name": s, "status": "pending",
250
+ "artifact": STAGE_SPEC[s]["artifact"],
251
+ "schema": STAGE_SPEC[s]["schema"],
252
+ "mode": "local" if STAGE_SPEC[s]["local"] else "external"}
253
+ for s in STAGES],
254
+ }
255
+ model_inventory = {
256
+ "run_id": run_id,
257
+ "execution_mode": agent_mode,
258
+ "routing": {
259
+ "education-planner": "strong/reasoning",
260
+ "evidence-retriever": "fast/low-cost",
261
+ "evidence-analyst": "strong/structured",
262
+ "skeptic": "independent/reasoning",
263
+ "method-reviewer": "strong/reasoning",
264
+ "evidence-judge": "strong/reasoning",
265
+ "intervention-designer": "strong/reasoning",
266
+ "evaluation-designer": "strong/reasoning"} if agent_available else {},
267
+ "agents": {},
268
+ }
269
+ agent_mcp_approval = {
270
+ "run_id": run_id,
271
+ "agent_mcp_available": agent_available,
272
+ "approved": approve_agent_mcp,
273
+ "approved_at": _utc_now() if approve_agent_mcp else None,
274
+ "mode": agent_mode,
275
+ "reason": ("user-approved via --approve-agent-mcp"
276
+ if approve_agent_mcp else "not yet approved; runs in platform-native mode"),
277
+ }
278
+
279
+ for name, data in (("capability_plan", capability_plan),
280
+ ("resource_plan", resource_plan),
281
+ ("execution_plan", execution_plan),
282
+ ("model_inventory", model_inventory),
283
+ ("agent_mcp_approval", agent_mcp_approval)):
284
+ (ws.path / f"{name}.json").write_text(
285
+ json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
286
+
287
+ ws.trace("run_initialized", detail=f"depth={depth} mode={agent_mode} question={question[:120]}")
288
+ return ws
289
+
290
+
291
+ # ------------------------------------------------------------- schema gates
292
+
293
+
294
+ def _load_artifact(ws: RunWorkspace, artifact: str) -> list[dict[str, Any]]:
295
+ path = ws.path / artifact
296
+ if not path.is_file():
297
+ return []
298
+ if artifact.endswith(".jsonl"):
299
+ return load_jsonl(path)
300
+ data = load_json(path)
301
+ return [data] if data else []
302
+
303
+
304
+ def schema_gate(ws: RunWorkspace, stage: str) -> dict[str, Any]:
305
+ """Validate a stage's primary artifact against its schema. Never raises."""
306
+ spec = STAGE_SPEC[stage]
307
+ artifact = spec["artifact"]
308
+ schema_name = spec["schema"]
309
+ if schema_name is None: # challenge: light parseability contract
310
+ data = load_json(ws.path / artifact)
311
+ ok = bool(data) and isinstance(data, dict)
312
+ return {"passed": ok, "stage": stage, "artifact": artifact,
313
+ "schema": None, "issues": [] if ok else ["skeptic.json missing or unparseable"]}
314
+
315
+ from validate_schema import SchemaError, Validator
316
+
317
+ schemas_dir = ROOT / "schemas"
318
+ if not (schemas_dir / schema_name).is_file():
319
+ share_dir = Path(sys.prefix) / "share" / "eduevidence" / "schemas"
320
+ if (share_dir / schema_name).is_file():
321
+ schemas_dir = share_dir
322
+
323
+ try:
324
+ schema = json.loads((schemas_dir / schema_name).read_text(encoding="utf-8"))
325
+ except OSError:
326
+ return {"passed": False, "stage": stage, "artifact": artifact,
327
+ "schema": schema_name, "issues": [f"schema file {schema_name} not found"]}
328
+
329
+ records = _load_artifact(ws, artifact)
330
+ if not records:
331
+ return {"passed": False, "stage": stage, "artifact": artifact,
332
+ "schema": schema_name, "issues": [f"{artifact} missing or empty"]}
333
+
334
+ issues = []
335
+ validator = Validator(schema, base_dir=schemas_dir)
336
+
337
+
338
+ for idx, record in enumerate(records):
339
+ try:
340
+ validator.validate(record, schema, f"{artifact}[{idx}]")
341
+ except SchemaError as exc:
342
+ issues.append(str(exc))
343
+ return {"passed": not issues, "stage": stage, "artifact": artifact,
344
+ "schema": schema_name, "issues": issues[:5]}
345
+
346
+
347
+ # ------------------------------------------------------- deterministic stages
348
+
349
+
350
+ def derive_sources_from_evidence(evidence: list[dict[str, Any]]) -> list[dict[str, Any]]:
351
+ """Deterministic source registry derived from evidence records (demo/test mode).
352
+
353
+ Honest fallback (review P1-2): authority comes from a real DOI parsed out
354
+ of the location, otherwise tier5 + source_metadata_incomplete. Never
355
+ fabricates a canonical URL from an id.
356
+ """
357
+ from build_result import _derive_source_from_evidence
358
+ from retrieval.source import make_source
359
+
360
+ seen: dict[str, dict[str, Any]] = {}
361
+ for ev in evidence:
362
+ sid = ev.get("source_id", "")
363
+ if not sid or sid in seen:
364
+ continue
365
+ loc = ev.get("source_location", "") or ""
366
+ if loc:
367
+ derived = _derive_source_from_evidence(ev)
368
+ if derived is not None:
369
+ seen[sid] = make_source(
370
+ source_id=sid,
371
+ title=ev.get("title", sid),
372
+ canonical_url=loc,
373
+ authority_level=derived["authority_level"],
374
+ year=ev.get("year"),
375
+ )
376
+ seen[sid].setdefault("extensions", {})["source_metadata_incomplete"] = derived["extensions"]["source_metadata_incomplete"]
377
+ if not seen.get(sid, {}).get("fetch"):
378
+ # fetchProvenance requires fetch_status; an empty dict is schema-invalid
379
+ seen.get(sid, {}).pop("fetch", None)
380
+ return list(seen.values())
381
+
382
+
383
+ def derive_skeptic_from_evidence(evidence: list[dict[str, Any]]) -> dict[str, Any]:
384
+ """Deterministic skeptic summary derived from evidence directions (demo/test mode).
385
+
386
+ Records what the corpus itself contains (contradictions / null results /
387
+ confounders); it never invents counter-evidence.
388
+ """
389
+ contradictions = [e.get("evidence_id") for e in evidence if e.get("direction") == "contradict"]
390
+ null_results = [e.get("evidence_id") for e in evidence if e.get("direction") == "neutral"]
391
+ confounders = sorted({c for e in evidence for c in (e.get("confounders", []) or [])})
392
+ return {
393
+ "search_performed": True,
394
+ "method": "derived from evidence corpus directions (demo/test mode)",
395
+ "contradictions": contradictions,
396
+ "null_results": null_results,
397
+ "confounders": confounders,
398
+ "no_contradictory_evidence_found": not contradictions,
399
+ }
400
+
401
+
402
+ def _cap_verdict(gate: dict[str, Any], raw_verdict: dict[str, Any],
403
+ computed: dict[str, Any]) -> dict[str, Any]:
404
+ """Build final_verdict.json: deterministic confidence + gate enforcement."""
405
+ final = copy.deepcopy(raw_verdict)
406
+ final["raw_model_confidence"] = raw_verdict.get("confidence")
407
+ final["raw_model_confidence_breakdown"] = raw_verdict.get("confidence_breakdown")
408
+ final["confidence"] = computed["confidence"]
409
+ final["confidence_score"] = computed["confidence_breakdown"].get("score")
410
+ final["confidence_policy_version"] = computed["confidence_policy_version"]
411
+ final["independent_studies"] = computed["independent_studies"]
412
+ final["independent_samples"] = computed["independent_samples"]
413
+ final["confidence_breakdown"] = computed["confidence_breakdown"]
414
+ return apply_enforcement(final, gate)
415
+
416
+
417
+ def _run_adjudicate(ws: RunWorkspace, question: str,
418
+ demo_pack: Path | None = None) -> dict[str, Any]:
419
+ """Local adjudicate: Pre-Verdict Gate + deterministic confidence."""
420
+ from compute_confidence import compute_confidence
421
+
422
+ raw_path = ws.path / "raw_verdict.json"
423
+ raw = load_json(raw_path)
424
+ if not raw and demo_pack is not None and Path(demo_pack).is_dir():
425
+ pack_verdict = Path(demo_pack) / "verdict.json"
426
+ if pack_verdict.is_file():
427
+ raw_path.write_bytes(pack_verdict.read_bytes())
428
+ raw = load_json(raw_path)
429
+ ws.trace("demo_seeded", stage="adjudicate",
430
+ detail="raw_verdict.json seeded from demo pack verdict.json")
431
+ evidence = load_jsonl(ws.path / "evidence.jsonl")
432
+ if not raw:
433
+ ws.write_brief("adjudicate", question, _STAGE_BRIEFS["adjudicate"])
434
+ return {"status": "pending", "detail": "raw_verdict.json missing; brief written for evidence judge"}
435
+ if not evidence:
436
+ ws.write_brief("adjudicate", question, _STAGE_BRIEFS["adjudicate"])
437
+ return {"status": "pending", "detail": "evidence.jsonl empty; extraction must complete first"}
438
+
439
+ pre = evaluate_workspace(ws.path, require_final=False)
440
+ computed = compute_confidence(evidence)
441
+ final = _cap_verdict(pre, raw, computed)
442
+ (ws.path / "final_verdict.json").write_text(
443
+ json.dumps(final, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
444
+
445
+ post = evaluate_workspace(ws.path, require_final=True)
446
+ gate_report = {"run_id": ws.run_id, "stage": "adjudicate",
447
+ "pre": {"passed": pre["passed"], "max_confidence": pre["max_confidence"],
448
+ "critical_failures": pre["critical_failures"]},
449
+ "post": post,
450
+ "final_confidence": final.get("confidence"),
451
+ "final_action": final.get("recommended_action"),
452
+ "checked_at": _utc_now()}
453
+ (ws.path / "gate_report.json").write_text(
454
+ json.dumps(gate_report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
455
+
456
+ if post["passed"]:
457
+ detail = (f"gate passed; confidence={final.get('confidence')} "
458
+ f"(policy={final.get('confidence_policy_version')})")
459
+ ws.trace("adjudicate_completed", stage="adjudicate", detail=detail)
460
+ return {"status": "completed", "detail": detail}
461
+ ws.trace("gate_failure_capped", stage="adjudicate",
462
+ detail=f"critical failures: {post['critical_failures']}; confidence capped at "
463
+ f"{post['max_confidence']}")
464
+ return {"status": "completed",
465
+ "detail": f"gate critical failures {post['critical_failures']}; verdict capped at "
466
+ f"{post['max_confidence']}"}
467
+
468
+
469
+ def _assemble_result(ws: RunWorkspace, manifest: dict[str, Any]) -> dict[str, Any]:
470
+ """Assemble result.json from workspace artifacts (decision=final_verdict.json)."""
471
+ from build_result import (NOT_CAPTURED_USAGE, OUTCOME_ORDER,
472
+ aggregate_outcomes, build_claims,
473
+ build_outcome_mapping, derive_provenance)
474
+
475
+ frame = load_json(ws.path / "frame.json")
476
+ evidence = load_jsonl(ws.path / "evidence.jsonl")
477
+ verdict = load_json(ws.path / "final_verdict.json") or load_json(ws.path / "raw_verdict.json")
478
+ methodology = load_json(ws.path / "methodology.json")
479
+ methodology_list = [methodology] if methodology else []
480
+ intervention = load_json(ws.path / "intervention.json")
481
+ evaluation = load_json(ws.path / "evaluation.json")
482
+ sources = load_jsonl(ws.path / "sources.jsonl")
483
+ if not sources:
484
+ sources = derive_sources_from_evidence(evidence)
485
+
486
+ claims = build_claims(evidence)
487
+ for idx, claim in enumerate(claims, 1):
488
+ claim["claim_id"] = f"C-{idx:03d}"
489
+
490
+ mode = manifest.get("execution_mode", "platform_native")
491
+ return {
492
+ "meta": {
493
+ "skill": "eduevidence",
494
+ "version": manifest.get("skill_version") or ENGINE_VERSION,
495
+ "generated_at": _utc_now(),
496
+ "mode": mode,
497
+ "question": frame.get("question", manifest.get("question", "")),
498
+ },
499
+ "execution": {
500
+ "complexity": frame.get("complexity") or manifest.get("depth", "M"),
501
+ "mode": mode,
502
+ "agents": [],
503
+ "usage": dict(NOT_CAPTURED_USAGE),
504
+ },
505
+ "research_frame": frame,
506
+ "decision": verdict,
507
+ "outcomes": aggregate_outcomes(evidence),
508
+ "outcome_mapping": build_outcome_mapping(evidence, frame),
509
+ "claims": claims,
510
+ "sources": sources,
511
+ "evidence": evidence,
512
+ "methodology_reviews": methodology_list,
513
+ "conflicts": [{"reason_for_disagreement": verdict.get("reason_for_disagreement", "")}]
514
+ if verdict.get("reason_for_disagreement") else [],
515
+ "applicability": verdict.get("applicability", {}),
516
+ "intervention": intervention,
517
+ "evaluation": evaluation,
518
+ "benchmark": {},
519
+ "provenance": derive_provenance(sources),
520
+ }
521
+
522
+
523
+ def _run_present(ws: RunWorkspace, manifest: dict[str, Any], question: str,
524
+ demo_pack: Path | None = None) -> dict[str, Any]:
525
+ """Local present: assemble + validate result.json, seed render artifacts."""
526
+ required = ("final_verdict.json", "intervention.json", "evaluation.json")
527
+ missing = [name for name in required if not (ws.path / name).is_file()
528
+ or not load_json(ws.path / name)]
529
+ if missing:
530
+ ws.write_brief("present", question, _STAGE_BRIEFS["present"])
531
+ return {"status": "pending",
532
+ "detail": f"missing prerequisite artifacts: {', '.join(missing)}"}
533
+
534
+ # demo/test mode: seed the bilingual + render artifacts from the example pack
535
+ seeded = []
536
+ if demo_pack is not None and Path(demo_pack).is_dir():
537
+ pack = Path(demo_pack)
538
+ for name in ("result.zh.json", "report_spec.json"):
539
+ target = ws.path / name
540
+ if (pack / name).is_file() and (not target.is_file() or target.stat().st_size <= 2):
541
+ target.write_bytes((pack / name).read_bytes())
542
+ seeded.append(name)
543
+ if not (ws.path / "report.html").is_file() or (ws.path / "report.html").stat().st_size <= 2:
544
+ for name in ("EduEvidence_Report.html", "report.html"):
545
+ if (pack / name).is_file():
546
+ (ws.path / "report.html").write_bytes((pack / name).read_bytes())
547
+ seeded.append(name)
548
+ break
549
+
550
+ result = _assemble_result(ws, manifest)
551
+ (ws.path / "result.json").write_text(
552
+ json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
553
+ gate = schema_gate(ws, "present")
554
+ if not gate["passed"]:
555
+ return {"status": "failed", "detail": f"result.json schema gate: {gate['issues']}"}
556
+ missing_render = [n for n in ("result.zh.json", "report_spec.json", "report.html")
557
+ if not (ws.path / n).is_file() or (ws.path / n).stat().st_size <= 2]
558
+ if seeded:
559
+ extras = f"seeded render artifacts: {', '.join(seeded)}"
560
+ elif missing_render:
561
+ extras = f"render artifacts missing: {', '.join(missing_render)}"
562
+ else:
563
+ extras = "render artifacts present"
564
+ return {"status": "completed",
565
+ "detail": (f"result.json assembled (sources={len(result['sources'])}, "
566
+ f"evidence={len(result['evidence'])}, claims={len(result['claims'])}); "
567
+ f"{extras}")}
568
+
569
+
570
+ # ------------------------------------------------------------- stage runner
571
+
572
+
573
+ def run_stage(ws: RunWorkspace, stage: str, *, demo_pack: Path | None = None) -> dict[str, Any]:
574
+ """Advance one stage. Returns {status: completed|pending|failed, detail}.
575
+
576
+ Deterministic stages execute locally; external stages are either seeded
577
+ from ``demo_pack`` (demo/test mode) or handed off via a task brief.
578
+ """
579
+ ws.trace("stage_started", stage=stage)
580
+ log.info("stage=%s run=%s start", stage, ws.run_id)
581
+ spec = STAGE_SPEC[stage]
582
+ question = ws.load_manifest().get("question", "")
583
+ artifact_path = ws.path / spec["artifact"]
584
+
585
+ # already-present artifact -> schema gate only (empty seeds count as missing)
586
+ if artifact_path.is_file() and artifact_path.stat().st_size > 2:
587
+ gate = schema_gate(ws, stage)
588
+ if gate["passed"]:
589
+ ws.mark_stage(stage, "completed",
590
+ detail=f"artifact {spec['artifact']} schema-valid",
591
+ artifacts=[spec["artifact"]])
592
+ ws.trace("stage_completed", stage=stage, detail="schema gate passed")
593
+ return {"status": "completed", "detail": "artifact present, schema-valid"}
594
+ ws.trace("schema_invalid", stage=stage, detail=gate["issues"][0])
595
+ ws.mark_stage(stage, "failed", detail=f"schema gate failed: {gate['issues'][0]}")
596
+ return {"status": "failed", "detail": f"schema gate failed: {gate['issues']}"}
597
+
598
+ # local deterministic stages
599
+ if stage == "adjudicate":
600
+ result = _run_adjudicate(ws, question, demo_pack=demo_pack)
601
+ elif stage == "present":
602
+ result = _run_present(ws, ws.load_manifest(), question, demo_pack=demo_pack)
603
+ else:
604
+ # demo/test seeding
605
+ if demo_pack is not None:
606
+ seeded = _seed_from_demo(ws, stage, demo_pack)
607
+ if seeded["seeded"]:
608
+ gate = schema_gate(ws, stage)
609
+ if gate["passed"]:
610
+ ws.mark_stage(stage, "completed",
611
+ detail=f"demo-seeded from {demo_pack.name}; schema-valid",
612
+ artifacts=[spec["artifact"]])
613
+ ws.trace("stage_completed", stage=stage, detail="demo seed, schema gate passed")
614
+ return {"status": "completed", "detail": seeded["detail"]}
615
+ ws.mark_stage(stage, "failed", detail=f"demo seed failed schema gate: {gate['issues']}")
616
+ return {"status": "failed", "detail": f"demo seed failed schema gate: {gate['issues']}"}
617
+ if seeded["reason"]:
618
+ return {"status": "pending", "detail": seeded["reason"]}
619
+
620
+ ws.write_brief(stage, question, _STAGE_BRIEFS[stage])
621
+ ws.mark_stage(stage, "pending",
622
+ detail=f"awaiting external agent; brief at task-briefs/{stage}.md")
623
+ return {"status": "pending",
624
+ "detail": f"external stage; brief written to task-briefs/{stage}.md"}
625
+
626
+ if result["status"] == "completed":
627
+ ws.mark_stage(stage, "completed", detail=result["detail"],
628
+ artifacts=[spec["artifact"]])
629
+ elif result["status"] == "failed":
630
+ ws.mark_stage(stage, "failed", detail=result["detail"])
631
+ else:
632
+ ws.mark_stage(stage, "pending", detail=result["detail"])
633
+ return result
634
+
635
+
636
+ def _seed_from_demo(ws: RunWorkspace, stage: str, demo_pack: Path) -> dict[str, Any]:
637
+ """Copy/derive the stage artifact from a demo pack (tests + --demo-pack)."""
638
+ pack = Path(demo_pack)
639
+ if not pack.is_dir():
640
+ return {"seeded": False, "reason": f"demo pack {demo_pack} not found"}
641
+ evidence = load_jsonl(pack / "evidence.jsonl")
642
+
643
+ if stage == "frame":
644
+ if (pack / "frame.json").is_file():
645
+ (ws.path / "frame.json").write_bytes((pack / "frame.json").read_bytes())
646
+ return {"seeded": True, "detail": "frame.json seeded from demo pack"}
647
+ elif stage == "retrieve":
648
+ if (pack / "sources.jsonl").is_file():
649
+ (ws.path / "sources.jsonl").write_bytes((pack / "sources.jsonl").read_bytes())
650
+ elif evidence:
651
+ save_jsonl(ws.path / "sources.jsonl", derive_sources_from_evidence(evidence))
652
+ else:
653
+ return {"seeded": False, "reason": "demo pack has no sources/evidence to derive from"}
654
+ return {"seeded": True, "detail": "sources.jsonl seeded from demo pack"}
655
+ elif stage == "extract":
656
+ if not evidence:
657
+ return {"seeded": False, "reason": "demo pack has no evidence.jsonl"}
658
+ (ws.path / "evidence.jsonl").write_bytes((pack / "evidence.jsonl").read_bytes())
659
+ return {"seeded": True, "detail": "evidence.jsonl seeded from demo pack"}
660
+ elif stage == "challenge":
661
+ (ws.path / "skeptic.json").write_text(
662
+ json.dumps(derive_skeptic_from_evidence(evidence), ensure_ascii=False, indent=2) + "\n",
663
+ encoding="utf-8")
664
+ return {"seeded": True, "detail": "skeptic.json derived from evidence (demo)"}
665
+ elif stage == "audit":
666
+ if (pack / "methodology.json").is_file():
667
+ (ws.path / "methodology.json").write_bytes((pack / "methodology.json").read_bytes())
668
+ return {"seeded": True, "detail": "methodology.json seeded from demo pack"}
669
+ elif stage == "intervene":
670
+ if (pack / "intervention.json").is_file():
671
+ (ws.path / "intervention.json").write_bytes((pack / "intervention.json").read_bytes())
672
+ return {"seeded": True, "detail": "intervention.json seeded from demo pack"}
673
+ elif stage == "evaluate":
674
+ if (pack / "evaluation.json").is_file():
675
+ (ws.path / "evaluation.json").write_bytes((pack / "evaluation.json").read_bytes())
676
+ return {"seeded": True, "detail": "evaluation.json seeded from demo pack"}
677
+ return {"seeded": False, "reason": f"demo pack has no artifact for stage {stage}"}
678
+
679
+
680
+ def advance(ws: RunWorkspace, *, demo_pack: Path | None = None) -> dict[str, Any]:
681
+ """Resume loop: advance stages in order until blocked (Phase 32).
682
+
683
+ Completed stages are skipped; pending/failed stages stop the pass so an
684
+ external agent can act, then ``resume`` continues.
685
+ """
686
+ summary = {"run_id": ws.run_id, "advanced": [], "blocked_on": None,
687
+ "failures": [], "completed_all": False}
688
+ state = ws.load_state()
689
+ for stage in STAGES:
690
+ status = state["stages"].get(stage, {}).get("status", "pending")
691
+ if status == "completed":
692
+ # crash/interruption safety: a completed stage whose primary artifact
693
+ # vanished (or was truncated to an empty seed) must re-run.
694
+ artifact = STAGE_SPEC[stage]["artifact"]
695
+ path = ws.path / artifact
696
+ if path.is_file() and path.stat().st_size > 2:
697
+ continue
698
+ ws.mark_stage(stage, "pending",
699
+ detail="artifact missing on resume; stage will re-run")
700
+ status = "pending"
701
+ result = run_stage(ws, stage, demo_pack=demo_pack)
702
+ summary["advanced"].append({"stage": stage, **result})
703
+ if result["status"] == "pending":
704
+ summary["blocked_on"] = stage
705
+ break
706
+ if result["status"] == "failed":
707
+ plan = handle_failure("SCHEMA_INVALID" if "schema" in result["detail"] else "TOOL_FAILURE")
708
+ summary["failures"].append({"stage": stage, "detail": result["detail"],
709
+ "handling": plan["action"]})
710
+ ws.trace("stage_failed", stage=stage, detail=result["detail"])
711
+ summary["blocked_on"] = stage
712
+ break
713
+ state = ws.load_state() # refresh after stage writes
714
+
715
+ state = ws.load_state()
716
+ remaining = [s for s in STAGES if state["stages"].get(s, {}).get("status") != "completed"]
717
+ if not remaining:
718
+ state = ws.save_state({"status": "completed", "current_stage": STAGES[-1]})
719
+ summary["completed_all"] = True
720
+ ws.trace("run_completed", detail="all stages completed")
721
+ else:
722
+ run_status = "failed" if summary["failures"] else "running"
723
+ state = ws.save_state({"status": run_status, "current_stage": remaining[0]})
724
+ summary["blocked_on"] = summary.get("blocked_on") or remaining[0]
725
+ summary["current_stage"] = state["current_stage"]
726
+ summary["status"] = state["status"]
727
+ return summary
728
+
729
+
730
+ def interactive_agent_mcp_setup(approved: bool) -> bool:
731
+ """W3.1 启动授权流程:检测 → 提示推荐启用 Agent MCP → 用户授权。
732
+
733
+ 保留 GitHub 版全部行为(--approve-agent-mcp 旗标、agent_mcp_approval.json、
734
+ safe_spawn 门);此处只补 run 启动时的交互提示层。非交互终端直接返回原值。
735
+ """
736
+ if approved:
737
+ return True
738
+ try:
739
+ from integrations.agent_mcp import detect_agent_mcp
740
+ detection = detect_agent_mcp()
741
+ except Exception:
742
+ detection = {"state": "unavailable", "mode": "platform_native",
743
+ "hint": "", "reasons": ["detect failed"]}
744
+
745
+ state_ = detection.get("state", "unavailable")
746
+ print("------------------------------------------------------------")
747
+ print(f"[startup] 执行模式: {detection.get('mode', 'platform_native')}"
748
+ f" | Agent MCP 状态: {state_}")
749
+ if detection.get("hint"):
750
+ print(f"[startup] 提示: {detection['hint']}")
751
+ print(" 启用 Agent MCP 增强 = 多 CLI/多模型分工、独立子上下文、"
752
+ "超时恢复与成本优化、Memory Bank;未启用自动降级 platform-native。")
753
+ print("------------------------------------------------------------")
754
+
755
+ if state_ != "available" and state_ != "daemon_reachable_undeclared":
756
+ print("[startup] Agent MCP 不可用(未安装或 daemon 未启动),本次以 platform-native 运行。")
757
+ return False
758
+ if not sys.stdin.isatty():
759
+ print("[startup] 非交互终端:需授权请使用 --approve-agent-mcp。")
760
+ return False
761
+
762
+ if state_ == "daemon_reachable_undeclared":
763
+ answer = input("检测到 daemon 在运行但未声明安装。是否写入 AGENT_MCP_INSTALLED=1 "
764
+ "到 ~/.eduevidence/env?[Y/n] ").strip().lower()
765
+ if answer in ("n", "no"):
766
+ return False
767
+ try:
768
+ env_file = os.path.expanduser("~/.eduevidence/env")
769
+ os.makedirs(os.path.dirname(env_file), exist_ok=True)
770
+ lines = []
771
+ if os.path.exists(env_file):
772
+ lines = Path(env_file).read_text(encoding="utf-8").splitlines()
773
+ if not any(l.strip().startswith("AGENT_MCP_INSTALLED=") for l in lines):
774
+ lines.append("AGENT_MCP_INSTALLED=1")
775
+ Path(env_file).write_text("\n".join(lines) + "\n", encoding="utf-8")
776
+ print(f"[startup] 已写入 {env_file}")
777
+ except OSError as exc:
778
+ print(f"[startup] 写入失败: {exc};继续 platform-native。")
779
+ return False
780
+
781
+ answer = input("是否启用 Agent MCP 增强模式(推荐)?[Y/n] ").strip().lower()
782
+ return answer not in ("n", "no")
783
+
784
+
785
+ def _cmd_run(args: argparse.Namespace) -> int:
786
+ approve = args.approve_agent_mcp or interactive_agent_mcp_setup(args.approve_agent_mcp)
787
+ ws = init_run(Path(args.runs_dir), args.question, depth=args.depth, run_id=args.run_id,
788
+ approve_agent_mcp=approve)
789
+ print(f"workspace created: {ws.path}")
790
+ print(f"manifest: {json.dumps(ws.load_manifest(), ensure_ascii=False, indent=2)}")
791
+ if args.dry_run:
792
+ print("[dry-run] workspace initialized; stage execution skipped")
793
+ return 0
794
+ summary = advance(ws, demo_pack=args.demo_pack)
795
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
796
+ if summary["failures"]:
797
+ return 1
798
+ return 0
799
+
800
+
801
+ def _print_status(ws) -> None:
802
+ state = ws.load_state()
803
+ print(f"run_id: {ws.run_id}")
804
+ print(f"question: {state.get('question', '')[:120]}")
805
+ print(f"status: {state.get('status')} current_stage: {state.get('current_stage')}")
806
+ print("stages:")
807
+ for stage in STAGES:
808
+ row = state["stages"].get(stage, {})
809
+ detail = row.get("detail", "")
810
+ print(f" {stage:12} {row.get('status', 'pending'):10} {detail}")
811
+
812
+
813
+ def _cmd_resume(args: argparse.Namespace) -> int:
814
+ ws = RunWorkspace(Path(args.runs_dir), args.run_id)
815
+ if not ws.exists():
816
+ print(f"ERROR: no run {args.run_id} under {args.runs_dir}", file=sys.stderr)
817
+ return 2
818
+ summary = advance(ws, demo_pack=args.demo_pack)
819
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
820
+ return 1 if summary["failures"] else 0
821
+
822
+
823
+ def _cmd_status(args: argparse.Namespace) -> int:
824
+ ws = RunWorkspace(Path(args.runs_dir), args.run_id)
825
+ if not ws.exists():
826
+ print(f"ERROR: no run {args.run_id} under {args.runs_dir}", file=sys.stderr)
827
+ return 2
828
+ _print_status(ws)
829
+ return 0
830
+
831
+
832
+ def _cmd_list(args: argparse.Namespace) -> int:
833
+ runs_dir = Path(args.runs_dir)
834
+ if not runs_dir.is_dir():
835
+ print("no runs yet")
836
+ return 0
837
+ for entry in sorted(runs_dir.iterdir()):
838
+ if not entry.is_dir():
839
+ continue
840
+ ws = RunWorkspace(runs_dir, entry.name)
841
+ if not ws.exists():
842
+ continue
843
+ state = ws.load_state()
844
+ print(f"{entry.name:24} {state.get('status', '?'):10} {state.get('question', '')[:80]}")
845
+ return 0
846
+
847
+
848
+ def _cmd_gate(args: argparse.Namespace) -> int:
849
+ ws = RunWorkspace(Path(args.runs_dir), args.run_id)
850
+ if not ws.exists():
851
+ print(f"ERROR: no run {args.run_id} under {args.runs_dir}", file=sys.stderr)
852
+ return 2
853
+ return main_gate(argv=["--workspace", str(ws.path),
854
+ *(["--require-final"] if args.require_final else []),
855
+ "--json"])
856
+
857
+
858
+ def main_gate(argv: list[str] | None = None) -> int:
859
+ """Re-export of the Pre-Verdict Gate CLI (used by `eduevidence gate`)."""
860
+ from pre_verdict_gate import main as gate_main
861
+ return gate_main(argv)
862
+
863
+
864
+
865
+
866
+ # ---- V4 command handlers -----------------------------------------------
867
+
868
+ def _cmd_domain(args) -> int:
869
+ from engine.evidencecore import list_domains, load_domain
870
+ if args.action == "list":
871
+ for d in list_domains():
872
+ print(d["id"], "|", d["description"][:80])
873
+ return 0
874
+ if args.action == "select":
875
+ domain = load_domain(args.domain)
876
+ print(f"selected domain: {domain['id']} (frame_schema={domain['frame_schema']})")
877
+ return 0
878
+ if args.action == "check":
879
+ domain = load_domain(args.domain)
880
+ try:
881
+ import json as _json
882
+ from pathlib import Path as _P
883
+ _json.load(open(_P(__file__).resolve().parent.parent / domain["frame_schema"]))
884
+ print(f"domain {domain['id']}: contracts OK (frame schema loads)")
885
+ return 0
886
+ except Exception as exc:
887
+ print(f"ERROR: domain {domain['id']} contract broken: {exc}", file=sys.stderr)
888
+ return 1
889
+ return 2
890
+
891
+ def _cmd_living(args) -> int:
892
+ from engine.project import ProjectWorkspace
893
+ from engine.living import create_subscription, refresh, set_subscription_status
894
+ home = _home(args)
895
+ ws = ProjectWorkspace.open(home, args.project)
896
+ if args.action == "subscribe":
897
+ sub = create_subscription(
898
+ ws, decision_snapshot_id=args.decision,
899
+ query_terms=args.term or [])
900
+ print(sub["subscription_id"])
901
+ return 0
902
+ if args.action == "refresh":
903
+ result = refresh(
904
+ ws, args.subscription,
905
+ new_evidence=args.evidence_file and [dict(json.loads(l)) for l in open(args.evidence_file) if l.strip()] or None,
906
+ retriever=None)
907
+ drift = result["drift"]
908
+ print(f"drift {drift['drift_id']}: rev {drift['from_revision']}->{drift['to_revision']} | "
909
+ f"suggested_action={drift['suggested_action']}")
910
+ print(drift.get("summary", ""))
911
+ return 0
912
+ if args.action == "status":
913
+ from pathlib import Path as _P
914
+ import json as _json
915
+ p = ws.path / "living" / "subscriptions" / f"{args.subscription}.json"
916
+ if not p.is_file():
917
+ print(f"ERROR: subscription not found: {args.subscription}", file=sys.stderr)
918
+ return 2
919
+ sub = _json.loads(p.read_text(encoding="utf-8"))
920
+ print(f"{sub['subscription_id']} | status={sub['status']} | "
921
+ f"decision={sub['decision_snapshot_id']} | terms={sub['query_terms']}")
922
+ return 0
923
+ return 2
924
+
925
+ def _cmd_benchmark_judge(args) -> int:
926
+ import benchmark_judge as bj
927
+ if args.action == "run":
928
+ return bj.main(["run", "--run", args.run, "--out", args.out, "--limit", str(args.limit)])
929
+ if args.action == "report":
930
+ return bj.main(["report", "--out", args.report])
931
+ return 2
932
+
933
+
934
+ # ---- V3 command handlers -----------------------------------------------
935
+
936
+ def _cmd_pilot(args) -> int:
937
+ from engine.project import ProjectWorkspace
938
+ from engine.pilot import import_outcomes, link_analysis, redecide, register_pilot
939
+ home = _home(args)
940
+ ws = ProjectWorkspace.open(home, args.project)
941
+ if args.action == "register":
942
+ pilot = register_pilot(
943
+ ws, decision_snapshot_id=args.decision, title=args.title,
944
+ start_date=args.start, end_date=args.end,
945
+ conditions=args.condition, sample_size=args.sample,
946
+ design_id=args.design,
947
+ anon_policy={"no_pii_columns": True, "note": "CLI default: PII columns refused"},
948
+ outcome_columns=args.outcome)
949
+ print(pilot["pilot_id"])
950
+ return 0
951
+ if args.action == "import":
952
+ import_outcomes(
953
+ ws, args.pilot, source_path=args.file,
954
+ privacy={"classification": args.privacy, "deidentification_status": "done"})
955
+ print("imported", args.file, "->", args.pilot)
956
+ return 0
957
+ if args.action == "analyze-link":
958
+ link_analysis(ws, args.pilot, analysis_run_id=args.analysis)
959
+ print("linked analysis", args.analysis)
960
+ return 0
961
+ if args.action == "redecide":
962
+ result = redecide(
963
+ ws, args.pilot, claim_id=args.claim, outcome_token=args.outcome,
964
+ measure=args.measure, effect_direction=args.effect,
965
+ raw_result_text=args.result_text, relation_to_claim=args.relation,
966
+ effect_estimate=({"value": args.effect_value} if args.effect_value is not None else None))
967
+ print("new decision:", result["snapshot"]["decision_snapshot_id"],
968
+ result["snapshot"]["decision"], result["snapshot"]["confidence_label"])
969
+ print("diff:", json.dumps(result["diff"], ensure_ascii=False)[:300])
970
+ return 0
971
+ return 2
972
+
973
+ def _cmd_synthesize(args) -> int:
974
+ from engine.library import ResearchLibrary
975
+ from engine.meta_synthesis import save_synthesis, synthesize_library
976
+ home = _home(args)
977
+ lib = ResearchLibrary.open(home)
978
+ syn = synthesize_library(lib)
979
+ out_dir = home / "syntheses" if args.out is None else args.out
980
+ path = save_synthesis(syn, out_dir)
981
+ print(f"wrote {path} (revision {syn['library_revision']}, studies {syn['independent_studies']})")
982
+ return 0
983
+
984
+ def _cmd_benchmark(args) -> int:
985
+ import benchmark_v3 as bv3
986
+ if args.action == "run":
987
+ return bv3.main(["run", "--baselines", args.baselines, "--questions", args.questions,
988
+ "--repeats", str(args.repeats), "--driver", args.driver,
989
+ "--out", args.out, "--budget-tokens", str(args.budget)])
990
+ if args.action == "eval":
991
+ return bv3.main(["eval", "--run", args.run, "--annotations", args.annotations])
992
+ if args.action == "report":
993
+ return bv3.main(["report", "--run", args.run, "--out", args.report])
994
+ return 2
995
+
996
+ # ---- V2 command handlers --------------------------------------------------
997
+
998
+ def _home(args) -> "object":
999
+ from engine.paths import resolve_home
1000
+ return resolve_home(getattr(args, "home", None))
1001
+
1002
+
1003
+ def _cmd_project(args) -> int:
1004
+ from engine.project import ProjectWorkspace
1005
+ home = _home(args)
1006
+ if args.action == "create":
1007
+ if not args.question:
1008
+ print("project create requires --question", file=sys.stderr)
1009
+ return 2
1010
+ ws = ProjectWorkspace.create(
1011
+ home, question=args.question, title=args.title or args.question,
1012
+ research_mode=args.mode)
1013
+ print(ws.project_id)
1014
+ return 0
1015
+ if args.action == "list":
1016
+ projects_dir = home / "projects"
1017
+ if not projects_dir.is_dir():
1018
+ return 0
1019
+ for p in sorted(projects_dir.iterdir()):
1020
+ manifest = p / "project.json"
1021
+ if manifest.is_file():
1022
+ import json
1023
+ m = json.loads(manifest.read_text(encoding="utf-8"))
1024
+ print(f"{m['project_id']}\t{m['status']}\trev {m['graph_revision']}\t{m['question'][:60]}")
1025
+ return 0
1026
+ # status
1027
+ ws = ProjectWorkspace.open(home, args.project)
1028
+ m = ws.manifest()
1029
+ print(f"project: {m['project_id']}")
1030
+ print(f"mode: {m['research_mode']} target: {m['decision_target']}")
1031
+ print(f"status: {m['status']} graph_revision: {m['graph_revision']}")
1032
+ return 0
1033
+
1034
+
1035
+ def _cmd_research(args) -> int:
1036
+ from engine.mode_router import recommend_mode
1037
+ from engine.planner import build_research_plan
1038
+ from engine.project import ProjectWorkspace
1039
+ ws = ProjectWorkspace.open(_home(args), args.project)
1040
+ if args.action == "plan":
1041
+ intent = {
1042
+ "decision_target": ws.manifest()["decision_target"],
1043
+ "wants_existing_evidence": True,
1044
+ "wants_study_design": ws.manifest()["research_mode"] == "full_research_cycle",
1045
+ "has_user_data": False,
1046
+ "wants_data_analysis": False,
1047
+ "wants_decision_update": False,
1048
+ }
1049
+ rec = recommend_mode(intent, project_has_grounding=ws.current_revision() > 0)
1050
+ plan = build_research_plan(
1051
+ mode=rec.mode, decision_target=ws.manifest()["decision_target"],
1052
+ depth="standard", has_grounding=ws.current_revision() > 0,
1053
+ has_dataset=False)
1054
+ print(f"mode: {rec.mode}")
1055
+ for step in plan:
1056
+ print(f" {step.kind:10s} {step.capability_id or step.wait_state}")
1057
+ return 0
1058
+ # run/resume: create a Run record
1059
+ from engine.run import start_run
1060
+ run = start_run(ws, purpose="research run", capabilities=[],
1061
+ execution_backend="sequential_main_agent")
1062
+ print(run["run_id"])
1063
+ return 0
1064
+
1065
+
1066
+ def _cmd_graph(args) -> int:
1067
+ from engine.graph_store import GraphStore
1068
+ from engine.project import ProjectWorkspace
1069
+ ws = ProjectWorkspace.open(_home(args), args.project)
1070
+ store = GraphStore.create(ws)
1071
+ problems = store.validate()
1072
+ if problems:
1073
+ for p in problems:
1074
+ print(p, file=sys.stderr)
1075
+ return 1
1076
+ print(f"graph valid at revision {store.active_revision()}")
1077
+ return 0
1078
+
1079
+
1080
+ def _cmd_study(args) -> int:
1081
+ from engine.study_design import validate_design_grounding
1082
+ from engine.project import ProjectWorkspace
1083
+ from engine.ids import new_local_id
1084
+ ws = ProjectWorkspace.open(_home(args), args.project)
1085
+ question = getattr(args, "question", None) or "grounded study"
1086
+ design = {
1087
+ "design_id": new_local_id("DSN", set()),
1088
+ "gap_ids": args.gap,
1089
+ "research_question": question,
1090
+ "design_type": getattr(args, "design_type", "rct") or "rct",
1091
+ "population": getattr(args, "population", "") or "unspecified",
1092
+ "sampling_plan": getattr(args, "sampling", "") or "unspecified",
1093
+ "intervention": getattr(args, "intervention", None),
1094
+ "comparison": getattr(args, "comparison", None),
1095
+ "outcomes": [getattr(args, "outcome", "outcome")] if getattr(args, "outcome", None) else ["outcome"],
1096
+ "measures": [getattr(args, "measure", "measure")] if getattr(args, "measure", None) else ["measure"],
1097
+ "timepoints": ["post"],
1098
+ "assignment_strategy": getattr(args, "assignment", "") or "unspecified",
1099
+ "confounder_plan": "",
1100
+ "analysis_requirements": ["descriptive_statistics"],
1101
+ "success_criteria": [],
1102
+ "stop_conditions": [],
1103
+ "ethics_flags": {"human_subjects": True, "sensitive_data": False,
1104
+ "minors_involved": False, "consent_status": "unknown",
1105
+ "ethics_review_required": True,
1106
+ "deidentification_required": False},
1107
+ "preregistration_fields": {},
1108
+ "derived_from_graph_revision": ws.current_revision(),
1109
+ "created_at": __import__("datetime").datetime.now(
1110
+ __import__("datetime").timezone.utc).isoformat(),
1111
+ "extensions": {},
1112
+ }
1113
+ errors = validate_design_grounding(ws, design)
1114
+ if errors:
1115
+ for e in errors:
1116
+ print(e, file=sys.stderr)
1117
+ return 1
1118
+ from engine.study_design import save_study_design
1119
+ path = save_study_design(ws, design)
1120
+ print(design["design_id"])
1121
+ return 0
1122
+
1123
+
1124
+ def _cmd_data(args) -> int:
1125
+ from engine.datasets import ingest_dataset
1126
+ from engine.project import ProjectWorkspace
1127
+ ws = ProjectWorkspace.open(_home(args), args.project)
1128
+ asset = ingest_dataset(
1129
+ ws, design_id=args.design, source_path=args.file,
1130
+ privacy={"classification": args.privacy, "deidentification_status": "not_done",
1131
+ "consent_metadata": None})
1132
+ print(asset["dataset_id"])
1133
+ return 0
1134
+
1135
+
1136
+ def _cmd_analyze(args) -> int:
1137
+ from engine.analysis import run_native_descriptive
1138
+ from engine.project import ProjectWorkspace
1139
+ import json
1140
+ ws = ProjectWorkspace.open(_home(args), args.project)
1141
+ plan_path = ws.path / "analyses" / f"{args.plan}.json"
1142
+ if not plan_path.is_file():
1143
+ print(f"plan not found: {plan_path}", file=sys.stderr)
1144
+ return 1
1145
+ plan = json.loads(plan_path.read_text(encoding="utf-8"))
1146
+ run = run_native_descriptive(ws, plan)
1147
+ print(json.dumps(run, ensure_ascii=False, indent=2))
1148
+ return 0
1149
+
1150
+
1151
+ def _cmd_adjudicate(args) -> int:
1152
+ from engine.graph_store import GraphStore
1153
+ from engine.project import ProjectWorkspace
1154
+ from engine.tribunal import adjudicate, save_decision_snapshot
1155
+ ws = ProjectWorkspace.open(_home(args), args.project)
1156
+ store = GraphStore.create(ws)
1157
+ snap = adjudicate(store, project=ws)
1158
+ path = save_decision_snapshot(ws, snap)
1159
+ print(f"{snap['decision']} / {snap['confidence_label']} ({path})")
1160
+ return 0
1161
+
1162
+
1163
+ def _cmd_report(args) -> int:
1164
+ from engine.project import ProjectWorkspace
1165
+ from engine.projections import build_v1_compat_result
1166
+ ws = ProjectWorkspace.open(_home(args), args.project)
1167
+ compat = build_v1_compat_result(ws)
1168
+ out = ws.path / "projections" / f"report-rev-{ws.current_revision():06d}.json"
1169
+ out.write_text(__import__("json").dumps(compat, ensure_ascii=False, indent=2) + "\n",
1170
+ encoding="utf-8")
1171
+ print(out)
1172
+ return 0
1173
+
1174
+
1175
+ def _cmd_migrate(args) -> int:
1176
+ from engine.migration import migrate_v1_pack
1177
+ result = migrate_v1_pack(args.pack, home=_home(args), title=args.title)
1178
+ print(f"{result.project_id} rev {result.graph_revision}")
1179
+ for w in result.warnings:
1180
+ print(f"warning: {w}", file=sys.stderr)
1181
+ return 0
1182
+
1183
+
1184
+ def _cmd_dashboard(args) -> int:
1185
+ from dashboard_server import run_dashboard_server
1186
+ run_dashboard_server(host=args.host, port=args.port)
1187
+ return 0
1188
+
1189
+
1190
+ def _cmd_search(args) -> int:
1191
+ from retrieval.search import search_evidence
1192
+ hits = search_evidence(args.query, limit=args.limit, academic_only=args.academic)
1193
+ print(json.dumps(hits, indent=2, ensure_ascii=False))
1194
+ return 0
1195
+
1196
+
1197
+ def _cmd_did(args) -> int:
1198
+ from did_regression import run_did_analysis
1199
+ res = run_did_analysis(str(args.csv))
1200
+ print(json.dumps(res, indent=2, ensure_ascii=False))
1201
+ return 0 if res.get("status") == "success" else 1
1202
+
1203
+
1204
+ def _cmd_effect(args) -> int:
1205
+ from effect_calculator import compute_hedges_g
1206
+ res = compute_hedges_g(args.mean1, args.sd1, args.n1, args.mean2, args.sd2, args.n2)
1207
+ print(json.dumps(res, indent=2, ensure_ascii=False))
1208
+ return 0
1209
+
1210
+
1211
+ def _cmd_lint(args) -> int:
1212
+ from skill_lint import lint_skill
1213
+ errs = lint_skill()
1214
+ if errs:
1215
+ for e in errs:
1216
+ print(f"ERROR: {e}", file=sys.stderr)
1217
+ return 1
1218
+ print("Skill lint passed.")
1219
+ return 0
1220
+
1221
+
1222
+ def main(argv: list[str] | None = None) -> int:
1223
+ parser = argparse.ArgumentParser(
1224
+ prog="eduevidence",
1225
+ description="EduEvidence run orchestrator — stage routing, schema gates, resume, failures")
1226
+ sub = parser.add_subparsers(dest="command", required=True)
1227
+
1228
+ p_run = sub.add_parser("run", help="create a run workspace and advance stages")
1229
+ p_run.add_argument("--question", required=True, help="education question to research")
1230
+ p_run.add_argument("--depth", default="M", choices=["quick", "standard", "deep", "S", "M", "L"],
1231
+ help="complexity depth (default: standard/M)")
1232
+ p_run.add_argument("--run-id", default=None, help="explicit run id (default: timestamp)")
1233
+ p_run.add_argument("--demo-pack", default=None, type=Path,
1234
+ help="seed external stages from an example pack (demo/test mode)")
1235
+ p_run.add_argument("--approve-agent-mcp", action="store_true",
1236
+ help="record agent-mcp approval in the manifest")
1237
+ p_run.add_argument("--dry-run", action="store_true", help="initialize only, do not advance")
1238
+ p_run.add_argument("--runs-dir", default=os.environ.get("EDUEVIDENCE_RUNS_DIR", str(ROOT / "runs")),
1239
+ help="directory holding run workspaces (default: <repo>/runs)")
1240
+ p_run.set_defaults(func=_cmd_run)
1241
+
1242
+ p_resume = sub.add_parser("resume", help="continue a run from its state.json")
1243
+ p_resume.add_argument("--run-id", required=True)
1244
+ p_resume.add_argument("--demo-pack", default=None, type=Path)
1245
+ p_resume.add_argument("--runs-dir", default=os.environ.get("EDUEVIDENCE_RUNS_DIR", str(ROOT / "runs")))
1246
+ p_resume.set_defaults(func=_cmd_resume)
1247
+
1248
+ p_status = sub.add_parser("status", help="show run state")
1249
+ p_status.add_argument("--run-id", required=True)
1250
+ p_status.add_argument("--runs-dir", default=os.environ.get("EDUEVIDENCE_RUNS_DIR", str(ROOT / "runs")))
1251
+ p_status.set_defaults(func=_cmd_status)
1252
+
1253
+ p_list = sub.add_parser("list", help="list runs")
1254
+ p_list.add_argument("--runs-dir", default=os.environ.get("EDUEVIDENCE_RUNS_DIR", str(ROOT / "runs")))
1255
+ p_list.set_defaults(func=_cmd_list)
1256
+
1257
+ p_gate = sub.add_parser("gate", help="run the Pre-Verdict Gate over a run")
1258
+ p_gate.add_argument("--run-id", required=True)
1259
+ p_gate.add_argument("--require-final", action="store_true")
1260
+ p_gate.add_argument("--runs-dir", default=os.environ.get("EDUEVIDENCE_RUNS_DIR", str(ROOT / "runs")))
1261
+ p_gate.set_defaults(func=_cmd_gate)
1262
+
1263
+ # ---- V2 project-scoped commands ------------------------------------
1264
+ p_proj_create = sub.add_parser("project", help="V2 project lifecycle")
1265
+ p_proj_create.add_argument("action", choices=["create", "list", "status"])
1266
+ p_proj_create.add_argument("--question", default=None, help="research question (create)")
1267
+ p_proj_create.add_argument("--title", default=None, help="project title (create)")
1268
+ p_proj_create.add_argument("--mode", default="evidence_review",
1269
+ choices=["evidence_review", "full_research_cycle"])
1270
+ p_proj_create.add_argument("--project", default=None, help="project id (status)")
1271
+ p_proj_create.add_argument("--home", default=None, help="EDUEVIDENCE_HOME override")
1272
+ p_proj_create.set_defaults(func=_cmd_project)
1273
+
1274
+ p_research = sub.add_parser("research", help="V2 research planning/run")
1275
+ p_research.add_argument("action", choices=["plan", "run", "resume"])
1276
+ p_research.add_argument("--project", required=True)
1277
+ p_research.add_argument("--home", default=None)
1278
+ p_research.set_defaults(func=_cmd_research)
1279
+
1280
+ p_graph = sub.add_parser("graph", help="V2 graph validation")
1281
+ p_graph.add_argument("action", choices=["validate"])
1282
+ p_graph.add_argument("--project", required=True)
1283
+ p_graph.add_argument("--home", default=None)
1284
+ p_graph.set_defaults(func=_cmd_graph)
1285
+
1286
+ p_study = sub.add_parser("study", help="V2 study design (grounded)")
1287
+ p_study.add_argument("action", choices=["design"])
1288
+ p_study.add_argument("--project", required=True)
1289
+ p_study.add_argument("--gap", action="append", default=[], help="GAP-xxx ids")
1290
+ p_study.add_argument("--question", default=None, help="research question")
1291
+ p_study.add_argument("--design-type", default="rct",
1292
+ choices=["rct", "cluster_rct", "quasi_experimental",
1293
+ "pre_post", "observational", "survey",
1294
+ "qualitative", "mixed_methods"])
1295
+ p_study.add_argument("--population", default=None)
1296
+ p_study.add_argument("--sampling", default=None)
1297
+ p_study.add_argument("--intervention", default=None)
1298
+ p_study.add_argument("--comparison", default=None)
1299
+ p_study.add_argument("--outcome", default=None)
1300
+ p_study.add_argument("--measure", default=None)
1301
+ p_study.add_argument("--assignment", default=None)
1302
+ p_study.add_argument("--home", default=None)
1303
+
1304
+ p_data = sub.add_parser("data", help="V2 dataset ingest")
1305
+ p_data.add_argument("action", choices=["ingest"])
1306
+ p_data.add_argument("--project", required=True)
1307
+ p_data.add_argument("--design", required=True)
1308
+ p_data.add_argument("--file", required=True, type=Path)
1309
+ p_data.add_argument("--privacy", default="internal",
1310
+ choices=["public", "internal", "confidential", "restricted"])
1311
+ p_data.add_argument("--home", default=None)
1312
+ p_data.set_defaults(func=_cmd_data)
1313
+
1314
+ p_analyze = sub.add_parser("analyze", help="V2 analysis (native descriptive)")
1315
+ p_analyze.add_argument("--project", required=True)
1316
+ p_analyze.add_argument("--plan", required=True)
1317
+ p_analyze.add_argument("--home", default=None)
1318
+ p_analyze.set_defaults(func=_cmd_analyze)
1319
+
1320
+ p_adjudicate = sub.add_parser("adjudicate", help="V2 tribunal over current graph")
1321
+ p_adjudicate.add_argument("--project", required=True)
1322
+ p_adjudicate.add_argument("--home", default=None)
1323
+ p_adjudicate.set_defaults(func=_cmd_adjudicate)
1324
+
1325
+ p_report = sub.add_parser("report", help="V2 projection/report")
1326
+ p_report.add_argument("--project", required=True)
1327
+ p_report.add_argument("--theme", default="claude",
1328
+ choices=["claude", "academic", "datalab", "datalab-dark", "presentation"])
1329
+ p_report.add_argument("--home", default=None)
1330
+ p_report.set_defaults(func=_cmd_report)
1331
+
1332
+
1333
+ p_pilot = sub.add_parser("pilot", help="V3 Decision-to-Outcome Loop")
1334
+ p_pilot.add_argument("action", choices=["register", "import", "analyze-link", "redecide"])
1335
+ p_pilot.add_argument("--project", required=True)
1336
+ p_pilot.add_argument("--decision", default=None, help="decision snapshot id (register)")
1337
+ p_pilot.add_argument("--title", default=None, help="pilot title (register)")
1338
+ p_pilot.add_argument("--start", default=None, help="start ISO date-time (register)")
1339
+ p_pilot.add_argument("--end", default=None, help="end ISO date-time (register)")
1340
+ p_pilot.add_argument("--condition", action="append", default=[], help="implementation condition (register)")
1341
+ p_pilot.add_argument("--sample", type=int, default=None, help="sample size (register)")
1342
+ p_pilot.add_argument("--design", default=None, help="study design id (register)")
1343
+ p_pilot.add_argument("--outcome", action="append", default=[], help="outcome taxonomy token")
1344
+ p_pilot.add_argument("--pilot", default=None, help="pilot id (import/analyze-link/redecide)")
1345
+ p_pilot.add_argument("--file", type=Path, default=None, help="outcome CSV (import)")
1346
+ p_pilot.add_argument("--privacy", default="internal", choices=["public", "internal", "confidential", "restricted"])
1347
+ p_pilot.add_argument("--analysis", default=None, help="analysis run id (analyze-link)")
1348
+ p_pilot.add_argument("--claim", default=None, help="claim id (redecide)")
1349
+ p_pilot.add_argument("--measure", default=None, help="finding measure (redecide)")
1350
+ p_pilot.add_argument("--effect", default="positive", choices=["positive", "negative", "null"])
1351
+ p_pilot.add_argument("--result-text", default=None, help="raw result text (redecide)")
1352
+ p_pilot.add_argument("--relation", default="support", choices=["support", "contradict", "neutral"])
1353
+ p_pilot.add_argument("--effect-value", type=float, default=None)
1354
+ p_pilot.add_argument("--home", default=None)
1355
+ p_pilot.set_defaults(func=_cmd_pilot)
1356
+
1357
+ p_syn = sub.add_parser("synthesize", help="V3 cross-project library synthesis")
1358
+ p_syn.add_argument("--home", default=None)
1359
+ p_syn.add_argument("--out", default=None, type=Path)
1360
+ p_syn.set_defaults(func=_cmd_synthesize)
1361
+
1362
+ p_bench = sub.add_parser("benchmark", help="V3 Layer B empirical benchmark")
1363
+ p_bench.add_argument("action", choices=["run", "eval", "report"])
1364
+ p_bench.add_argument("--baselines", default="B2_standard_agent,B3_eduevidence_single")
1365
+ p_bench.add_argument("--questions", default="benchmarks/questions.jsonl")
1366
+ p_bench.add_argument("--repeats", type=int, default=3)
1367
+ p_bench.add_argument("--driver", default=None, choices=["api", "cli", "sim"],
1368
+ help="api | cli (omp) | sim (harness validation only); default: auto (api > cli > sim)")
1369
+ p_bench.add_argument("--out", default="benchmarks/empirical/run-001")
1370
+ p_bench.add_argument("--budget", type=int, default=1000000)
1371
+ p_bench.add_argument("--run", default=None, help="run dir (eval/report)")
1372
+ p_bench.add_argument("--annotations", default="benchmarks/annotations")
1373
+ p_bench.add_argument("--report", default="benchmarks/empirical/v3-report.md")
1374
+ p_bench.set_defaults(func=_cmd_benchmark)
1375
+
1376
+
1377
+ p_domain = sub.add_parser("domain", help="V4 domain registry (EvidenceCore)")
1378
+ p_domain.add_argument("action", choices=["list", "select", "check"])
1379
+ p_domain.add_argument("--domain", default=None, help="domain id (select/check)")
1380
+ p_domain.set_defaults(func=_cmd_domain)
1381
+
1382
+ p_living = sub.add_parser("living", help="V4 Living Evidence (subscribe/refresh/status)")
1383
+ p_living.add_argument("action", choices=["subscribe", "refresh", "status"])
1384
+ p_living.add_argument("--project", required=True)
1385
+ p_living.add_argument("--decision", default=None, help="decision snapshot id (subscribe)")
1386
+ p_living.add_argument("--term", action="append", default=[], help="query term (subscribe)")
1387
+ p_living.add_argument("--subscription", default=None, help="subscription id (refresh/status)")
1388
+ p_living.add_argument("--evidence-file", default=None, type=Path, help="new evidence JSONL (refresh)")
1389
+ p_living.add_argument("--home", default=None)
1390
+ p_living.set_defaults(func=_cmd_living)
1391
+
1392
+ p_judge = sub.add_parser("benchmark-judge", help="V4 LLM judge evaluation")
1393
+ p_judge.add_argument("action", choices=["run", "report"])
1394
+ p_judge.add_argument("--run", default="benchmarks/empirical/run-empirical-01")
1395
+ p_judge.add_argument("--out", default="benchmarks/empirical/judge-evaluation.json")
1396
+ p_judge.add_argument("--report", default="benchmarks/empirical/judge-report.md")
1397
+ p_judge.add_argument("--limit", type=int, default=60)
1398
+ p_judge.set_defaults(func=_cmd_benchmark_judge)
1399
+
1400
+ p_migrate = sub.add_parser("migrate-v1", help="import a V1 pack into a V2 project")
1401
+ p_migrate.add_argument("--pack", required=True, type=Path)
1402
+ p_migrate.add_argument("--title", default=None)
1403
+ p_migrate.add_argument("--home", default=None)
1404
+ p_migrate.set_defaults(func=_cmd_migrate)
1405
+
1406
+ p_dash = sub.add_parser("dashboard", help="start local research & token dashboard")
1407
+ p_dash.add_argument("--host", default="127.0.0.1")
1408
+ p_dash.add_argument("--port", type=int, default=8765)
1409
+ p_dash.set_defaults(func=_cmd_dashboard)
1410
+
1411
+ p_srch = sub.add_parser("search", help="multi-channel hybrid search")
1412
+ p_srch.add_argument("query", help="search query")
1413
+ p_srch.add_argument("--limit", type=int, default=10)
1414
+ p_srch.add_argument("--academic", action="store_true", help="academic only")
1415
+ p_srch.set_defaults(func=_cmd_search)
1416
+
1417
+ p_did = sub.add_parser("did", help="run DID regression on classroom CSV")
1418
+ p_did.add_argument("csv", type=Path, help="CSV file path")
1419
+ p_did.set_defaults(func=_cmd_did)
1420
+
1421
+ p_eff = sub.add_parser("effect", help="calculate Hedges g effect size")
1422
+ p_eff.add_argument("--mean1", type=float, required=True)
1423
+ p_eff.add_argument("--sd1", type=float, required=True)
1424
+ p_eff.add_argument("--n1", type=int, required=True)
1425
+ p_eff.add_argument("--mean2", type=float, required=True)
1426
+ p_eff.add_argument("--sd2", type=float, required=True)
1427
+ p_eff.add_argument("--n2", type=int, required=True)
1428
+ p_eff.set_defaults(func=_cmd_effect)
1429
+
1430
+ p_lnt = sub.add_parser("lint", help="run skill static linter")
1431
+ p_lnt.set_defaults(func=_cmd_lint)
1432
+
1433
+ args = parser.parse_args(argv)
1434
+ if os.environ.get("EDUEVIDENCE_LOG_LEVEL"):
1435
+ # Opt-in engine/retrieval diagnostics (E4): EDUEVIDENCE_LOG_LEVEL=INFO/DEBUG
1436
+ enable_console_logging(getattr(logging, os.environ["EDUEVIDENCE_LOG_LEVEL"].upper(), logging.INFO))
1437
+ try:
1438
+ return args.func(args)
1439
+ except FileNotFoundError as exc:
1440
+ # V2 project lookups fail cleanly like V1 (ERROR + exit 2)
1441
+ print(f"ERROR: {exc}", file=sys.stderr)
1442
+ return 2
1443
+ except (ValueError, RuntimeError) as exc:
1444
+ # v3/v4 command handlers validate inputs and raise ValueError on
1445
+ # contract violations; surface cleanly.
1446
+ print(f"ERROR: {exc}", file=sys.stderr)
1447
+ return 2
1448
+ except (KeyError, AttributeError, TypeError) as exc:
1449
+ # v4 handlers: unknown domain ids, missing required args etc. must not
1450
+ # leak raw tracebacks (review P2).
1451
+ print(f"ERROR: {exc}", file=sys.stderr)
1452
+ return 2
1453
+
1454
+
1455
+ if __name__ == "__main__":
1456
+ sys.exit(main())