@researai/deepscientist 1.5.0 → 1.5.2

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 (168) hide show
  1. package/AGENTS.md +26 -0
  2. package/README.md +47 -161
  3. package/assets/connectors/lingzhu/openclaw-bridge/README.md +124 -0
  4. package/assets/connectors/lingzhu/openclaw-bridge/index.ts +162 -0
  5. package/assets/connectors/lingzhu/openclaw-bridge/openclaw.plugin.json +145 -0
  6. package/assets/connectors/lingzhu/openclaw-bridge/package.json +35 -0
  7. package/assets/connectors/lingzhu/openclaw-bridge/src/cli.ts +180 -0
  8. package/assets/connectors/lingzhu/openclaw-bridge/src/config.ts +196 -0
  9. package/assets/connectors/lingzhu/openclaw-bridge/src/debug-log.ts +111 -0
  10. package/assets/connectors/lingzhu/openclaw-bridge/src/events.ts +4 -0
  11. package/assets/connectors/lingzhu/openclaw-bridge/src/http-handler.ts +1133 -0
  12. package/assets/connectors/lingzhu/openclaw-bridge/src/image-cache.ts +75 -0
  13. package/assets/connectors/lingzhu/openclaw-bridge/src/lingzhu-tools.ts +246 -0
  14. package/assets/connectors/lingzhu/openclaw-bridge/src/transform.ts +541 -0
  15. package/assets/connectors/lingzhu/openclaw-bridge/src/types.ts +131 -0
  16. package/assets/connectors/lingzhu/openclaw-bridge/tsconfig.json +14 -0
  17. package/assets/connectors/lingzhu/openclaw.lingzhu.config.template.json +39 -0
  18. package/bin/ds.js +2048 -166
  19. package/docs/en/00_QUICK_START.md +152 -0
  20. package/docs/en/01_SETTINGS_REFERENCE.md +1104 -0
  21. package/docs/en/02_START_RESEARCH_GUIDE.md +404 -0
  22. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +325 -0
  23. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +216 -0
  24. package/docs/en/05_TUI_GUIDE.md +141 -0
  25. package/docs/en/06_RUNTIME_AND_CANVAS.md +679 -0
  26. package/docs/en/07_MEMORY_AND_MCP.md +253 -0
  27. package/docs/en/08_FIGURE_STYLE_GUIDE.md +97 -0
  28. package/docs/en/09_DOCTOR.md +152 -0
  29. package/docs/en/90_ARCHITECTURE.md +247 -0
  30. package/docs/en/91_DEVELOPMENT.md +195 -0
  31. package/docs/en/99_ACKNOWLEDGEMENTS.md +29 -0
  32. package/docs/zh/00_QUICK_START.md +152 -0
  33. package/docs/zh/01_SETTINGS_REFERENCE.md +1137 -0
  34. package/docs/zh/02_START_RESEARCH_GUIDE.md +414 -0
  35. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +324 -0
  36. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +230 -0
  37. package/docs/zh/05_TUI_GUIDE.md +128 -0
  38. package/docs/zh/06_RUNTIME_AND_CANVAS.md +271 -0
  39. package/docs/zh/07_MEMORY_AND_MCP.md +235 -0
  40. package/docs/zh/08_FIGURE_STYLE_GUIDE.md +97 -0
  41. package/docs/zh/09_DOCTOR.md +154 -0
  42. package/docs/zh/99_ACKNOWLEDGEMENTS.md +29 -0
  43. package/install.sh +41 -16
  44. package/package.json +5 -2
  45. package/pyproject.toml +1 -1
  46. package/src/deepscientist/__init__.py +6 -1
  47. package/src/deepscientist/artifact/guidance.py +9 -2
  48. package/src/deepscientist/artifact/service.py +1026 -39
  49. package/src/deepscientist/bash_exec/monitor.py +27 -5
  50. package/src/deepscientist/bash_exec/runtime.py +639 -0
  51. package/src/deepscientist/bash_exec/service.py +99 -16
  52. package/src/deepscientist/bridges/base.py +3 -0
  53. package/src/deepscientist/bridges/connectors.py +292 -13
  54. package/src/deepscientist/channels/qq.py +19 -2
  55. package/src/deepscientist/channels/relay.py +1 -0
  56. package/src/deepscientist/cli.py +32 -25
  57. package/src/deepscientist/config/models.py +28 -2
  58. package/src/deepscientist/config/service.py +202 -7
  59. package/src/deepscientist/connector_runtime.py +2 -0
  60. package/src/deepscientist/daemon/api/handlers.py +68 -6
  61. package/src/deepscientist/daemon/api/router.py +3 -0
  62. package/src/deepscientist/daemon/app.py +531 -15
  63. package/src/deepscientist/doctor.py +511 -0
  64. package/src/deepscientist/gitops/diff.py +3 -0
  65. package/src/deepscientist/home.py +26 -2
  66. package/src/deepscientist/latex_runtime.py +17 -4
  67. package/src/deepscientist/lingzhu_support.py +182 -0
  68. package/src/deepscientist/mcp/context.py +3 -1
  69. package/src/deepscientist/mcp/server.py +55 -2
  70. package/src/deepscientist/prompts/builder.py +222 -58
  71. package/src/deepscientist/quest/layout.py +2 -0
  72. package/src/deepscientist/quest/service.py +133 -14
  73. package/src/deepscientist/quest/stage_views.py +65 -1
  74. package/src/deepscientist/runners/codex.py +2 -0
  75. package/src/deepscientist/runtime_tools/__init__.py +16 -0
  76. package/src/deepscientist/runtime_tools/builtins.py +19 -0
  77. package/src/deepscientist/runtime_tools/models.py +29 -0
  78. package/src/deepscientist/runtime_tools/registry.py +40 -0
  79. package/src/deepscientist/runtime_tools/service.py +59 -0
  80. package/src/deepscientist/runtime_tools/tinytex.py +25 -0
  81. package/src/deepscientist/shared.py +44 -17
  82. package/src/deepscientist/tinytex.py +276 -0
  83. package/src/prompts/connectors/lingzhu.md +15 -0
  84. package/src/prompts/connectors/qq.md +121 -0
  85. package/src/prompts/system.md +214 -37
  86. package/src/skills/analysis-campaign/SKILL.md +46 -7
  87. package/src/skills/baseline/SKILL.md +12 -5
  88. package/src/skills/decision/SKILL.md +7 -5
  89. package/src/skills/experiment/SKILL.md +22 -5
  90. package/src/skills/finalize/SKILL.md +9 -5
  91. package/src/skills/idea/SKILL.md +6 -5
  92. package/src/skills/intake-audit/SKILL.md +277 -0
  93. package/src/skills/intake-audit/references/state-audit-template.md +41 -0
  94. package/src/skills/rebuttal/SKILL.md +409 -0
  95. package/src/skills/rebuttal/references/action-plan-template.md +63 -0
  96. package/src/skills/rebuttal/references/evidence-update-template.md +30 -0
  97. package/src/skills/rebuttal/references/response-letter-template.md +113 -0
  98. package/src/skills/rebuttal/references/review-matrix-template.md +55 -0
  99. package/src/skills/review/SKILL.md +295 -0
  100. package/src/skills/review/references/experiment-todo-template.md +29 -0
  101. package/src/skills/review/references/review-report-template.md +83 -0
  102. package/src/skills/review/references/revision-log-template.md +40 -0
  103. package/src/skills/scout/SKILL.md +6 -5
  104. package/src/skills/write/SKILL.md +8 -4
  105. package/src/tui/dist/components/WelcomePanel.js +17 -43
  106. package/src/tui/dist/components/messages/BashExecOperationMessage.js +3 -2
  107. package/src/tui/package.json +1 -1
  108. package/src/ui/dist/assets/{AiManusChatView-7v-dHngU.js → AiManusChatView-CZpg376x.js} +127 -597
  109. package/src/ui/dist/assets/{AnalysisPlugin-B_Xmz-KE.js → AnalysisPlugin-CtHA22g3.js} +1 -1
  110. package/src/ui/dist/assets/{AutoFigurePlugin-Cko-0tm1.js → AutoFigurePlugin-BSWmLMmF.js} +63 -8
  111. package/src/ui/dist/assets/{CliPlugin-BsU0ht7q.js → CliPlugin-CJ7jdm_s.js} +43 -609
  112. package/src/ui/dist/assets/{CodeEditorPlugin-DcMMP0Rt.js → CodeEditorPlugin-DhInVGFf.js} +8 -8
  113. package/src/ui/dist/assets/{CodeViewerPlugin-BqoQ5QyY.js → CodeViewerPlugin-D1n8S9r5.js} +5 -5
  114. package/src/ui/dist/assets/{DocViewerPlugin-D7eHNhU6.js → DocViewerPlugin-C4XM_kqk.js} +3 -3
  115. package/src/ui/dist/assets/{GitDiffViewerPlugin-DLJN42T5.js → GitDiffViewerPlugin-W6kS9r6v.js} +1 -1
  116. package/src/ui/dist/assets/{ImageViewerPlugin-gJMV7MOu.js → ImageViewerPlugin-DPeUx_Oz.js} +5 -6
  117. package/src/ui/dist/assets/{LabCopilotPanel-B857sfxP.js → LabCopilotPanel-eAelUaub.js} +12 -15
  118. package/src/ui/dist/assets/LabPlugin-BbOrBxKY.js +2676 -0
  119. package/src/ui/dist/assets/{LatexPlugin-DWKEo-Wj.js → LatexPlugin-C-HhkVXY.js} +16 -16
  120. package/src/ui/dist/assets/{MarkdownViewerPlugin-DBzoEmhv.js → MarkdownViewerPlugin-BDIzIBfh.js} +4 -4
  121. package/src/ui/dist/assets/{MarketplacePlugin-DoHc-8vo.js → MarketplacePlugin-DAOJphwr.js} +3 -3
  122. package/src/ui/dist/assets/{NotebookEditor-CKjKH-yS.js → NotebookEditor-BsoMvDoU.js} +3 -3
  123. package/src/ui/dist/assets/{PdfLoader-zFoL0VPo.js → PdfLoader-fiC7RtHf.js} +1 -1
  124. package/src/ui/dist/assets/{PdfMarkdownPlugin-DXPaL9Nt.js → PdfMarkdownPlugin-C5OxZBFK.js} +3 -3
  125. package/src/ui/dist/assets/{PdfViewerPlugin-DhK8qCFp.js → PdfViewerPlugin-CAbxQebk.js} +10 -10
  126. package/src/ui/dist/assets/{SearchPlugin-CdSi6krf.js → SearchPlugin-SE33Lb9B.js} +1 -1
  127. package/src/ui/dist/assets/{Stepper-V-WiDQJl.js → Stepper-0Av7GfV7.js} +1 -1
  128. package/src/ui/dist/assets/{TextViewerPlugin-hIs1Efiu.js → TextViewerPlugin-Daf2gJDI.js} +4 -4
  129. package/src/ui/dist/assets/{VNCViewer-DG8b0q2X.js → VNCViewer-BKrMUIOX.js} +9 -10
  130. package/src/ui/dist/assets/{bibtex-HDac6fVW.js → bibtex-JBdOEe45.js} +1 -1
  131. package/src/ui/dist/assets/{code-BnBeNxBc.js → code-B0TDFCZz.js} +1 -1
  132. package/src/ui/dist/assets/{file-content-IRQ3jHb8.js → file-content-3YtrSacz.js} +1 -1
  133. package/src/ui/dist/assets/{file-diff-panel-DZoQ9I6r.js → file-diff-panel-CJEg5OG1.js} +1 -1
  134. package/src/ui/dist/assets/{file-socket-BMCdLc-P.js → file-socket-CYQYdmB1.js} +1 -1
  135. package/src/ui/dist/assets/{file-utils-CltILB3w.js → file-utils-Cd1C9Ppl.js} +1 -1
  136. package/src/ui/dist/assets/{image-Boe6ffhu.js → image-B33ctrvC.js} +1 -1
  137. package/src/ui/dist/assets/{index-2Zf65FZt.js → index-9CLPVeZh.js} +1 -1
  138. package/src/ui/dist/assets/{index-DZqJ-qAM.js → index-BNQWqmJ2.js} +60 -2154
  139. package/src/ui/dist/assets/{index-DO43pFZP.js → index-BVXsmS7V.js} +84086 -84365
  140. package/src/ui/dist/assets/{index-BlplpvE1.js → index-Buw_N1VQ.js} +2 -2
  141. package/src/ui/dist/assets/{index-Bq2bvfkl.css → index-SwmFAld3.css} +2622 -2619
  142. package/src/ui/dist/assets/{message-square-mUHn_Ssb.js → message-square-D0cUJ9yU.js} +1 -1
  143. package/src/ui/dist/assets/{monaco-fe0arNEU.js → monaco-UZLYkp2n.js} +1 -1
  144. package/src/ui/dist/assets/{popover-D_7i19qU.js → popover-CTeiY-dK.js} +1 -1
  145. package/src/ui/dist/assets/{project-sync-DyVGrU7H.js → project-sync-Dbs01Xky.js} +2 -8
  146. package/src/ui/dist/assets/{sigma-BzazRyxQ.js → sigma-CM08S-xT.js} +1 -1
  147. package/src/ui/dist/assets/{tooltip-DN_yjHFH.js → tooltip-pDtzvU9p.js} +1 -1
  148. package/src/ui/dist/assets/trash-YvPCP-da.js +32 -0
  149. package/src/ui/dist/assets/{useCliAccess-DV2L2Qxy.js → useCliAccess-Bavi74Ac.js} +12 -42
  150. package/src/ui/dist/assets/{useFileDiffOverlay-DyTj-p_V.js → useFileDiffOverlay-CVXY6oeg.js} +1 -1
  151. package/src/ui/dist/assets/{wrap-text-ozYHtUwq.js → wrap-text-Cf4flRW7.js} +1 -1
  152. package/src/ui/dist/assets/{zoom-out-BN9MUyCQ.js → zoom-out-Hb0Z1YpT.js} +1 -1
  153. package/src/ui/dist/index.html +2 -2
  154. package/uv.lock +1155 -0
  155. package/assets/fonts/Inter-Variable.ttf +0 -0
  156. package/assets/fonts/NotoSerifSC-Regular-C94HN_ZN.ttf +0 -0
  157. package/assets/fonts/NunitoSans-Variable.ttf +0 -0
  158. package/assets/fonts/Satoshi-Medium-ByP-Zb-9.woff2 +0 -0
  159. package/assets/fonts/SourceSans3-Variable.ttf +0 -0
  160. package/assets/fonts/ds-fonts.css +0 -83
  161. package/src/ui/dist/assets/Inter-Variable-VF2RPR_K.ttf +0 -0
  162. package/src/ui/dist/assets/LabPlugin-bL7rpic8.js +0 -43
  163. package/src/ui/dist/assets/NotoSerifSC-Regular-C94HN_ZN-C94HN_ZN.ttf +0 -0
  164. package/src/ui/dist/assets/NunitoSans-Variable-B_ZymHAd.ttf +0 -0
  165. package/src/ui/dist/assets/Satoshi-Medium-ByP-Zb-9-GkA34YXu.woff2 +0 -0
  166. package/src/ui/dist/assets/SourceSans3-Variable-CD-WOsSK.ttf +0 -0
  167. package/src/ui/dist/assets/info-CcsK_htA.js +0 -18
  168. package/src/ui/dist/assets/user-plus-BusDx-hF.js +0 -79
@@ -91,6 +91,41 @@ class ArtifactService:
91
91
  self.baselines = BaselineRegistry(home)
92
92
  self.quest_service = QuestService(home)
93
93
 
94
+ def _normalize_evaluation_summary(self, payload: dict[str, Any] | None) -> dict[str, str] | None:
95
+ if not isinstance(payload, dict):
96
+ return None
97
+ normalized: dict[str, str] = {}
98
+ for key in (
99
+ "takeaway",
100
+ "claim_update",
101
+ "baseline_relation",
102
+ "comparability",
103
+ "failure_mode",
104
+ "next_action",
105
+ ):
106
+ value = payload.get(key)
107
+ if value is None:
108
+ continue
109
+ text = str(value).strip()
110
+ if text:
111
+ normalized[key] = text
112
+ return normalized or None
113
+
114
+ def _evaluation_summary_markdown_lines(self, payload: dict[str, Any] | None) -> list[str]:
115
+ normalized = self._normalize_evaluation_summary(payload)
116
+ if not normalized:
117
+ return ["- Not recorded."]
118
+ labels = (
119
+ ("takeaway", "Takeaway"),
120
+ ("claim_update", "Claim Update"),
121
+ ("baseline_relation", "Baseline Relation"),
122
+ ("comparability", "Comparability"),
123
+ ("failure_mode", "Failure Mode"),
124
+ ("next_action", "Next Action"),
125
+ )
126
+ lines = [f"- {label}: {normalized[key]}" for key, label in labels if normalized.get(key)]
127
+ return lines or ["- Not recorded."]
128
+
94
129
  def _workspace_root_for(self, quest_root: Path, workspace_root: Path | None = None) -> Path:
95
130
  if workspace_root is not None:
96
131
  return workspace_root
@@ -387,6 +422,207 @@ class ArtifactService:
387
422
  write_json(path, normalized)
388
423
  return normalized
389
424
 
425
+ def _analysis_baseline_inventory_path(self, quest_root: Path) -> Path:
426
+ return ensure_dir(quest_root / "artifacts" / "baselines") / "analysis_inventory.json"
427
+
428
+ def _read_analysis_baseline_inventory(self, quest_root: Path) -> dict[str, Any]:
429
+ path = self._analysis_baseline_inventory_path(quest_root)
430
+ payload = read_json(path, {})
431
+ if not isinstance(payload, dict):
432
+ payload = {}
433
+ entries = payload.get("entries") if isinstance(payload.get("entries"), list) else []
434
+ return {
435
+ "schema_version": 1,
436
+ "entries": [dict(item) for item in entries if isinstance(item, dict)],
437
+ "updated_at": payload.get("updated_at"),
438
+ }
439
+
440
+ def _write_analysis_baseline_inventory(self, quest_root: Path, payload: dict[str, Any]) -> dict[str, Any]:
441
+ path = self._analysis_baseline_inventory_path(quest_root)
442
+ normalized_entries = payload.get("entries") if isinstance(payload.get("entries"), list) else []
443
+ normalized = {
444
+ "schema_version": 1,
445
+ "entries": [dict(item) for item in normalized_entries if isinstance(item, dict)],
446
+ "updated_at": utc_now(),
447
+ }
448
+ write_json(path, normalized)
449
+ return normalized
450
+
451
+ def _normalize_baseline_root_rel_path(
452
+ self,
453
+ quest_root: Path,
454
+ baseline_root_rel_path: str | None,
455
+ *,
456
+ baseline_id: str | None = None,
457
+ ) -> tuple[str | None, str | None]:
458
+ raw = str(baseline_root_rel_path or "").strip()
459
+ if not raw:
460
+ return None, None
461
+ candidate = Path(raw)
462
+ resolved = candidate.resolve() if candidate.is_absolute() else resolve_within(quest_root, raw)
463
+ if not resolved.exists():
464
+ raise FileNotFoundError(f"Baseline root does not exist: {resolved}")
465
+ try:
466
+ relative = resolved.relative_to(quest_root.resolve()).as_posix()
467
+ except ValueError as exc:
468
+ raise ValueError("`baseline_root_rel_path` must stay within quest_root.") from exc
469
+ parts = Path(relative).parts
470
+ if len(parts) < 3 or parts[0] != "baselines" or parts[1] not in {"local", "imported"}:
471
+ raise ValueError(
472
+ "`baseline_root_rel_path` must live under `baselines/local/<baseline_id>/...` or "
473
+ "`baselines/imported/<baseline_id>/...`."
474
+ )
475
+ normalized_baseline_id = str(baseline_id or parts[2]).strip() or None
476
+ if normalized_baseline_id and parts[2] != normalized_baseline_id:
477
+ raise ValueError(
478
+ f"`baseline_root_rel_path` points to baseline `{parts[2]}`, which does not match `{normalized_baseline_id}`."
479
+ )
480
+ return relative, parts[1]
481
+
482
+ @staticmethod
483
+ def _analysis_baseline_label(payload: dict[str, Any]) -> str:
484
+ baseline_id = str(payload.get("baseline_id") or "baseline").strip() or "baseline"
485
+ parts = [f"`{baseline_id}`"]
486
+ variant_id = str(payload.get("variant_id") or "").strip()
487
+ if variant_id:
488
+ parts.append(f"variant `{variant_id}`")
489
+ benchmark = str(payload.get("benchmark") or "").strip()
490
+ split = str(payload.get("split") or "").strip()
491
+ if benchmark and split:
492
+ parts.append(f"benchmark `{benchmark}` / split `{split}`")
493
+ elif benchmark:
494
+ parts.append(f"benchmark `{benchmark}`")
495
+ elif split:
496
+ parts.append(f"split `{split}`")
497
+ reason = str(payload.get("reason") or "").strip()
498
+ if reason:
499
+ parts.append(f"reason: {reason}")
500
+ return " · ".join(parts)
501
+
502
+ def _normalize_required_baselines(self, quest_root: Path, values: list[object] | None) -> list[dict[str, Any]]:
503
+ normalized: list[dict[str, Any]] = []
504
+ for raw in values or []:
505
+ if not isinstance(raw, dict):
506
+ continue
507
+ baseline_id = str(raw.get("baseline_id") or "").strip()
508
+ if not baseline_id:
509
+ continue
510
+ baseline_root_rel_path, storage_mode = self._normalize_baseline_root_rel_path(
511
+ quest_root,
512
+ raw.get("baseline_root_rel_path"),
513
+ baseline_id=baseline_id,
514
+ )
515
+ normalized.append(
516
+ {
517
+ "baseline_id": baseline_id,
518
+ "variant_id": str(raw.get("variant_id") or "").strip() or None,
519
+ "reason": str(raw.get("reason") or "").strip() or None,
520
+ "benchmark": str(raw.get("benchmark") or "").strip() or None,
521
+ "split": str(raw.get("split") or "").strip() or None,
522
+ "baseline_root_rel_path": baseline_root_rel_path,
523
+ "storage_mode": storage_mode or (str(raw.get("storage_mode") or "").strip() or None),
524
+ "usage_scope": "supplementary",
525
+ }
526
+ )
527
+ return normalized
528
+
529
+ def _normalize_comparison_baselines(self, quest_root: Path, values: list[object] | None) -> list[dict[str, Any]]:
530
+ normalized: list[dict[str, Any]] = []
531
+ for raw in values or []:
532
+ if not isinstance(raw, dict):
533
+ continue
534
+ baseline_id = str(raw.get("baseline_id") or "").strip()
535
+ if not baseline_id:
536
+ continue
537
+ baseline_root_rel_path, storage_mode = self._normalize_baseline_root_rel_path(
538
+ quest_root,
539
+ raw.get("baseline_root_rel_path"),
540
+ baseline_id=baseline_id,
541
+ )
542
+ metrics_summary = (
543
+ normalize_metrics_summary(raw.get("metrics_summary"))
544
+ if isinstance(raw.get("metrics_summary"), dict)
545
+ else {}
546
+ )
547
+ normalized.append(
548
+ {
549
+ "baseline_id": baseline_id,
550
+ "variant_id": str(raw.get("variant_id") or "").strip() or None,
551
+ "benchmark": str(raw.get("benchmark") or "").strip() or None,
552
+ "split": str(raw.get("split") or "").strip() or None,
553
+ "reason": str(raw.get("reason") or "").strip() or None,
554
+ "metrics_summary": metrics_summary,
555
+ "evidence_paths": [
556
+ str(item).strip() for item in (raw.get("evidence_paths") or []) if str(item).strip()
557
+ ],
558
+ "baseline_root_rel_path": baseline_root_rel_path,
559
+ "storage_mode": storage_mode or (str(raw.get("storage_mode") or "").strip() or None),
560
+ "usage_scope": "supplementary",
561
+ "published": bool(raw.get("published", False)),
562
+ "published_entry_id": str(raw.get("published_entry_id") or "").strip() or None,
563
+ "status": str(raw.get("status") or "registered").strip() or "registered",
564
+ }
565
+ )
566
+ return normalized
567
+
568
+ @staticmethod
569
+ def _analysis_inventory_entry_key(payload: dict[str, Any]) -> tuple[str, str, str, str, str, str]:
570
+ origin = dict(payload.get("origin") or {}) if isinstance(payload.get("origin"), dict) else {}
571
+ return (
572
+ str(payload.get("baseline_id") or "").strip(),
573
+ str(payload.get("variant_id") or "").strip(),
574
+ str(origin.get("campaign_id") or "").strip(),
575
+ str(origin.get("slice_id") or "").strip(),
576
+ str(payload.get("benchmark") or "").strip(),
577
+ str(payload.get("split") or "").strip(),
578
+ )
579
+
580
+ @staticmethod
581
+ def _merge_analysis_inventory_entry(existing: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
582
+ merged = dict(existing)
583
+ for key, value in incoming.items():
584
+ if value is None:
585
+ continue
586
+ if isinstance(value, str) and not value.strip():
587
+ continue
588
+ if isinstance(value, (list, dict)) and not value:
589
+ continue
590
+ merged[key] = value
591
+ merged["updated_at"] = utc_now()
592
+ merged.setdefault("created_at", existing.get("created_at") or incoming.get("created_at") or utc_now())
593
+ return merged
594
+
595
+ def _upsert_analysis_baseline_inventory(self, quest_root: Path, entries: list[dict[str, Any]]) -> dict[str, Any]:
596
+ inventory = self._read_analysis_baseline_inventory(quest_root)
597
+ existing_entries = [dict(item) for item in (inventory.get("entries") or []) if isinstance(item, dict)]
598
+ by_key = {
599
+ self._analysis_inventory_entry_key(item): dict(item)
600
+ for item in existing_entries
601
+ if str(item.get("baseline_id") or "").strip()
602
+ }
603
+ for raw in entries:
604
+ if not isinstance(raw, dict):
605
+ continue
606
+ entry = dict(raw)
607
+ if not str(entry.get("baseline_id") or "").strip():
608
+ continue
609
+ key = self._analysis_inventory_entry_key(entry)
610
+ current = by_key.get(key)
611
+ if current is None:
612
+ stamped = dict(entry)
613
+ stamped.setdefault("created_at", utc_now())
614
+ stamped["updated_at"] = utc_now()
615
+ by_key[key] = stamped
616
+ continue
617
+ by_key[key] = self._merge_analysis_inventory_entry(current, entry)
618
+ normalized = self._write_analysis_baseline_inventory(
619
+ quest_root,
620
+ {
621
+ "entries": list(by_key.values()),
622
+ },
623
+ )
624
+ return normalized
625
+
390
626
  def _paper_root(self, quest_root: Path) -> Path:
391
627
  return ensure_dir(quest_root / "paper")
392
628
 
@@ -405,6 +641,114 @@ class ArtifactService:
405
641
  def _paper_bundle_manifest_path(self, quest_root: Path) -> Path:
406
642
  return self._paper_root(quest_root) / "paper_bundle_manifest.json"
407
643
 
644
+ def _paper_baseline_inventory_path(self, quest_root: Path) -> Path:
645
+ return self._paper_root(quest_root) / "baseline_inventory.json"
646
+
647
+ def _open_source_root(self, quest_root: Path) -> Path:
648
+ return ensure_dir(quest_root / "release" / "open_source")
649
+
650
+ def _open_source_manifest_path(self, quest_root: Path) -> Path:
651
+ return self._open_source_root(quest_root) / "manifest.json"
652
+
653
+ def _open_source_cleanup_plan_path(self, quest_root: Path) -> Path:
654
+ return self._open_source_root(quest_root) / "cleanup_plan.md"
655
+
656
+ def _open_source_include_paths_path(self, quest_root: Path) -> Path:
657
+ return self._open_source_root(quest_root) / "include_paths.json"
658
+
659
+ def _open_source_exclude_paths_path(self, quest_root: Path) -> Path:
660
+ return self._open_source_root(quest_root) / "exclude_paths.json"
661
+
662
+ def _write_paper_baseline_inventory(self, quest_root: Path) -> dict[str, Any]:
663
+ quest_yaml = self.quest_service.read_quest_yaml(quest_root)
664
+ confirmed_baseline_ref = (
665
+ dict(quest_yaml.get("confirmed_baseline_ref") or {})
666
+ if isinstance(quest_yaml.get("confirmed_baseline_ref"), dict)
667
+ else None
668
+ )
669
+ analysis_inventory = self._read_analysis_baseline_inventory(quest_root)
670
+ payload = {
671
+ "schema_version": 1,
672
+ "canonical_baseline_ref": confirmed_baseline_ref,
673
+ "supplementary_baselines": [
674
+ dict(item) for item in (analysis_inventory.get("entries") or []) if isinstance(item, dict)
675
+ ],
676
+ "updated_at": utc_now(),
677
+ }
678
+ write_json(self._paper_baseline_inventory_path(quest_root), payload)
679
+ return payload
680
+
681
+ def _ensure_open_source_prep(
682
+ self,
683
+ quest_root: Path,
684
+ *,
685
+ source_branch: str | None,
686
+ source_bundle_manifest_path: str,
687
+ baseline_inventory_path: str,
688
+ ) -> dict[str, Any]:
689
+ root = self._open_source_root(quest_root)
690
+ cleanup_plan_path = self._open_source_cleanup_plan_path(quest_root)
691
+ include_paths_path = self._open_source_include_paths_path(quest_root)
692
+ exclude_paths_path = self._open_source_exclude_paths_path(quest_root)
693
+ manifest_path = self._open_source_manifest_path(quest_root)
694
+ if not cleanup_plan_path.exists():
695
+ write_text(
696
+ cleanup_plan_path,
697
+ "\n".join(
698
+ [
699
+ "# Open Source Cleanup Plan",
700
+ "",
701
+ "## Goal",
702
+ "",
703
+ "Prepare a clean public code branch from the finalized paper line.",
704
+ "",
705
+ "## Keep",
706
+ "",
707
+ "- Core training / evaluation code needed to reproduce the public results.",
708
+ "",
709
+ "## Remove Or Private",
710
+ "",
711
+ "- Temporary logs, scratch files, local secrets, and unrelated experimental debris.",
712
+ "",
713
+ "## Before Release",
714
+ "",
715
+ "- Confirm README, license, and benchmark instructions are complete.",
716
+ "- Confirm only necessary files remain in scope.",
717
+ "",
718
+ ]
719
+ ).rstrip()
720
+ + "\n",
721
+ )
722
+ if not include_paths_path.exists():
723
+ write_json(include_paths_path, {"paths": []})
724
+ if not exclude_paths_path.exists():
725
+ write_json(exclude_paths_path, {"paths": []})
726
+ existing = read_json(manifest_path, {})
727
+ existing = existing if isinstance(existing, dict) else {}
728
+ manifest = {
729
+ **existing,
730
+ "schema_version": 1,
731
+ "status": str(existing.get("status") or "draft").strip() or "draft",
732
+ "source_branch": str(existing.get("source_branch") or source_branch or "").strip() or None,
733
+ "release_branch": str(existing.get("release_branch") or "").strip() or None,
734
+ "source_bundle_manifest_path": str(
735
+ existing.get("source_bundle_manifest_path") or source_bundle_manifest_path or ""
736
+ ).strip()
737
+ or source_bundle_manifest_path,
738
+ "baseline_inventory_path": str(existing.get("baseline_inventory_path") or baseline_inventory_path or "").strip()
739
+ or baseline_inventory_path,
740
+ "cleanup_plan_path": str(existing.get("cleanup_plan_path") or "release/open_source/cleanup_plan.md").strip()
741
+ or "release/open_source/cleanup_plan.md",
742
+ "include_paths_path": str(existing.get("include_paths_path") or "release/open_source/include_paths.json").strip()
743
+ or "release/open_source/include_paths.json",
744
+ "exclude_paths_path": str(existing.get("exclude_paths_path") or "release/open_source/exclude_paths.json").strip()
745
+ or "release/open_source/exclude_paths.json",
746
+ "created_at": existing.get("created_at") or utc_now(),
747
+ "updated_at": utc_now(),
748
+ }
749
+ write_json(manifest_path, manifest)
750
+ return manifest
751
+
408
752
  def _next_paper_outline_id(self, quest_root: Path) -> str:
409
753
  max_index = 0
410
754
  for root in (self._paper_outline_candidates_root(quest_root), self._paper_outline_revisions_root(quest_root)):
@@ -422,6 +766,45 @@ class ArtifactService:
422
766
  def _normalize_string_list(values: list[object] | None) -> list[str]:
423
767
  return [str(item).strip() for item in (values or []) if str(item).strip()]
424
768
 
769
+ def _normalize_campaign_origin(self, payload: dict[str, Any] | None) -> dict[str, Any] | None:
770
+ if not isinstance(payload, dict):
771
+ return None
772
+ origin_kind = str(payload.get("kind") or "analysis").strip().lower() or "analysis"
773
+ normalized = {
774
+ "kind": origin_kind,
775
+ "reason": str(payload.get("reason") or "").strip() or None,
776
+ "source_artifact_id": str(payload.get("source_artifact_id") or "").strip() or None,
777
+ "source_outline_ref": str(payload.get("source_outline_ref") or "").strip() or None,
778
+ "source_review_round": str(payload.get("source_review_round") or "").strip() or None,
779
+ "reviewer_item_ids": self._normalize_string_list(payload.get("reviewer_item_ids")),
780
+ }
781
+ if not any(value for key, value in normalized.items() if key != "kind"):
782
+ normalized["reason"] = None
783
+ return normalized
784
+
785
+ def _normalize_campaign_todo_items(self, todo_items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
786
+ normalized_items: list[dict[str, Any]] = []
787
+ for raw in todo_items or []:
788
+ if not isinstance(raw, dict):
789
+ continue
790
+ normalized_items.append(
791
+ {
792
+ "todo_id": str(raw.get("todo_id") or raw.get("slice_id") or "").strip() or None,
793
+ "slice_id": str(raw.get("slice_id") or "").strip() or None,
794
+ "title": str(raw.get("title") or "").strip() or None,
795
+ "status": str(raw.get("status") or "pending").strip() or "pending",
796
+ "research_question": str(raw.get("research_question") or "").strip() or None,
797
+ "experimental_design": str(raw.get("experimental_design") or "").strip() or None,
798
+ "completion_condition": str(raw.get("completion_condition") or "").strip() or None,
799
+ "why_now": str(raw.get("why_now") or "").strip() or None,
800
+ "success_criteria": str(raw.get("success_criteria") or "").strip() or None,
801
+ "abandonment_criteria": str(raw.get("abandonment_criteria") or "").strip() or None,
802
+ "reviewer_item_ids": self._normalize_string_list(raw.get("reviewer_item_ids")),
803
+ "manuscript_targets": self._normalize_string_list(raw.get("manuscript_targets")),
804
+ }
805
+ )
806
+ return normalized_items
807
+
425
808
  def _normalize_paper_outline_record(
426
809
  self,
427
810
  *,
@@ -1073,6 +1456,80 @@ class ArtifactService:
1073
1456
  ]
1074
1457
  return candidates[-1] if candidates else None
1075
1458
 
1459
+ def _latest_branch_idea_id(self, quest_root: Path, branch_name: str) -> str | None:
1460
+ normalized_branch = str(branch_name or "").strip()
1461
+ if not normalized_branch:
1462
+ return None
1463
+ latest_idea = self._latest_idea_for_branch(quest_root, normalized_branch)
1464
+ if isinstance(latest_idea, dict):
1465
+ candidate = str(latest_idea.get("idea_id") or "").strip()
1466
+ if candidate:
1467
+ return candidate
1468
+ latest_main_run = self._latest_main_run_for_branch(quest_root, normalized_branch)
1469
+ if isinstance(latest_main_run, dict):
1470
+ candidate = str(latest_main_run.get("idea_id") or "").strip()
1471
+ if candidate:
1472
+ return candidate
1473
+ latest_match: tuple[str, int, str] | None = None
1474
+ latest_candidate: str | None = None
1475
+ for item in self.quest_service._collect_artifacts(quest_root):
1476
+ payload = dict(item.get("payload") or {}) if isinstance(item.get("payload"), dict) else {}
1477
+ if not payload:
1478
+ continue
1479
+ if str(payload.get("branch") or "").strip() != normalized_branch:
1480
+ continue
1481
+ candidate = str(payload.get("idea_id") or "").strip()
1482
+ if not candidate:
1483
+ continue
1484
+ artifact_path = str(item.get("path") or "")
1485
+ try:
1486
+ artifact_mtime_ns = Path(artifact_path).stat().st_mtime_ns if artifact_path else 0
1487
+ except OSError:
1488
+ artifact_mtime_ns = 0
1489
+ sort_key = (
1490
+ str(payload.get("updated_at") or payload.get("created_at") or ""),
1491
+ artifact_mtime_ns,
1492
+ artifact_path,
1493
+ )
1494
+ if latest_match is None or sort_key > latest_match:
1495
+ latest_match = sort_key
1496
+ latest_candidate = candidate
1497
+ if latest_match is not None and latest_candidate:
1498
+ return latest_candidate
1499
+ return None
1500
+
1501
+ def _resolve_analysis_parent_context(
1502
+ self,
1503
+ quest_root: Path,
1504
+ *,
1505
+ state: dict[str, Any],
1506
+ ) -> tuple[str, Path, str | None]:
1507
+ current_root_raw = str(state.get("current_workspace_root") or "").strip()
1508
+ head_root_raw = str(state.get("research_head_worktree_root") or "").strip()
1509
+ parent_worktree_root: Path | None = None
1510
+ for raw in (current_root_raw, head_root_raw):
1511
+ if not raw:
1512
+ continue
1513
+ candidate = Path(raw)
1514
+ if candidate.exists():
1515
+ parent_worktree_root = candidate
1516
+ break
1517
+ if parent_worktree_root is None:
1518
+ parent_worktree_root = self._workspace_root_for(quest_root)
1519
+
1520
+ parent_branch = (
1521
+ str(state.get("current_workspace_branch") or "").strip()
1522
+ or str(state.get("research_head_branch") or "").strip()
1523
+ or current_branch(parent_worktree_root)
1524
+ or current_branch(self._workspace_root_for(quest_root))
1525
+ )
1526
+ parent_branch = str(parent_branch or "").strip()
1527
+ if not parent_branch:
1528
+ raise ValueError("Unable to resolve a parent branch for the analysis campaign.")
1529
+
1530
+ idea_id = self._latest_branch_idea_id(quest_root, parent_branch) or str(state.get("active_idea_id") or "").strip() or None
1531
+ return parent_branch, parent_worktree_root, idea_id
1532
+
1076
1533
  def _idea_parent_branch(self, record: dict[str, Any] | None) -> str | None:
1077
1534
  if not isinstance(record, dict) or not record:
1078
1535
  return None
@@ -1357,6 +1814,111 @@ class ArtifactService:
1357
1814
  "branches": branches,
1358
1815
  }
1359
1816
 
1817
+ def resolve_runtime_refs(self, quest_root: Path) -> dict[str, Any]:
1818
+ state = self.quest_service.read_research_state(quest_root)
1819
+ snapshot = self.quest_service.snapshot(self._quest_id(quest_root))
1820
+ active_campaign_id = str(state.get("active_analysis_campaign_id") or "").strip() or None
1821
+ analysis_parent_branch = str(state.get("analysis_parent_branch") or "").strip() or None
1822
+ current_workspace_branch = str(state.get("current_workspace_branch") or "").strip() or None
1823
+ research_head_branch = str(state.get("research_head_branch") or "").strip() or None
1824
+ canonical_branch = analysis_parent_branch or current_workspace_branch or research_head_branch
1825
+ latest_main_run = self._latest_main_run_for_branch(quest_root, canonical_branch or "")
1826
+ selected_outline = read_json(self._paper_selected_outline_path(quest_root), {})
1827
+ selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
1828
+ active_campaign = (
1829
+ self._read_analysis_manifest(quest_root, active_campaign_id)
1830
+ if active_campaign_id
1831
+ else {}
1832
+ )
1833
+ active_campaign = active_campaign if isinstance(active_campaign, dict) else {}
1834
+ latest_paths = (
1835
+ dict(latest_main_run.get("paths") or {})
1836
+ if isinstance(latest_main_run, dict) and isinstance(latest_main_run.get("paths"), dict)
1837
+ else {}
1838
+ )
1839
+ return {
1840
+ "ok": True,
1841
+ "active_idea_id": str(state.get("active_idea_id") or "").strip() or None,
1842
+ "research_head_branch": research_head_branch,
1843
+ "research_head_worktree_root": str(state.get("research_head_worktree_root") or "").strip() or None,
1844
+ "current_workspace_branch": current_workspace_branch,
1845
+ "current_workspace_root": str(state.get("current_workspace_root") or "").strip() or None,
1846
+ "analysis_parent_branch": analysis_parent_branch,
1847
+ "analysis_parent_worktree_root": str(state.get("analysis_parent_worktree_root") or "").strip() or None,
1848
+ "current_canonical_branch": canonical_branch,
1849
+ "active_analysis_campaign_id": active_campaign_id,
1850
+ "active_campaign_title": str(active_campaign.get("title") or "").strip() or None,
1851
+ "next_pending_slice_id": str(state.get("next_pending_slice_id") or "").strip() or None,
1852
+ "latest_main_run_id": str((latest_main_run or {}).get("run_id") or "").strip() or None,
1853
+ "latest_main_run_branch": str((latest_main_run or {}).get("branch") or "").strip() or None,
1854
+ "latest_main_result_json": str(latest_paths.get("result_json") or "").strip() or None,
1855
+ "selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
1856
+ "default_reply_interaction_id": str(snapshot.get("default_reply_interaction_id") or "").strip() or None,
1857
+ }
1858
+
1859
+ def get_analysis_campaign(self, quest_root: Path, campaign_id: str | None = None) -> dict[str, Any]:
1860
+ resolved_campaign_id = str(campaign_id or "").strip()
1861
+ if not resolved_campaign_id or resolved_campaign_id == "active":
1862
+ state = self.quest_service.read_research_state(quest_root)
1863
+ resolved_campaign_id = str(state.get("active_analysis_campaign_id") or "").strip()
1864
+ if not resolved_campaign_id:
1865
+ raise ValueError("No active analysis campaign is available.")
1866
+ manifest = self._read_analysis_manifest(quest_root, resolved_campaign_id)
1867
+ slices = [dict(item) for item in (manifest.get("slices") or []) if isinstance(item, dict)]
1868
+ pending_slices = [item for item in slices if str(item.get("status") or "pending").strip() == "pending"]
1869
+ completed_slices = [item for item in slices if str(item.get("status") or "").strip() != "pending"]
1870
+ next_pending_slice = pending_slices[0] if pending_slices else None
1871
+ return {
1872
+ "ok": True,
1873
+ "campaign_id": resolved_campaign_id,
1874
+ "title": str(manifest.get("title") or "").strip() or None,
1875
+ "goal": str(manifest.get("goal") or "").strip() or None,
1876
+ "active_idea_id": str(manifest.get("active_idea_id") or "").strip() or None,
1877
+ "parent_run_id": str(manifest.get("parent_run_id") or "").strip() or None,
1878
+ "parent_branch": str(manifest.get("parent_branch") or "").strip() or None,
1879
+ "parent_worktree_root": str(manifest.get("parent_worktree_root") or "").strip() or None,
1880
+ "selected_outline_ref": str(manifest.get("selected_outline_ref") or "").strip() or None,
1881
+ "campaign_origin": dict(manifest.get("campaign_origin") or {}) if isinstance(manifest.get("campaign_origin"), dict) else None,
1882
+ "todo_items": [dict(item) for item in (manifest.get("todo_items") or []) if isinstance(item, dict)],
1883
+ "slices": slices,
1884
+ "next_pending_slice_id": str((next_pending_slice or {}).get("slice_id") or "").strip() or None,
1885
+ "pending_slice_count": len(pending_slices),
1886
+ "completed_slice_count": len(completed_slices),
1887
+ "manifest": manifest,
1888
+ }
1889
+
1890
+ def list_paper_outlines(self, quest_root: Path) -> dict[str, Any]:
1891
+ selected_outline = read_json(self._paper_selected_outline_path(quest_root), {})
1892
+ selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
1893
+ outlines: list[dict[str, Any]] = []
1894
+ for status, root in (
1895
+ ("candidate", self._paper_outline_candidates_root(quest_root)),
1896
+ ("revised", self._paper_outline_revisions_root(quest_root)),
1897
+ ):
1898
+ for path in sorted(root.glob("outline-*.json")):
1899
+ record = read_json(path, {})
1900
+ if not isinstance(record, dict) or not record:
1901
+ continue
1902
+ outline_id = str(record.get("outline_id") or path.stem).strip() or path.stem
1903
+ outlines.append(
1904
+ {
1905
+ "outline_id": outline_id,
1906
+ "title": str(record.get("title") or outline_id).strip() or outline_id,
1907
+ "status": str(record.get("status") or status).strip() or status,
1908
+ "review_result": str(record.get("review_result") or "").strip() or None,
1909
+ "path": str(path),
1910
+ "is_selected": outline_id == str(selected_outline.get("outline_id") or "").strip(),
1911
+ }
1912
+ )
1913
+ outlines.sort(key=lambda item: (str(item.get("outline_id") or ""), str(item.get("status") or "")))
1914
+ return {
1915
+ "ok": True,
1916
+ "selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
1917
+ "selected_outline": selected_outline or None,
1918
+ "count": len(outlines),
1919
+ "outlines": outlines,
1920
+ }
1921
+
1360
1922
  def _previous_primary_best(
1361
1923
  self,
1362
1924
  quest_root: Path,
@@ -2149,6 +2711,7 @@ class ArtifactService:
2149
2711
  status: str = "completed",
2150
2712
  baseline_id: str | None = None,
2151
2713
  baseline_variant_id: str | None = None,
2714
+ evaluation_summary: dict[str, Any] | None = None,
2152
2715
  ) -> dict[str, Any]:
2153
2716
  self._require_baseline_gate_open(quest_root, action="record_main_experiment")
2154
2717
  state = self.quest_service.read_research_state(quest_root)
@@ -2227,6 +2790,7 @@ class ArtifactService:
2227
2790
  resolved_config_paths = [str(item).strip() for item in (config_paths or []) if str(item).strip()]
2228
2791
  resolved_notes = [str(item).strip() for item in (notes or []) if str(item).strip()]
2229
2792
  normalized_dataset_scope = str(dataset_scope or "full").strip().lower() or "full"
2793
+ normalized_evaluation_summary = self._normalize_evaluation_summary(evaluation_summary)
2230
2794
  primary = comparisons.get("primary") if isinstance(comparisons, dict) else {}
2231
2795
  primary_metric_id = str(progress_eval.get("primary_metric_id") or comparisons.get("primary_metric_id") or "").strip() or None
2232
2796
  primary_value = primary.get("run_value") if isinstance(primary, dict) else None
@@ -2336,6 +2900,8 @@ class ArtifactService:
2336
2900
  if resolved_notes:
2337
2901
  run_lines.extend(["", "## Notes", ""])
2338
2902
  run_lines.extend([f"- {item}" for item in resolved_notes])
2903
+ run_lines.extend(["", "## Evaluation Summary", ""])
2904
+ run_lines.extend(self._evaluation_summary_markdown_lines(normalized_evaluation_summary))
2339
2905
  run_lines.extend(
2340
2906
  [
2341
2907
  "",
@@ -2384,6 +2950,7 @@ class ArtifactService:
2384
2950
  key: value for key, value in comparisons.items() if key != "primary"
2385
2951
  },
2386
2952
  "progress_eval": progress_eval,
2953
+ "evaluation_summary": normalized_evaluation_summary,
2387
2954
  "delivery_policy": delivery_policy,
2388
2955
  "startup_contract": delivery_policy.get("startup_contract") or None,
2389
2956
  "evidence_paths": resolved_evidence_paths,
@@ -2424,6 +2991,7 @@ class ArtifactService:
2424
2991
  "recommended_next_route": delivery_policy.get("recommended_next_route"),
2425
2992
  "changed_file_count": len(resolved_changed_files),
2426
2993
  "evidence_count": len(resolved_evidence_paths),
2994
+ "evaluation_summary": normalized_evaluation_summary,
2427
2995
  },
2428
2996
  "delivery_policy": delivery_policy,
2429
2997
  "startup_contract": delivery_policy.get("startup_contract") or None,
@@ -2439,6 +3007,7 @@ class ArtifactService:
2439
3007
  key: value for key, value in comparisons.items() if key != "primary"
2440
3008
  },
2441
3009
  "progress_eval": progress_eval,
3010
+ "evaluation_summary": normalized_evaluation_summary,
2442
3011
  "files_changed": resolved_changed_files,
2443
3012
  "evidence_paths": resolved_evidence_paths,
2444
3013
  "verdict": verdict,
@@ -2475,6 +3044,7 @@ class ArtifactService:
2475
3044
  "breakthrough_level": progress_eval.get("breakthrough_level"),
2476
3045
  "need_research_paper": delivery_policy.get("need_research_paper"),
2477
3046
  "recommended_next_route": delivery_policy.get("recommended_next_route"),
3047
+ "evaluation_summary": normalized_evaluation_summary,
2478
3048
  }
2479
3049
  ],
2480
3050
  )
@@ -2497,6 +3067,7 @@ class ArtifactService:
2497
3067
  key: value for key, value in comparisons.items() if key != "primary"
2498
3068
  },
2499
3069
  "progress_eval": progress_eval,
3070
+ "evaluation_summary": normalized_evaluation_summary,
2500
3071
  "delivery_policy": delivery_policy,
2501
3072
  }
2502
3073
 
@@ -2508,6 +3079,7 @@ class ArtifactService:
2508
3079
  campaign_goal: str,
2509
3080
  parent_run_id: str | None = None,
2510
3081
  slices: list[dict[str, Any]],
3082
+ campaign_origin: dict[str, Any] | None = None,
2511
3083
  selected_outline_ref: str | None = None,
2512
3084
  research_questions: list[str] | None = None,
2513
3085
  experimental_designs: list[str] | None = None,
@@ -2515,21 +3087,25 @@ class ArtifactService:
2515
3087
  ) -> dict[str, Any]:
2516
3088
  self._require_baseline_gate_open(quest_root, action="create_analysis_campaign")
2517
3089
  state = self.quest_service.read_research_state(quest_root)
2518
- active_idea_id = str(state.get("active_idea_id") or "").strip()
3090
+ parent_branch, parent_worktree_root, resolved_idea_id = self._resolve_analysis_parent_context(
3091
+ quest_root,
3092
+ state=state,
3093
+ )
3094
+ active_idea_id = str(resolved_idea_id or "").strip()
2519
3095
  if not active_idea_id:
2520
3096
  raise ValueError("An active idea is required before starting an analysis campaign.")
2521
3097
  if not slices:
2522
3098
  raise ValueError("At least one analysis slice is required.")
2523
- parent_branch = str(state.get("research_head_branch") or current_branch(self._workspace_root_for(quest_root))).strip()
2524
- parent_worktree_root = Path(str(state.get("research_head_worktree_root") or self._workspace_root_for(quest_root)))
2525
3099
  campaign_id = generate_id("analysis")
2526
3100
  charter_dir = ensure_dir(parent_worktree_root / "experiments" / "analysis-results" / campaign_id)
2527
3101
  charter_path = charter_dir / "campaign.md"
3102
+ normalized_campaign_origin = self._normalize_campaign_origin(campaign_origin)
2528
3103
  resolved_outline_ref = str(selected_outline_ref or "").strip() or None
2529
3104
  normalized_research_questions = self._normalize_string_list(research_questions)
2530
3105
  normalized_experimental_designs = self._normalize_string_list(experimental_designs)
2531
- normalized_todo_items = [dict(item) for item in (todo_items or []) if isinstance(item, dict)]
3106
+ normalized_todo_items = self._normalize_campaign_todo_items(todo_items)
2532
3107
  slice_contexts: list[dict[str, Any]] = []
3108
+ inventory_entries: list[dict[str, Any]] = []
2533
3109
  for index, raw in enumerate(slices, start=1):
2534
3110
  slice_id = str(raw.get("slice_id") or generate_id("slice")).strip()
2535
3111
  title = str(raw.get("title") or slice_id).strip() or slice_id
@@ -2550,6 +3126,21 @@ class ArtifactService:
2550
3126
  worktree_root=worktree_root,
2551
3127
  start_point=parent_branch,
2552
3128
  )
3129
+ reviewer_item_ids = self._normalize_string_list(
3130
+ raw.get("reviewer_item_ids") or matched_todo.get("reviewer_item_ids")
3131
+ )
3132
+ manuscript_targets = self._normalize_string_list(
3133
+ raw.get("manuscript_targets") or matched_todo.get("manuscript_targets")
3134
+ )
3135
+ why_now = str(raw.get("why_now") or matched_todo.get("why_now") or "").strip()
3136
+ success_criteria = str(raw.get("success_criteria") or matched_todo.get("success_criteria") or "").strip()
3137
+ abandonment_criteria = str(
3138
+ raw.get("abandonment_criteria") or matched_todo.get("abandonment_criteria") or ""
3139
+ ).strip()
3140
+ required_baselines = self._normalize_required_baselines(
3141
+ quest_root,
3142
+ raw.get("required_baselines") or matched_todo.get("required_baselines"),
3143
+ )
2553
3144
  plan_dir = ensure_dir(worktree_root / "experiments" / "analysis" / campaign_id / slice_id)
2554
3145
  plan_path = plan_dir / "plan.md"
2555
3146
  requirement_lines = [
@@ -2567,6 +3158,10 @@ class ArtifactService:
2567
3158
  "",
2568
3159
  str(raw.get("experimental_design") or matched_todo.get("experimental_design") or "").strip() or "TBD",
2569
3160
  "",
3161
+ "## Why Now",
3162
+ "",
3163
+ why_now or "TBD",
3164
+ "",
2570
3165
  "## Hypothesis",
2571
3166
  "",
2572
3167
  str(raw.get("hypothesis") or "").strip() or "TBD",
@@ -2575,25 +3170,55 @@ class ArtifactService:
2575
3170
  "",
2576
3171
  str(raw.get("required_changes") or "").strip() or "TBD",
2577
3172
  "",
2578
- "## Metric Contract",
2579
- "",
2580
- str(raw.get("metric_contract") or "").strip() or "TBD",
2581
- "",
2582
- "## Environment Notes",
2583
- "",
2584
- str(raw.get("environment_notes") or "").strip() or "TBD",
2585
- "",
2586
- "## Must Not Simplify",
2587
- "",
2588
- str(raw.get("must_not_simplify") or "").strip() or "Full dataset / full protocol only unless explicitly approved.",
2589
- "",
2590
- "## Completion Condition",
2591
- "",
2592
- str(raw.get("completion_condition") or matched_todo.get("completion_condition") or "").strip()
2593
- or str(raw.get("must_not_simplify") or matched_todo.get("must_not_simplify") or "").strip()
2594
- or "Complete the planned analysis slice and mirror the durable result back to the parent branch.",
3173
+ "## Required Baselines",
2595
3174
  "",
2596
3175
  ]
3176
+ if required_baselines:
3177
+ requirement_lines.extend([f"- {self._analysis_baseline_label(item)}" for item in required_baselines])
3178
+ else:
3179
+ requirement_lines.append("- None recorded.")
3180
+ requirement_lines.extend(
3181
+ [
3182
+ "",
3183
+ "## Metric Contract",
3184
+ "",
3185
+ str(raw.get("metric_contract") or "").strip() or "TBD",
3186
+ "",
3187
+ "## Environment Notes",
3188
+ "",
3189
+ str(raw.get("environment_notes") or "").strip() or "TBD",
3190
+ "",
3191
+ "## Must Not Simplify",
3192
+ "",
3193
+ str(raw.get("must_not_simplify") or "").strip() or "Full dataset / full protocol only unless explicitly approved.",
3194
+ "",
3195
+ "## Success Criteria",
3196
+ "",
3197
+ success_criteria or "TBD",
3198
+ "",
3199
+ "## Abandonment Criteria",
3200
+ "",
3201
+ abandonment_criteria or "TBD",
3202
+ "",
3203
+ "## Completion Condition",
3204
+ "",
3205
+ str(raw.get("completion_condition") or matched_todo.get("completion_condition") or "").strip()
3206
+ or str(raw.get("must_not_simplify") or matched_todo.get("must_not_simplify") or "").strip()
3207
+ or "Complete the planned analysis slice and mirror the durable result back to the parent branch.",
3208
+ "",
3209
+ ]
3210
+ )
3211
+ requirement_lines.extend(["## Reviewer Item IDs", ""])
3212
+ if reviewer_item_ids:
3213
+ requirement_lines.extend([f"- `{item}`" for item in reviewer_item_ids])
3214
+ else:
3215
+ requirement_lines.append("- None recorded.")
3216
+ requirement_lines.extend(["", "## Manuscript Targets", ""])
3217
+ if manuscript_targets:
3218
+ requirement_lines.extend([f"- {item}" for item in manuscript_targets])
3219
+ else:
3220
+ requirement_lines.append("- None recorded.")
3221
+ requirement_lines.append("")
2597
3222
  write_text(plan_path, "\n".join(requirement_lines))
2598
3223
  slice_contexts.append(
2599
3224
  {
@@ -2612,20 +3237,48 @@ class ArtifactService:
2612
3237
  "experimental_design": str(
2613
3238
  raw.get("experimental_design") or matched_todo.get("experimental_design") or ""
2614
3239
  ).strip(),
3240
+ "why_now": why_now,
2615
3241
  "hypothesis": str(raw.get("hypothesis") or "").strip(),
2616
3242
  "required_changes": str(raw.get("required_changes") or "").strip(),
2617
3243
  "metric_contract": str(raw.get("metric_contract") or "").strip(),
2618
3244
  "environment_notes": str(raw.get("environment_notes") or "").strip(),
2619
3245
  "must_not_simplify": str(raw.get("must_not_simplify") or "").strip(),
3246
+ "success_criteria": success_criteria,
3247
+ "abandonment_criteria": abandonment_criteria,
2620
3248
  "completion_condition": str(
2621
3249
  raw.get("completion_condition") or matched_todo.get("completion_condition") or ""
2622
3250
  ).strip(),
3251
+ "required_baselines": required_baselines,
3252
+ "reviewer_item_ids": reviewer_item_ids,
3253
+ "manuscript_targets": manuscript_targets,
2623
3254
  }
3255
+ )
3256
+ inventory_entries.extend(
3257
+ [
3258
+ {
3259
+ "baseline_id": item.get("baseline_id"),
3260
+ "variant_id": item.get("variant_id"),
3261
+ "usage_scope": "supplementary",
3262
+ "status": "required",
3263
+ "reason": item.get("reason"),
3264
+ "benchmark": item.get("benchmark"),
3265
+ "split": item.get("split"),
3266
+ "baseline_root_rel_path": item.get("baseline_root_rel_path"),
3267
+ "storage_mode": item.get("storage_mode"),
3268
+ "origin": {
3269
+ "stage": "analysis_campaign",
3270
+ "campaign_id": campaign_id,
3271
+ "slice_id": slice_id,
3272
+ },
3273
+ }
3274
+ for item in required_baselines
3275
+ ]
2624
3276
  )
2625
3277
 
2626
3278
  todo_manifest = {
2627
3279
  "schema_version": 1,
2628
3280
  "campaign_id": campaign_id,
3281
+ "campaign_origin": normalized_campaign_origin,
2629
3282
  "selected_outline_ref": resolved_outline_ref,
2630
3283
  "research_questions": normalized_research_questions,
2631
3284
  "experimental_designs": normalized_experimental_designs,
@@ -2635,9 +3288,15 @@ class ArtifactService:
2635
3288
  "slice_id": context["slice_id"],
2636
3289
  "title": str(item.get("title") or context["title"]).strip() or context["title"],
2637
3290
  "status": str(item.get("status") or "pending").strip() or "pending",
2638
- "research_question": context.get("research_question") or item.get("research_question"),
2639
- "experimental_design": context.get("experimental_design") or item.get("experimental_design"),
2640
- "completion_condition": context.get("completion_condition") or item.get("completion_condition") or context.get("must_not_simplify"),
3291
+ "research_question": item.get("research_question") or context.get("research_question"),
3292
+ "experimental_design": item.get("experimental_design") or context.get("experimental_design"),
3293
+ "completion_condition": item.get("completion_condition") or context.get("completion_condition") or context.get("must_not_simplify"),
3294
+ "why_now": item.get("why_now") or context.get("why_now"),
3295
+ "success_criteria": item.get("success_criteria") or context.get("success_criteria"),
3296
+ "abandonment_criteria": item.get("abandonment_criteria") or context.get("abandonment_criteria"),
3297
+ "required_baselines": item.get("required_baselines") or context.get("required_baselines") or [],
3298
+ "reviewer_item_ids": item.get("reviewer_item_ids") or context.get("reviewer_item_ids") or [],
3299
+ "manuscript_targets": item.get("manuscript_targets") or context.get("manuscript_targets") or [],
2641
3300
  }
2642
3301
  for context, item in zip(slice_contexts, normalized_todo_items + [{}] * max(0, len(slice_contexts) - len(normalized_todo_items)))
2643
3302
  ],
@@ -2666,6 +3325,14 @@ class ArtifactService:
2666
3325
  "",
2667
3326
  f"`{resolved_outline_ref or 'none'}`",
2668
3327
  "",
3328
+ "## Campaign Origin",
3329
+ "",
3330
+ f"- Kind: `{(normalized_campaign_origin or {}).get('kind') or 'analysis'}`",
3331
+ f"- Reason: {str((normalized_campaign_origin or {}).get('reason') or 'Not recorded')}",
3332
+ f"- Source Artifact: `{str((normalized_campaign_origin or {}).get('source_artifact_id') or 'none')}`",
3333
+ f"- Source Outline: `{str((normalized_campaign_origin or {}).get('source_outline_ref') or 'none')}`",
3334
+ f"- Source Review Round: `{str((normalized_campaign_origin or {}).get('source_review_round') or 'none')}`",
3335
+ "",
2669
3336
  "## Slices",
2670
3337
  "",
2671
3338
  ]
@@ -2681,8 +3348,14 @@ class ArtifactService:
2681
3348
  f"- Goal: {item['goal'] or 'TBD'}",
2682
3349
  f"- Research question: {item['research_question'] or 'TBD'}",
2683
3350
  f"- Experimental design: {item['experimental_design'] or 'TBD'}",
3351
+ f"- Why now: {item['why_now'] or 'TBD'}",
3352
+ f"- Required baselines: {', '.join(self._analysis_baseline_label(entry) for entry in item['required_baselines']) or 'none'}",
3353
+ f"- Success criteria: {item['success_criteria'] or 'TBD'}",
3354
+ f"- Abandonment criteria: {item['abandonment_criteria'] or 'TBD'}",
2684
3355
  f"- Completion condition: {item['completion_condition'] or item['must_not_simplify'] or 'TBD'}",
2685
3356
  f"- Requirement: {item['must_not_simplify'] or 'TBD'}",
3357
+ f"- Reviewer items: {', '.join(item['reviewer_item_ids']) or 'none'}",
3358
+ f"- Manuscript targets: {', '.join(item['manuscript_targets']) or 'none'}",
2686
3359
  "",
2687
3360
  ]
2688
3361
  )
@@ -2697,6 +3370,7 @@ class ArtifactService:
2697
3370
  "active_idea_id": active_idea_id,
2698
3371
  "parent_branch": parent_branch,
2699
3372
  "parent_worktree_root": str(parent_worktree_root),
3373
+ "campaign_origin": normalized_campaign_origin,
2700
3374
  "selected_outline_ref": resolved_outline_ref,
2701
3375
  "research_questions": normalized_research_questions,
2702
3376
  "experimental_designs": normalized_experimental_designs,
@@ -2707,6 +3381,45 @@ class ArtifactService:
2707
3381
  "created_at": utc_now(),
2708
3382
  },
2709
3383
  )
3384
+ for item in slice_contexts:
3385
+ self.record(
3386
+ quest_root,
3387
+ {
3388
+ "kind": "milestone",
3389
+ "status": "prepared",
3390
+ "summary": f"Analysis slice `{item['slice_id']}` prepared as a child branch.",
3391
+ "reason": "Expose the pending follow-up branch durably so Canvas and Git lineage stay visible before execution.",
3392
+ "idea_id": active_idea_id,
3393
+ "campaign_id": campaign_id,
3394
+ "slice_id": item["slice_id"],
3395
+ "branch": item["branch"],
3396
+ "parent_branch": parent_branch,
3397
+ "worktree_root": item["worktree_root"],
3398
+ "worktree_rel_path": self._workspace_relative(quest_root, Path(item["worktree_root"])),
3399
+ "flow_type": "analysis_slice",
3400
+ "protocol_step": "prepare",
3401
+ "paths": {
3402
+ "plan_md": item["plan_path"],
3403
+ },
3404
+ "details": {
3405
+ "title": item["title"],
3406
+ "goal": item["goal"],
3407
+ "run_kind": item["run_kind"],
3408
+ "research_question": item["research_question"],
3409
+ "experimental_design": item["experimental_design"],
3410
+ "why_now": item["why_now"],
3411
+ "completion_condition": item["completion_condition"] or item["must_not_simplify"],
3412
+ "must_not_simplify": item["must_not_simplify"],
3413
+ "required_baselines": item["required_baselines"],
3414
+ "success_criteria": item["success_criteria"],
3415
+ "abandonment_criteria": item["abandonment_criteria"],
3416
+ "reviewer_item_ids": item["reviewer_item_ids"],
3417
+ "manuscript_targets": item["manuscript_targets"],
3418
+ },
3419
+ },
3420
+ checkpoint=False,
3421
+ workspace_root=Path(item["worktree_root"]),
3422
+ )
2710
3423
  first_slice = slice_contexts[0]
2711
3424
  artifact = self.record(
2712
3425
  quest_root,
@@ -2729,6 +3442,7 @@ class ArtifactService:
2729
3442
  "campaign_title": campaign_title,
2730
3443
  "campaign_goal": campaign_goal,
2731
3444
  "parent_run_id": parent_run_id,
3445
+ "campaign_origin": normalized_campaign_origin,
2732
3446
  "selected_outline_ref": resolved_outline_ref,
2733
3447
  "todo_manifest_path": str(todo_manifest_path),
2734
3448
  "slice_count": len(slice_contexts),
@@ -2742,8 +3456,14 @@ class ArtifactService:
2742
3456
  "goal": item["goal"],
2743
3457
  "research_question": item["research_question"],
2744
3458
  "experimental_design": item["experimental_design"],
3459
+ "why_now": item["why_now"],
2745
3460
  "completion_condition": item["completion_condition"] or item["must_not_simplify"],
2746
3461
  "must_not_simplify": item["must_not_simplify"],
3462
+ "required_baselines": item["required_baselines"],
3463
+ "success_criteria": item["success_criteria"],
3464
+ "abandonment_criteria": item["abandonment_criteria"],
3465
+ "reviewer_item_ids": item["reviewer_item_ids"],
3466
+ "manuscript_targets": item["manuscript_targets"],
2747
3467
  }
2748
3468
  for item in slice_contexts
2749
3469
  ],
@@ -2754,6 +3474,7 @@ class ArtifactService:
2754
3474
  )
2755
3475
  research_state = self.quest_service.update_research_state(
2756
3476
  quest_root,
3477
+ active_idea_id=active_idea_id,
2757
3478
  active_analysis_campaign_id=campaign_id,
2758
3479
  analysis_parent_branch=parent_branch,
2759
3480
  analysis_parent_worktree_root=str(parent_worktree_root),
@@ -2763,6 +3484,7 @@ class ArtifactService:
2763
3484
  workspace_mode="analysis",
2764
3485
  last_flow_type="analysis_campaign",
2765
3486
  )
3487
+ baseline_inventory = self._upsert_analysis_baseline_inventory(quest_root, inventory_entries) if inventory_entries else None
2766
3488
  self.quest_service.update_settings(self._quest_id(quest_root), active_anchor="analysis-campaign")
2767
3489
  checkpoint_result = self._checkpoint_with_optional_push(
2768
3490
  parent_worktree_root,
@@ -2785,16 +3507,17 @@ class ArtifactService:
2785
3507
  attachments=[
2786
3508
  {
2787
3509
  "kind": "analysis_campaign",
2788
- "campaign_id": campaign_id,
2789
- "parent_branch": parent_branch,
2790
- "parent_worktree_root": str(parent_worktree_root),
2791
- "selected_outline_ref": resolved_outline_ref,
2792
- "todo_manifest_path": str(todo_manifest_path),
2793
- "next_slice": first_slice,
2794
- "todo_items": todo_manifest["todo_items"],
2795
- "slices": slice_contexts,
2796
- }
2797
- ],
3510
+ "campaign_id": campaign_id,
3511
+ "parent_branch": parent_branch,
3512
+ "parent_worktree_root": str(parent_worktree_root),
3513
+ "campaign_origin": normalized_campaign_origin,
3514
+ "selected_outline_ref": resolved_outline_ref,
3515
+ "todo_manifest_path": str(todo_manifest_path),
3516
+ "next_slice": first_slice,
3517
+ "todo_items": todo_manifest["todo_items"],
3518
+ "slices": slice_contexts,
3519
+ }
3520
+ ],
2798
3521
  )
2799
3522
  return {
2800
3523
  "ok": True,
@@ -2807,9 +3530,11 @@ class ArtifactService:
2807
3530
  "campaign_id": campaign_id,
2808
3531
  "parent_branch": parent_branch,
2809
3532
  "parent_worktree_root": str(parent_worktree_root),
3533
+ "campaign_origin": normalized_campaign_origin,
2810
3534
  "charter_path": str(charter_path),
2811
3535
  "slices": slice_contexts,
2812
3536
  "manifest": manifest,
3537
+ "analysis_baseline_inventory": baseline_inventory,
2813
3538
  "todo_manifest_path": str(todo_manifest_path),
2814
3539
  "artifact": artifact,
2815
3540
  "checkpoint": checkpoint_result,
@@ -2992,6 +3717,18 @@ class ArtifactService:
2992
3717
  raise ValueError("submit_paper_bundle requires a selected outline or explicit `outline_path`.")
2993
3718
 
2994
3719
  manifest_path = self._paper_bundle_manifest_path(quest_root)
3720
+ baseline_inventory = self._write_paper_baseline_inventory(quest_root)
3721
+ baseline_inventory_path = self._paper_baseline_inventory_path(quest_root)
3722
+ source_branch = (
3723
+ str(self.quest_service.read_research_state(quest_root).get("current_workspace_branch") or "").strip()
3724
+ or current_branch(self._workspace_root_for(quest_root))
3725
+ )
3726
+ open_source_manifest = self._ensure_open_source_prep(
3727
+ quest_root,
3728
+ source_branch=source_branch,
3729
+ source_bundle_manifest_path="paper/paper_bundle_manifest.json",
3730
+ baseline_inventory_path="paper/baseline_inventory.json",
3731
+ )
2995
3732
  manifest = {
2996
3733
  "schema_version": 1,
2997
3734
  "title": str(
@@ -3010,6 +3747,10 @@ class ArtifactService:
3010
3747
  "compile_report_path": str(compile_report_path or "paper/build/compile_report.json").strip() or None,
3011
3748
  "pdf_path": str(pdf_path or "").strip() or None,
3012
3749
  "latex_root_path": str(latex_root_path or "").strip() or None,
3750
+ "baseline_inventory_path": "paper/baseline_inventory.json",
3751
+ "open_source_manifest_path": "release/open_source/manifest.json",
3752
+ "open_source_cleanup_plan_path": str(open_source_manifest.get("cleanup_plan_path") or "").strip()
3753
+ or "release/open_source/cleanup_plan.md",
3013
3754
  "selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
3014
3755
  "created_at": utc_now(),
3015
3756
  "updated_at": utc_now(),
@@ -3031,10 +3772,14 @@ class ArtifactService:
3031
3772
  "outline_path": manifest.get("outline_path"),
3032
3773
  "draft_path": manifest.get("draft_path"),
3033
3774
  "pdf_path": manifest.get("pdf_path"),
3775
+ "baseline_inventory_path": str(baseline_inventory_path),
3776
+ "open_source_manifest_path": str(self._open_source_manifest_path(quest_root)),
3034
3777
  },
3035
3778
  "details": {
3036
3779
  "title": manifest.get("title"),
3037
3780
  "selected_outline_ref": manifest.get("selected_outline_ref"),
3781
+ "baseline_inventory_count": len(baseline_inventory.get("supplementary_baselines") or []),
3782
+ "open_source_status": open_source_manifest.get("status"),
3038
3783
  },
3039
3784
  },
3040
3785
  checkpoint=False,
@@ -3044,6 +3789,8 @@ class ArtifactService:
3044
3789
  "ok": True,
3045
3790
  "manifest_path": str(manifest_path),
3046
3791
  "manifest": manifest,
3792
+ "baseline_inventory_path": str(baseline_inventory_path),
3793
+ "open_source_manifest_path": str(self._open_source_manifest_path(quest_root)),
3047
3794
  "artifact": artifact,
3048
3795
  }
3049
3796
 
@@ -3060,8 +3807,14 @@ class ArtifactService:
3060
3807
  evidence_paths: list[str] | None = None,
3061
3808
  metric_rows: list[dict[str, Any]] | None = None,
3062
3809
  deviations: list[str] | None = None,
3810
+ claim_impact: str | None = None,
3811
+ reviewer_resolution: str | None = None,
3812
+ manuscript_update_hint: str | None = None,
3813
+ next_recommendation: str | None = None,
3063
3814
  dataset_scope: str = "full",
3064
3815
  subset_approval_ref: str | None = None,
3816
+ comparison_baselines: list[dict[str, Any]] | None = None,
3817
+ evaluation_summary: dict[str, Any] | None = None,
3065
3818
  ) -> dict[str, Any]:
3066
3819
  state = self.quest_service.read_research_state(quest_root)
3067
3820
  manifest = self._read_analysis_manifest(quest_root, campaign_id)
@@ -3076,12 +3829,19 @@ class ArtifactService:
3076
3829
  evidence_paths = [str(item).strip() for item in (evidence_paths or []) if str(item).strip()]
3077
3830
  deviations = [str(item).strip() for item in (deviations or []) if str(item).strip()]
3078
3831
  metric_rows = [item for item in (metric_rows or []) if isinstance(item, dict)]
3832
+ normalized_comparison_baselines = self._normalize_comparison_baselines(quest_root, comparison_baselines)
3833
+ normalized_claim_impact = str(claim_impact or "").strip() or None
3834
+ normalized_reviewer_resolution = str(reviewer_resolution or "").strip() or None
3835
+ normalized_manuscript_update_hint = str(manuscript_update_hint or "").strip() or None
3836
+ normalized_next_recommendation = str(next_recommendation or "").strip() or None
3837
+ normalized_evaluation_summary = self._normalize_evaluation_summary(evaluation_summary)
3079
3838
  slice_worktree_root = Path(str(target.get("worktree_root") or ""))
3080
3839
  parent_worktree_root = Path(str(manifest.get("parent_worktree_root") or ""))
3081
3840
  parent_branch = str(manifest.get("parent_branch") or "")
3082
3841
 
3083
3842
  result_dir = ensure_dir(slice_worktree_root / "experiments" / "analysis" / campaign_id / slice_id)
3084
3843
  result_path = result_dir / "RESULT.md"
3844
+ result_json_path = result_dir / "RESULT.json"
3085
3845
  result_lines = [
3086
3846
  f"# {target.get('title') or slice_id}",
3087
3847
  "",
@@ -3104,6 +3864,26 @@ class ArtifactService:
3104
3864
  "",
3105
3865
  results.strip() or "TBD",
3106
3866
  "",
3867
+ "## Claim Impact",
3868
+ "",
3869
+ normalized_claim_impact or "Not recorded.",
3870
+ "",
3871
+ "## Reviewer Resolution",
3872
+ "",
3873
+ normalized_reviewer_resolution or "Not recorded.",
3874
+ "",
3875
+ "## Manuscript Update Hint",
3876
+ "",
3877
+ normalized_manuscript_update_hint or "Not recorded.",
3878
+ "",
3879
+ "## Next Recommendation",
3880
+ "",
3881
+ normalized_next_recommendation or "Not recorded.",
3882
+ "",
3883
+ "## Evaluation Summary",
3884
+ "",
3885
+ *self._evaluation_summary_markdown_lines(normalized_evaluation_summary),
3886
+ "",
3107
3887
  "## Deviations",
3108
3888
  "",
3109
3889
  ]
@@ -3120,6 +3900,20 @@ class ArtifactService:
3120
3900
  result_lines.extend(["", "## Metric Rows", ""])
3121
3901
  for row in metric_rows:
3122
3902
  result_lines.append(f"- `{row}`")
3903
+ result_lines.extend(["", "## Comparison Baselines", ""])
3904
+ if normalized_comparison_baselines:
3905
+ for entry in normalized_comparison_baselines:
3906
+ result_lines.append(f"- {self._analysis_baseline_label(entry)}")
3907
+ if entry.get("baseline_root_rel_path"):
3908
+ result_lines.append(f" - Root: `{entry['baseline_root_rel_path']}`")
3909
+ if entry.get("metrics_summary"):
3910
+ result_lines.append(f" - Metrics: `{entry['metrics_summary']}`")
3911
+ if entry.get("published"):
3912
+ result_lines.append(
3913
+ f" - Published: `{entry.get('published_entry_id') or entry.get('baseline_id')}`"
3914
+ )
3915
+ else:
3916
+ result_lines.append("- None recorded.")
3123
3917
  if subset_approval_ref:
3124
3918
  result_lines.extend(["", "## Subset Approval", "", f"`{subset_approval_ref}`"])
3125
3919
  write_text(result_path, "\n".join(result_lines).rstrip() + "\n")
@@ -3134,6 +3928,37 @@ class ArtifactService:
3134
3928
  if len(keys) == 1:
3135
3929
  metrics_summary[keys[0]] = row.get(keys[0])
3136
3930
 
3931
+ result_payload = {
3932
+ "schema_version": 1,
3933
+ "result_kind": "analysis_slice",
3934
+ "campaign_id": campaign_id,
3935
+ "slice_id": slice_id,
3936
+ "status": status,
3937
+ "title": target.get("title"),
3938
+ "goal": target.get("goal"),
3939
+ "run_kind": target.get("run_kind"),
3940
+ "required_baselines": target.get("required_baselines") or [],
3941
+ "comparison_baselines": normalized_comparison_baselines,
3942
+ "metrics_summary": metrics_summary,
3943
+ "metric_rows": metric_rows,
3944
+ "dataset_scope": normalized_scope,
3945
+ "subset_approval_ref": subset_approval_ref,
3946
+ "setup": setup.strip() or None,
3947
+ "execution": execution.strip() or None,
3948
+ "results": results.strip() or None,
3949
+ "claim_impact": normalized_claim_impact,
3950
+ "reviewer_resolution": normalized_reviewer_resolution,
3951
+ "manuscript_update_hint": normalized_manuscript_update_hint,
3952
+ "next_recommendation": normalized_next_recommendation,
3953
+ "evaluation_summary": normalized_evaluation_summary,
3954
+ "deviations": deviations,
3955
+ "evidence_paths": evidence_paths,
3956
+ "source_branch": str(target.get("branch") or ""),
3957
+ "source_worktree_root": str(slice_worktree_root),
3958
+ "updated_at": utc_now(),
3959
+ }
3960
+ write_json(result_json_path, result_payload)
3961
+
3137
3962
  mirror_dir = ensure_dir(parent_worktree_root / "experiments" / "analysis-results" / campaign_id)
3138
3963
  mirror_path = mirror_dir / f"{slice_id}.md"
3139
3964
  mirror_lines = [
@@ -3164,7 +3989,25 @@ class ArtifactService:
3164
3989
  "",
3165
3990
  results.strip() or "TBD",
3166
3991
  "",
3992
+ "## Claim Impact",
3993
+ "",
3994
+ normalized_claim_impact or "Not recorded.",
3995
+ "",
3996
+ "## Manuscript Update Hint",
3997
+ "",
3998
+ normalized_manuscript_update_hint or "Not recorded.",
3999
+ "",
4000
+ "## Evaluation Summary",
4001
+ "",
4002
+ *self._evaluation_summary_markdown_lines(normalized_evaluation_summary),
4003
+ "",
3167
4004
  ]
4005
+ mirror_lines.extend(["## Comparison Baselines", ""])
4006
+ if normalized_comparison_baselines:
4007
+ mirror_lines.extend([f"- {self._analysis_baseline_label(entry)}" for entry in normalized_comparison_baselines])
4008
+ else:
4009
+ mirror_lines.append("- None recorded.")
4010
+ mirror_lines.append("")
3168
4011
  write_text(mirror_path, "\n".join(mirror_lines).rstrip() + "\n")
3169
4012
 
3170
4013
  artifact = self.record(
@@ -3188,6 +4031,7 @@ class ArtifactService:
3188
4031
  "protocol_step": "record",
3189
4032
  "paths": {
3190
4033
  "slice_result_md": str(result_path),
4034
+ "slice_result_json": str(result_json_path),
3191
4035
  "parent_result_md": str(mirror_path),
3192
4036
  },
3193
4037
  "details": {
@@ -3197,9 +4041,17 @@ class ArtifactService:
3197
4041
  "dataset_scope": normalized_scope,
3198
4042
  "subset_approval_ref": subset_approval_ref,
3199
4043
  "metric_rows": metric_rows,
4044
+ "claim_impact": normalized_claim_impact,
4045
+ "reviewer_resolution": normalized_reviewer_resolution,
4046
+ "manuscript_update_hint": normalized_manuscript_update_hint,
4047
+ "next_recommendation": normalized_next_recommendation,
3200
4048
  "deviations": deviations,
3201
4049
  "evidence_paths": evidence_paths,
4050
+ "required_baselines": target.get("required_baselines") or [],
4051
+ "comparison_baselines": normalized_comparison_baselines,
4052
+ "evaluation_summary": normalized_evaluation_summary,
3202
4053
  },
4054
+ "evaluation_summary": normalized_evaluation_summary,
3203
4055
  },
3204
4056
  checkpoint=False,
3205
4057
  workspace_root=slice_worktree_root,
@@ -3222,7 +4074,14 @@ class ArtifactService:
3222
4074
  updated["status"] = status
3223
4075
  updated["completed_at"] = utc_now()
3224
4076
  updated["result_path"] = str(result_path)
4077
+ updated["result_json_path"] = str(result_json_path)
3225
4078
  updated["mirror_path"] = str(mirror_path)
4079
+ updated["claim_impact"] = normalized_claim_impact
4080
+ updated["reviewer_resolution"] = normalized_reviewer_resolution
4081
+ updated["manuscript_update_hint"] = normalized_manuscript_update_hint
4082
+ updated["next_recommendation"] = normalized_next_recommendation
4083
+ updated["comparison_baselines"] = normalized_comparison_baselines
4084
+ updated["evaluation_summary"] = normalized_evaluation_summary
3226
4085
  updated_slices.append(updated)
3227
4086
  next_slice = next((item for item in updated_slices if str(item.get("status") or "") == "pending"), None)
3228
4087
  manifest = self._write_analysis_manifest(
@@ -3233,6 +4092,36 @@ class ArtifactService:
3233
4092
  "slices": updated_slices,
3234
4093
  },
3235
4094
  )
4095
+ baseline_inventory = (
4096
+ self._upsert_analysis_baseline_inventory(
4097
+ quest_root,
4098
+ [
4099
+ {
4100
+ "baseline_id": entry.get("baseline_id"),
4101
+ "variant_id": entry.get("variant_id"),
4102
+ "usage_scope": "supplementary",
4103
+ "status": "registered",
4104
+ "reason": entry.get("reason"),
4105
+ "benchmark": entry.get("benchmark"),
4106
+ "split": entry.get("split"),
4107
+ "baseline_root_rel_path": entry.get("baseline_root_rel_path"),
4108
+ "storage_mode": entry.get("storage_mode"),
4109
+ "metrics_summary": entry.get("metrics_summary"),
4110
+ "evidence_paths": entry.get("evidence_paths"),
4111
+ "published": entry.get("published"),
4112
+ "published_entry_id": entry.get("published_entry_id"),
4113
+ "origin": {
4114
+ "stage": "analysis_campaign",
4115
+ "campaign_id": campaign_id,
4116
+ "slice_id": slice_id,
4117
+ },
4118
+ }
4119
+ for entry in normalized_comparison_baselines
4120
+ ],
4121
+ )
4122
+ if normalized_comparison_baselines
4123
+ else self._read_analysis_baseline_inventory(quest_root)
4124
+ )
3236
4125
 
3237
4126
  if next_slice is not None:
3238
4127
  research_state = self.quest_service.update_research_state(
@@ -3274,14 +4163,17 @@ class ArtifactService:
3274
4163
  "slice_id": slice_id,
3275
4164
  "status": status,
3276
4165
  "result_path": str(result_path),
4166
+ "result_json_path": str(result_json_path),
3277
4167
  "mirror_path": str(mirror_path),
3278
4168
  "artifact": artifact,
3279
4169
  "slice_checkpoint": slice_checkpoint,
3280
4170
  "parent_checkpoint": parent_checkpoint,
3281
4171
  "next_slice": next_slice,
3282
4172
  "manifest": manifest,
4173
+ "analysis_baseline_inventory": baseline_inventory,
3283
4174
  "interaction": interaction,
3284
4175
  "research_state": research_state,
4176
+ "evaluation_summary": normalized_evaluation_summary,
3285
4177
  "completed": False,
3286
4178
  }
3287
4179
 
@@ -3336,12 +4228,14 @@ class ArtifactService:
3336
4228
  parent_worktree_root,
3337
4229
  message=f"analysis: summarize {campaign_id}",
3338
4230
  )
4231
+ restored_idea_id = self._latest_branch_idea_id(quest_root, parent_branch) or str(manifest.get("active_idea_id") or "").strip() or None
3339
4232
  research_state = self.quest_service.update_research_state(
3340
4233
  quest_root,
4234
+ active_idea_id=restored_idea_id,
3341
4235
  active_analysis_campaign_id=None,
3342
4236
  next_pending_slice_id=None,
3343
- current_workspace_branch=state.get("research_head_branch") or parent_branch,
3344
- current_workspace_root=state.get("research_head_worktree_root") or str(parent_worktree_root),
4237
+ current_workspace_branch=parent_branch,
4238
+ current_workspace_root=str(parent_worktree_root),
3345
4239
  workspace_mode="idea",
3346
4240
  last_flow_type="analysis_campaign_complete",
3347
4241
  )
@@ -3374,6 +4268,7 @@ class ArtifactService:
3374
4268
  "slice_id": slice_id,
3375
4269
  "status": status,
3376
4270
  "result_path": str(result_path),
4271
+ "result_json_path": str(result_json_path),
3377
4272
  "mirror_path": str(mirror_path),
3378
4273
  "artifact": artifact,
3379
4274
  "slice_checkpoint": slice_checkpoint,
@@ -3382,8 +4277,10 @@ class ArtifactService:
3382
4277
  "summary_checkpoint": parent_summary_checkpoint,
3383
4278
  "summary_path": str(summary_path),
3384
4279
  "manifest": manifest,
4280
+ "analysis_baseline_inventory": baseline_inventory,
3385
4281
  "interaction": interaction,
3386
4282
  "research_state": research_state,
4283
+ "evaluation_summary": normalized_evaluation_summary,
3387
4284
  "completed": True,
3388
4285
  "returned_to_branch": parent_branch,
3389
4286
  "returned_to_worktree_root": str(parent_worktree_root),
@@ -3789,6 +4686,8 @@ class ArtifactService:
3789
4686
  expects_reply: bool | None = None,
3790
4687
  reply_mode: str | None = None,
3791
4688
  options: list[dict[str, Any]] | None = None,
4689
+ surface_actions: list[dict[str, Any]] | None = None,
4690
+ connector_hints: dict[str, Any] | None = None,
3792
4691
  allow_free_text: bool = True,
3793
4692
  reply_schema: dict[str, Any] | None = None,
3794
4693
  reply_to_interaction_id: str | None = None,
@@ -3801,6 +4700,9 @@ class ArtifactService:
3801
4700
  "approval_result": "approval",
3802
4701
  }.get(kind, "progress")
3803
4702
  options_resolved = options or []
4703
+ surface_actions_resolved = [dict(item) for item in (surface_actions or []) if isinstance(item, dict)]
4704
+ connector_hints_resolved = self._normalize_connector_hints(connector_hints)
4705
+ attachments_resolved, attachment_issues = self._normalize_interaction_attachments(quest_root, attachments)
3804
4706
  reply_schema_resolved = reply_schema if isinstance(reply_schema, dict) else {}
3805
4707
  reply_mode_resolved = str(
3806
4708
  reply_mode
@@ -3860,10 +4762,14 @@ class ArtifactService:
3860
4762
  "expects_reply": False,
3861
4763
  "reply_mode": "none",
3862
4764
  "delivered": False,
4765
+ "delivery_results": [],
3863
4766
  "response_phase": response_phase,
3864
4767
  "delivery_targets": [],
3865
4768
  "delivery_policy": self._delivery_policy(self._connectors_config()),
3866
4769
  "preferred_connector": self._preferred_connector(self._connectors_config()),
4770
+ "connector_hints": connector_hints_resolved,
4771
+ "normalized_attachments": attachments_resolved,
4772
+ "attachment_issues": attachment_issues,
3867
4773
  "recent_inbound_messages": mailbox_payload.get("recent_inbound_messages") or [],
3868
4774
  "delivery_batch": mailbox_payload.get("delivery_batch"),
3869
4775
  "recent_interaction_records": mailbox_payload.get("recent_interaction_records") or [],
@@ -3889,11 +4795,13 @@ class ArtifactService:
3889
4795
  "summary": message,
3890
4796
  "interaction_phase": "request" if kind == "decision_request" else response_phase,
3891
4797
  "importance": importance,
3892
- "attachments": attachments or [],
4798
+ "attachments": attachments_resolved,
3893
4799
  "interaction_id": resolved_interaction_id,
3894
4800
  "expects_reply": expects_reply_resolved,
3895
4801
  "reply_mode": reply_mode_resolved,
3896
4802
  "options": options_resolved,
4803
+ "surface_actions": surface_actions_resolved,
4804
+ "connector_hints": connector_hints_resolved,
3897
4805
  "allow_free_text": allow_free_text,
3898
4806
  "reply_schema": reply_schema_resolved,
3899
4807
  "reply_to_interaction_id": reply_to_interaction_id,
@@ -3929,6 +4837,7 @@ class ArtifactService:
3929
4837
  )
3930
4838
  delivery_targets: list[str] = []
3931
4839
  delivered = False
4840
+ delivery_results: list[dict[str, Any]] = []
3932
4841
  if deliver_to_bound_conversations:
3933
4842
  connectors = self._connectors_config()
3934
4843
  targets = self._select_delivery_targets(
@@ -3950,12 +4859,17 @@ class ArtifactService:
3950
4859
  "expects_reply": expects_reply_resolved,
3951
4860
  "reply_mode": reply_mode_resolved,
3952
4861
  "options": options_resolved,
4862
+ "surface_actions": surface_actions_resolved,
4863
+ "connector_hints": connector_hints_resolved,
3953
4864
  "allow_free_text": allow_free_text,
3954
4865
  "reply_schema": reply_schema_resolved,
3955
4866
  "reply_to_interaction_id": reply_to_interaction_id,
3956
- "attachments": attachments or [],
4867
+ "attachments": attachments_resolved,
3957
4868
  }
3958
- if self._send_to_channel(channel_name, payload, connectors=connectors):
4869
+ delivery_result = self._deliver_to_channel(channel_name, payload, connectors=connectors)
4870
+ delivery_result["conversation_id"] = target
4871
+ delivery_results.append(delivery_result)
4872
+ if delivery_result.get("ok", False) or delivery_result.get("queued", False):
3959
4873
  delivery_targets.append(target)
3960
4874
  delivered = True
3961
4875
 
@@ -3985,6 +4899,8 @@ class ArtifactService:
3985
4899
  message=message,
3986
4900
  response_phase=response_phase,
3987
4901
  reply_mode=reply_mode_resolved,
4902
+ surface_actions=surface_actions_resolved,
4903
+ connector_hints=connector_hints_resolved,
3988
4904
  created_at=(artifact.get("record") or {}).get("updated_at"),
3989
4905
  )
3990
4906
 
@@ -3994,7 +4910,12 @@ class ArtifactService:
3994
4910
  "interaction_id": request_state.get("interaction_id"),
3995
4911
  "expects_reply": expects_reply_resolved,
3996
4912
  "reply_mode": reply_mode_resolved,
4913
+ "surface_actions": surface_actions_resolved,
4914
+ "connector_hints": connector_hints_resolved,
4915
+ "normalized_attachments": attachments_resolved,
4916
+ "attachment_issues": attachment_issues,
3997
4917
  "delivered": delivered,
4918
+ "delivery_results": delivery_results,
3998
4919
  "response_phase": response_phase,
3999
4920
  "delivery_targets": delivery_targets,
4000
4921
  "delivery_policy": self._delivery_policy(self._connectors_config()),
@@ -4296,6 +5217,67 @@ class ArtifactService:
4296
5217
  return enabled[0]
4297
5218
  return None
4298
5219
 
5220
+ @staticmethod
5221
+ def _normalize_connector_hints(connector_hints: dict[str, Any] | None) -> dict[str, Any]:
5222
+ if not isinstance(connector_hints, dict):
5223
+ return {}
5224
+ normalized: dict[str, Any] = {}
5225
+ for key, value in connector_hints.items():
5226
+ name = str(key or "").strip().lower()
5227
+ if not name or not isinstance(value, dict):
5228
+ continue
5229
+ normalized[name] = dict(value)
5230
+ return normalized
5231
+
5232
+ def _normalize_interaction_attachments(
5233
+ self,
5234
+ quest_root: Path,
5235
+ attachments: list[dict[str, Any]] | None,
5236
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
5237
+ normalized: list[dict[str, Any]] = []
5238
+ issues: list[dict[str, Any]] = []
5239
+ for index, raw_item in enumerate(attachments or [], start=1):
5240
+ if not isinstance(raw_item, dict):
5241
+ issues.append(
5242
+ {
5243
+ "attachment_index": index,
5244
+ "error": "attachment must be an object",
5245
+ }
5246
+ )
5247
+ continue
5248
+ item = dict(raw_item)
5249
+ path_value = str(item.get("path") or "").strip()
5250
+ if path_value:
5251
+ resolved_path = Path(path_value).expanduser()
5252
+ if not resolved_path.is_absolute():
5253
+ resolved_path = (quest_root / resolved_path).resolve()
5254
+ else:
5255
+ resolved_path = resolved_path.resolve()
5256
+ item["path"] = str(resolved_path)
5257
+ if not resolved_path.exists():
5258
+ item["path_error"] = "path_not_found"
5259
+ issues.append(
5260
+ {
5261
+ "attachment_index": index,
5262
+ "path": str(resolved_path),
5263
+ "error": "attachment path does not exist",
5264
+ }
5265
+ )
5266
+ connector_delivery = item.get("connector_delivery")
5267
+ if isinstance(connector_delivery, dict):
5268
+ normalized_delivery: dict[str, Any] = {}
5269
+ for key, value in connector_delivery.items():
5270
+ name = str(key or "").strip().lower()
5271
+ if not name or not isinstance(value, dict):
5272
+ continue
5273
+ normalized_delivery[name] = dict(value)
5274
+ if normalized_delivery:
5275
+ item["connector_delivery"] = normalized_delivery
5276
+ else:
5277
+ item.pop("connector_delivery", None)
5278
+ normalized.append(item)
5279
+ return normalized, issues
5280
+
4299
5281
  def _select_delivery_targets(self, targets: list[str], *, connectors: dict[str, Any]) -> list[str]:
4300
5282
  if not targets:
4301
5283
  return ["local:default"]
@@ -4445,6 +5427,11 @@ class ArtifactService:
4445
5427
  "transport": transport,
4446
5428
  "response_phase": str(payload.get("response_phase") or "").strip() or None,
4447
5429
  "importance": str(payload.get("importance") or "").strip() or None,
5430
+ "artifact_id": str(payload.get("artifact_id") or "").strip() or None,
5431
+ "interaction_id": str(payload.get("interaction_id") or "").strip() or None,
5432
+ "surface_actions": payload.get("surface_actions") if isinstance(payload.get("surface_actions"), list) else [],
5433
+ "connector_hints": payload.get("connector_hints") if isinstance(payload.get("connector_hints"), dict) else {},
5434
+ "delivery_parts": delivery.get("parts") if isinstance(delivery.get("parts"), list) else [],
4448
5435
  "error": str(result.get("error") or delivery.get("error") or "").strip() or None,
4449
5436
  "created_at": utc_now(),
4450
5437
  },