@researai/deepscientist 1.5.7 → 1.5.8

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 (148) hide show
  1. package/README.md +4 -0
  2. package/bin/ds.js +220 -5
  3. package/docs/en/07_MEMORY_AND_MCP.md +40 -3
  4. package/docs/en/99_ACKNOWLEDGEMENTS.md +1 -0
  5. package/docs/zh/07_MEMORY_AND_MCP.md +40 -3
  6. package/docs/zh/99_ACKNOWLEDGEMENTS.md +1 -0
  7. package/install.sh +34 -0
  8. package/package.json +1 -1
  9. package/pyproject.toml +1 -1
  10. package/src/deepscientist/__init__.py +1 -1
  11. package/src/deepscientist/acp/envelope.py +1 -0
  12. package/src/deepscientist/artifact/metrics.py +813 -80
  13. package/src/deepscientist/artifact/schemas.py +1 -0
  14. package/src/deepscientist/artifact/service.py +1101 -99
  15. package/src/deepscientist/bash_exec/monitor.py +1 -1
  16. package/src/deepscientist/bash_exec/service.py +17 -9
  17. package/src/deepscientist/channels/qq.py +17 -0
  18. package/src/deepscientist/channels/relay.py +16 -0
  19. package/src/deepscientist/config/models.py +6 -0
  20. package/src/deepscientist/config/service.py +70 -2
  21. package/src/deepscientist/daemon/api/handlers.py +284 -14
  22. package/src/deepscientist/daemon/api/router.py +1 -0
  23. package/src/deepscientist/daemon/app.py +291 -20
  24. package/src/deepscientist/gitops/diff.py +6 -10
  25. package/src/deepscientist/mcp/server.py +188 -39
  26. package/src/deepscientist/prompts/builder.py +51 -18
  27. package/src/deepscientist/quest/service.py +83 -34
  28. package/src/deepscientist/quest/stage_views.py +74 -29
  29. package/src/deepscientist/runners/codex.py +1 -1
  30. package/src/prompts/connectors/qq.md +1 -1
  31. package/src/prompts/contracts/shared_interaction.md +14 -0
  32. package/src/prompts/system.md +106 -32
  33. package/src/skills/analysis-campaign/SKILL.md +10 -14
  34. package/src/skills/baseline/SKILL.md +51 -38
  35. package/src/skills/baseline/references/baseline-plan-template.md +2 -0
  36. package/src/skills/decision/SKILL.md +12 -8
  37. package/src/skills/experiment/SKILL.md +28 -16
  38. package/src/skills/experiment/references/main-experiment-plan-template.md +2 -0
  39. package/src/skills/figure-polish/SKILL.md +1 -0
  40. package/src/skills/finalize/SKILL.md +3 -8
  41. package/src/skills/idea/SKILL.md +2 -8
  42. package/src/skills/intake-audit/SKILL.md +2 -8
  43. package/src/skills/rebuttal/SKILL.md +2 -8
  44. package/src/skills/review/SKILL.md +2 -8
  45. package/src/skills/scout/SKILL.md +2 -8
  46. package/src/skills/write/SKILL.md +52 -16
  47. package/src/skills/write/templates/DEEPSCIENTIST_NOTES.md +21 -0
  48. package/src/skills/write/templates/README.md +408 -0
  49. package/src/skills/write/templates/UPSTREAM_LICENSE.txt +21 -0
  50. package/src/skills/write/templates/aaai2026/README.md +534 -0
  51. package/src/skills/write/templates/aaai2026/aaai2026-unified-supp.tex +144 -0
  52. package/src/skills/write/templates/aaai2026/aaai2026-unified-template.tex +952 -0
  53. package/src/skills/write/templates/aaai2026/aaai2026.bib +111 -0
  54. package/src/skills/write/templates/aaai2026/aaai2026.bst +1493 -0
  55. package/src/skills/write/templates/aaai2026/aaai2026.sty +315 -0
  56. package/src/skills/write/templates/acl/README.md +50 -0
  57. package/src/skills/write/templates/acl/acl.sty +312 -0
  58. package/src/skills/write/templates/acl/acl_latex.tex +377 -0
  59. package/src/skills/write/templates/acl/acl_lualatex.tex +101 -0
  60. package/src/skills/write/templates/acl/acl_natbib.bst +1940 -0
  61. package/src/skills/write/templates/acl/anthology.bib.txt +26 -0
  62. package/src/skills/write/templates/acl/custom.bib +70 -0
  63. package/src/skills/write/templates/acl/formatting.md +326 -0
  64. package/src/skills/write/templates/asplos2027/main.tex +459 -0
  65. package/src/skills/write/templates/asplos2027/references.bib +135 -0
  66. package/src/skills/write/templates/colm2025/README.md +3 -0
  67. package/src/skills/write/templates/colm2025/colm2025_conference.bib +11 -0
  68. package/src/skills/write/templates/colm2025/colm2025_conference.bst +1440 -0
  69. package/src/skills/write/templates/colm2025/colm2025_conference.sty +218 -0
  70. package/src/skills/write/templates/colm2025/colm2025_conference.tex +305 -0
  71. package/src/skills/write/templates/colm2025/fancyhdr.sty +485 -0
  72. package/src/skills/write/templates/colm2025/math_commands.tex +508 -0
  73. package/src/skills/write/templates/colm2025/natbib.sty +1246 -0
  74. package/src/skills/write/templates/iclr2026/fancyhdr.sty +485 -0
  75. package/src/skills/write/templates/iclr2026/iclr2026_conference.bib +24 -0
  76. package/src/skills/write/templates/iclr2026/iclr2026_conference.bst +1440 -0
  77. package/src/skills/write/templates/iclr2026/iclr2026_conference.sty +246 -0
  78. package/src/skills/write/templates/iclr2026/iclr2026_conference.tex +414 -0
  79. package/src/skills/write/templates/iclr2026/math_commands.tex +508 -0
  80. package/src/skills/write/templates/iclr2026/natbib.sty +1246 -0
  81. package/src/skills/write/templates/icml2026/algorithm.sty +79 -0
  82. package/src/skills/write/templates/icml2026/algorithmic.sty +201 -0
  83. package/src/skills/write/templates/icml2026/example_paper.bib +75 -0
  84. package/src/skills/write/templates/icml2026/example_paper.tex +662 -0
  85. package/src/skills/write/templates/icml2026/fancyhdr.sty +864 -0
  86. package/src/skills/write/templates/icml2026/icml2026.bst +1443 -0
  87. package/src/skills/write/templates/icml2026/icml2026.sty +767 -0
  88. package/src/skills/write/templates/neurips2025/Makefile +36 -0
  89. package/src/skills/write/templates/neurips2025/extra_pkgs.tex +53 -0
  90. package/src/skills/write/templates/neurips2025/main.tex +38 -0
  91. package/src/skills/write/templates/neurips2025/neurips.sty +382 -0
  92. package/src/skills/write/templates/nsdi2027/main.tex +426 -0
  93. package/src/skills/write/templates/nsdi2027/references.bib +151 -0
  94. package/src/skills/write/templates/nsdi2027/usenix-2020-09.sty +83 -0
  95. package/src/skills/write/templates/osdi2026/main.tex +429 -0
  96. package/src/skills/write/templates/osdi2026/references.bib +150 -0
  97. package/src/skills/write/templates/osdi2026/usenix-2020-09.sty +83 -0
  98. package/src/skills/write/templates/sosp2026/main.tex +532 -0
  99. package/src/skills/write/templates/sosp2026/references.bib +148 -0
  100. package/src/tui/package.json +1 -1
  101. package/src/ui/dist/assets/{AiManusChatView-BS3V4ZOk.js → AiManusChatView-m2FNtwbn.js} +110 -14
  102. package/src/ui/dist/assets/{AnalysisPlugin-DLPXQsmr.js → AnalysisPlugin-BMTF8EGL.js} +1 -1
  103. package/src/ui/dist/assets/{AutoFigurePlugin-C-Fr9knQ.js → AutoFigurePlugin-DxPdMUNb.js} +5 -5
  104. package/src/ui/dist/assets/{CliPlugin-Dd8AHzFg.js → CliPlugin-BEOWgxCI.js} +9 -9
  105. package/src/ui/dist/assets/{CodeEditorPlugin-Dg-RepTl.js → CodeEditorPlugin-BCXvjqmb.js} +8 -8
  106. package/src/ui/dist/assets/{CodeViewerPlugin-D2J_3nyt.js → CodeViewerPlugin-DaJcy3nD.js} +5 -5
  107. package/src/ui/dist/assets/{DocViewerPlugin-ChRLLKNb.js → DocViewerPlugin-ByfeIq4K.js} +3 -3
  108. package/src/ui/dist/assets/{GitDiffViewerPlugin-DgHfcved.js → GitDiffViewerPlugin-Cksf3VZ-.js} +830 -86
  109. package/src/ui/dist/assets/{ImageViewerPlugin-C89GZMBy.js → ImageViewerPlugin-CFz-OsTS.js} +5 -5
  110. package/src/ui/dist/assets/{LabCopilotPanel-BUfIwUcb.js → LabCopilotPanel-CJ1cJzoX.js} +10 -10
  111. package/src/ui/dist/assets/{LabPlugin-zvUmQUMq.js → LabPlugin-BF3dVJwa.js} +1 -1
  112. package/src/ui/dist/assets/{LatexPlugin-C1SSNuWp.js → LatexPlugin-DDkwZ6Sj.js} +7 -7
  113. package/src/ui/dist/assets/{MarkdownViewerPlugin-D2Mf5tU5.js → MarkdownViewerPlugin-HAuvurcT.js} +4 -4
  114. package/src/ui/dist/assets/{MarketplacePlugin-CF4LgiS2.js → MarketplacePlugin-BtoTYy2C.js} +3 -3
  115. package/src/ui/dist/assets/{index-Be0NAmh8.js → NotebookEditor-CSJYx7b-.js} +12 -155
  116. package/src/ui/dist/assets/{NotebookEditor-BM7Bgwlv.js → NotebookEditor-DQgRezm_.js} +1 -1
  117. package/src/ui/dist/assets/{PdfLoader-Bc5qfD-Z.js → PdfLoader-DPa_-fv6.js} +1 -1
  118. package/src/ui/dist/assets/{PdfMarkdownPlugin-sh1-IRcp.js → PdfMarkdownPlugin-BZpXOEjm.js} +3 -3
  119. package/src/ui/dist/assets/{PdfViewerPlugin-C_a7CpWG.js → PdfViewerPlugin-BT8a6wGR.js} +10 -10
  120. package/src/ui/dist/assets/{SearchPlugin-L4z3HcLf.js → SearchPlugin-D_blveZi.js} +1 -1
  121. package/src/ui/dist/assets/{Stepper-Dk4aQ3fN.js → Stepper-DH2k75Vo.js} +1 -1
  122. package/src/ui/dist/assets/{TextViewerPlugin-BsNtlKVo.js → TextViewerPlugin-Btx0M3hX.js} +4 -4
  123. package/src/ui/dist/assets/{VNCViewer-BpeDcZ5_.js → VNCViewer-DImJO4rO.js} +9 -9
  124. package/src/ui/dist/assets/{bibtex-C4QI-bbj.js → bibtex-B-Hqu0Sg.js} +1 -1
  125. package/src/ui/dist/assets/{code-DuMINRsg.js → code-BUfXGJSl.js} +1 -1
  126. package/src/ui/dist/assets/{file-content-C3N-432K.js → file-content-VqamwI3X.js} +1 -1
  127. package/src/ui/dist/assets/{file-diff-panel-CffQ4ZMg.js → file-diff-panel-C_wOoS7a.js} +1 -1
  128. package/src/ui/dist/assets/{file-socket-CRH59PCO.js → file-socket-D2bTuMVP.js} +1 -1
  129. package/src/ui/dist/assets/{file-utils-vYGtW2mI.js → file-utils--zJCPN1i.js} +1 -1
  130. package/src/ui/dist/assets/{image-DBVGaooo.js → image-BZkGJ4mM.js} +1 -1
  131. package/src/ui/dist/assets/{index-DjSFDmgB.js → index-CxkvSeKw.js} +2 -2
  132. package/src/ui/dist/assets/{index-BpjYH9Vg.js → index-D9QIGcmc.js} +1 -1
  133. package/src/ui/dist/assets/{index-Do9N28uB.css → index-DXZ1daiJ.css} +163 -34
  134. package/src/ui/dist/assets/index-DdRW6RMJ.js +159 -0
  135. package/src/ui/dist/assets/{index-B1P6hQRJ.js → index-DjggJovS.js} +3029 -1780
  136. package/src/ui/dist/assets/{message-square-BsPDBhiY.js → message-square-FUIPIhU2.js} +1 -1
  137. package/src/ui/dist/assets/{monaco-BTkdPojV.js → monaco-DHMc7kKM.js} +1 -1
  138. package/src/ui/dist/assets/{popover-cWjCk-vc.js → popover-B85oCgCS.js} +1 -1
  139. package/src/ui/dist/assets/{project-sync-CXn530xb.js → project-sync-DOMCcPac.js} +1 -1
  140. package/src/ui/dist/assets/{sigma-04Jr12jg.js → sigma-BO2rQrl3.js} +1 -1
  141. package/src/ui/dist/assets/{tooltip-BdVDl0G5.js → tooltip-B1OspAkx.js} +1 -1
  142. package/src/ui/dist/assets/{trash-CB_GlQyC.js → trash-BsVEH_dV.js} +1 -1
  143. package/src/ui/dist/assets/{useCliAccess-BL932NwS.js → useCliAccess-b8L6JuZm.js} +1 -1
  144. package/src/ui/dist/assets/{useFileDiffOverlay-B2WK7Tvq.js → useFileDiffOverlay-BY7uA9hV.js} +1 -1
  145. package/src/ui/dist/assets/{wrap-text-YC68g12z.js → wrap-text-BwyVuUIK.js} +1 -1
  146. package/src/ui/dist/assets/{zoom-out-C0RJvFiJ.js → zoom-out-RDpLugQP.js} +1 -1
  147. package/src/ui/dist/index.html +5 -2
  148. /package/src/ui/dist/assets/{index-CccQYZjX.css → NotebookEditor-CccQYZjX.css} +0 -0
@@ -10,6 +10,7 @@ from ..channels import get_channel_factory, register_builtin_channels
10
10
  from ..config import ConfigManager
11
11
  from ..connector_runtime import conversation_identity_key, infer_connector_transport, normalize_conversation_id
12
12
  from ..gitops import (
13
+ branch_exists,
13
14
  canonical_worktree_root,
14
15
  checkpoint_repo,
15
16
  create_worktree,
@@ -42,13 +43,17 @@ from .guidance import build_guidance_for_record, guidance_summary
42
43
  from .metrics import (
43
44
  baseline_metric_lines,
44
45
  build_metrics_timeline,
46
+ canonicalize_baseline_submission,
45
47
  compare_with_baseline,
46
48
  compute_progress_eval,
49
+ MetricContractValidationError,
47
50
  normalize_metric_contract,
48
51
  normalize_metric_rows,
49
52
  normalize_metrics_summary,
50
53
  selected_baseline_metrics,
51
54
  to_number,
55
+ validate_baseline_metric_contract_submission,
56
+ validate_main_experiment_against_baseline_contract,
52
57
  )
53
58
  from .schemas import ARTIFACT_DIRS, guidance_for_kind, validate_artifact_payload
54
59
 
@@ -126,6 +131,19 @@ class ArtifactService:
126
131
  lines = [f"- {label}: {normalized[key]}" for key, label in labels if normalized.get(key)]
127
132
  return lines or ["- Not recorded."]
128
133
 
134
+ def _load_metric_contract_payload(self, quest_root: Path, metric_contract_json_rel_path: str | None) -> dict[str, Any] | None:
135
+ rel_path = str(metric_contract_json_rel_path or "").strip()
136
+ if not rel_path:
137
+ return None
138
+ try:
139
+ resolved_path = resolve_within(quest_root, rel_path)
140
+ except ValueError:
141
+ return None
142
+ if not resolved_path.exists():
143
+ return None
144
+ payload = read_json(resolved_path, {})
145
+ return payload if isinstance(payload, dict) and payload else None
146
+
129
147
  def _workspace_root_for(self, quest_root: Path, workspace_root: Path | None = None) -> Path:
130
148
  if workspace_root is not None:
131
149
  return workspace_root
@@ -139,6 +157,73 @@ class ArtifactService:
139
157
  except ValueError:
140
158
  return str(path)
141
159
 
160
+ @staticmethod
161
+ def _branch_kind_from_name(branch_name: str | None) -> str:
162
+ normalized = str(branch_name or "").strip()
163
+ if normalized in {"main", "master"} or normalized.startswith("quest/"):
164
+ return "quest"
165
+ if normalized.startswith("idea/"):
166
+ return "idea"
167
+ if normalized.startswith("analysis/"):
168
+ return "analysis"
169
+ if normalized.startswith("paper/"):
170
+ return "paper"
171
+ if normalized.startswith("run/"):
172
+ return "run"
173
+ return "branch"
174
+
175
+ def _workspace_mode_for_branch(self, branch_name: str | None, *, has_idea: bool = False) -> str:
176
+ branch_kind = self._branch_kind_from_name(branch_name)
177
+ if branch_kind == "paper":
178
+ return "paper"
179
+ if branch_kind == "analysis":
180
+ return "analysis"
181
+ if branch_kind == "run":
182
+ return "run"
183
+ if branch_kind == "idea" or has_idea:
184
+ return "idea"
185
+ return "quest"
186
+
187
+ def _prepare_branch_worktree_root(
188
+ self,
189
+ quest_root: Path,
190
+ *,
191
+ branch_name: str,
192
+ branch_kind: str,
193
+ run_id: str | None = None,
194
+ idea_id: str | None = None,
195
+ ) -> Path:
196
+ normalized_kind = str(branch_kind or "").strip().lower() or "run"
197
+ normalized_run_id = str(run_id or "").strip() or None
198
+ normalized_idea_id = str(idea_id or "").strip() or None
199
+ if normalized_kind == "idea" and normalized_idea_id:
200
+ return canonical_worktree_root(quest_root, f"idea-{normalized_idea_id}")
201
+ if normalized_kind == "paper":
202
+ return canonical_worktree_root(
203
+ quest_root,
204
+ f"paper-{normalized_run_id or slugify(branch_name, 'paper')}",
205
+ )
206
+ if normalized_kind == "run" and normalized_run_id:
207
+ return canonical_worktree_root(quest_root, normalized_run_id)
208
+ return canonical_worktree_root(quest_root, slugify(branch_name, "branch"))
209
+
210
+ def _latest_prepare_branch_record(self, quest_root: Path, branch_name: str) -> dict[str, Any]:
211
+ normalized_branch = str(branch_name or "").strip()
212
+ if not normalized_branch:
213
+ return {}
214
+ for item in reversed(self.quest_service._collect_artifacts(quest_root)):
215
+ payload = dict(item.get("payload") or {}) if isinstance(item.get("payload"), dict) else {}
216
+ if not payload:
217
+ continue
218
+ if str(payload.get("kind") or "").strip() != "decision":
219
+ continue
220
+ if str(payload.get("action") or "").strip() != "prepare_branch":
221
+ continue
222
+ if str(payload.get("branch") or "").strip() != normalized_branch:
223
+ continue
224
+ return payload
225
+ return {}
226
+
142
227
  def _git_config(self) -> dict[str, Any]:
143
228
  config = ConfigManager(self.home).load_named("config")
144
229
  payload = config.get("git") if isinstance(config.get("git"), dict) else {}
@@ -623,43 +708,91 @@ class ArtifactService:
623
708
  )
624
709
  return normalized
625
710
 
626
- def _paper_root(self, quest_root: Path) -> Path:
627
- return ensure_dir(quest_root / "paper")
711
+ def _paper_root(
712
+ self,
713
+ quest_root: Path,
714
+ *,
715
+ workspace_root: Path | None = None,
716
+ prefer_workspace: bool = True,
717
+ create: bool = False,
718
+ ) -> Path:
719
+ roots: list[Path] = []
720
+ if prefer_workspace:
721
+ roots.append(self._workspace_root_for(quest_root, workspace_root))
722
+ roots.append(quest_root)
723
+ seen: set[str] = set()
724
+ first_candidate: Path | None = None
725
+ for root in roots:
726
+ key = str(root.resolve())
727
+ if key in seen:
728
+ continue
729
+ seen.add(key)
730
+ candidate = root / "paper"
731
+ if first_candidate is None:
732
+ first_candidate = candidate
733
+ if candidate.exists():
734
+ return candidate
735
+ fallback = first_candidate or (quest_root / "paper")
736
+ return ensure_dir(fallback) if create else fallback
628
737
 
629
- def _paper_outline_candidates_root(self, quest_root: Path) -> Path:
630
- return ensure_dir(self._paper_root(quest_root) / "outlines" / "candidates")
738
+ def _paper_outline_candidates_root(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
739
+ return ensure_dir(self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "outlines" / "candidates")
631
740
 
632
- def _paper_outline_revisions_root(self, quest_root: Path) -> Path:
633
- return ensure_dir(self._paper_root(quest_root) / "outlines" / "revisions")
741
+ def _paper_outline_revisions_root(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
742
+ return ensure_dir(self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "outlines" / "revisions")
634
743
 
635
- def _paper_selected_outline_path(self, quest_root: Path) -> Path:
636
- return self._paper_root(quest_root) / "selected_outline.json"
744
+ def _paper_selected_outline_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
745
+ return self._paper_root(quest_root, workspace_root=workspace_root) / "selected_outline.json"
637
746
 
638
- def _paper_outline_selection_path(self, quest_root: Path) -> Path:
639
- return self._paper_root(quest_root) / "outline_selection.md"
747
+ def _paper_outline_selection_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
748
+ return self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "outline_selection.md"
640
749
 
641
- def _paper_bundle_manifest_path(self, quest_root: Path) -> Path:
642
- return self._paper_root(quest_root) / "paper_bundle_manifest.json"
750
+ def _paper_bundle_manifest_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
751
+ return self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "paper_bundle_manifest.json"
643
752
 
644
- def _paper_baseline_inventory_path(self, quest_root: Path) -> Path:
645
- return self._paper_root(quest_root) / "baseline_inventory.json"
753
+ def _paper_baseline_inventory_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
754
+ return self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "baseline_inventory.json"
646
755
 
647
- def _open_source_root(self, quest_root: Path) -> Path:
648
- return ensure_dir(quest_root / "release" / "open_source")
756
+ def _open_source_root(
757
+ self,
758
+ quest_root: Path,
759
+ *,
760
+ workspace_root: Path | None = None,
761
+ prefer_workspace: bool = True,
762
+ create: bool = False,
763
+ ) -> Path:
764
+ roots: list[Path] = []
765
+ if prefer_workspace:
766
+ roots.append(self._workspace_root_for(quest_root, workspace_root))
767
+ roots.append(quest_root)
768
+ seen: set[str] = set()
769
+ first_candidate: Path | None = None
770
+ for root in roots:
771
+ key = str(root.resolve())
772
+ if key in seen:
773
+ continue
774
+ seen.add(key)
775
+ candidate = root / "release" / "open_source"
776
+ if first_candidate is None:
777
+ first_candidate = candidate
778
+ if candidate.exists():
779
+ return candidate
780
+ fallback = first_candidate or (quest_root / "release" / "open_source")
781
+ return ensure_dir(fallback) if create else fallback
649
782
 
650
- def _open_source_manifest_path(self, quest_root: Path) -> Path:
651
- return self._open_source_root(quest_root) / "manifest.json"
783
+ def _open_source_manifest_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
784
+ return self._open_source_root(quest_root, workspace_root=workspace_root, create=True) / "manifest.json"
652
785
 
653
- def _open_source_cleanup_plan_path(self, quest_root: Path) -> Path:
654
- return self._open_source_root(quest_root) / "cleanup_plan.md"
786
+ def _open_source_cleanup_plan_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
787
+ return self._open_source_root(quest_root, workspace_root=workspace_root, create=True) / "cleanup_plan.md"
655
788
 
656
- def _open_source_include_paths_path(self, quest_root: Path) -> Path:
657
- return self._open_source_root(quest_root) / "include_paths.json"
789
+ def _open_source_include_paths_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
790
+ return self._open_source_root(quest_root, workspace_root=workspace_root, create=True) / "include_paths.json"
658
791
 
659
- def _open_source_exclude_paths_path(self, quest_root: Path) -> Path:
660
- return self._open_source_root(quest_root) / "exclude_paths.json"
792
+ def _open_source_exclude_paths_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
793
+ return self._open_source_root(quest_root, workspace_root=workspace_root, create=True) / "exclude_paths.json"
661
794
 
662
- def _write_paper_baseline_inventory(self, quest_root: Path) -> dict[str, Any]:
795
+ def _write_paper_baseline_inventory(self, quest_root: Path, *, workspace_root: Path | None = None) -> dict[str, Any]:
663
796
  quest_yaml = self.quest_service.read_quest_yaml(quest_root)
664
797
  confirmed_baseline_ref = (
665
798
  dict(quest_yaml.get("confirmed_baseline_ref") or {})
@@ -675,22 +808,23 @@ class ArtifactService:
675
808
  ],
676
809
  "updated_at": utc_now(),
677
810
  }
678
- write_json(self._paper_baseline_inventory_path(quest_root), payload)
811
+ write_json(self._paper_baseline_inventory_path(quest_root, workspace_root=workspace_root), payload)
679
812
  return payload
680
813
 
681
814
  def _ensure_open_source_prep(
682
815
  self,
683
816
  quest_root: Path,
684
817
  *,
818
+ workspace_root: Path | None,
685
819
  source_branch: str | None,
686
820
  source_bundle_manifest_path: str,
687
821
  baseline_inventory_path: str,
688
822
  ) -> 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)
823
+ root = self._open_source_root(quest_root, workspace_root=workspace_root, create=True)
824
+ cleanup_plan_path = self._open_source_cleanup_plan_path(quest_root, workspace_root=workspace_root)
825
+ include_paths_path = self._open_source_include_paths_path(quest_root, workspace_root=workspace_root)
826
+ exclude_paths_path = self._open_source_exclude_paths_path(quest_root, workspace_root=workspace_root)
827
+ manifest_path = self._open_source_manifest_path(quest_root, workspace_root=workspace_root)
694
828
  if not cleanup_plan_path.exists():
695
829
  write_text(
696
830
  cleanup_plan_path,
@@ -737,11 +871,17 @@ class ArtifactService:
737
871
  or source_bundle_manifest_path,
738
872
  "baseline_inventory_path": str(existing.get("baseline_inventory_path") or baseline_inventory_path or "").strip()
739
873
  or baseline_inventory_path,
740
- "cleanup_plan_path": str(existing.get("cleanup_plan_path") or "release/open_source/cleanup_plan.md").strip()
874
+ "cleanup_plan_path": str(
875
+ existing.get("cleanup_plan_path") or self._workspace_relative(quest_root, cleanup_plan_path) or ""
876
+ ).strip()
741
877
  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()
878
+ "include_paths_path": str(
879
+ existing.get("include_paths_path") or self._workspace_relative(quest_root, include_paths_path) or ""
880
+ ).strip()
743
881
  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()
882
+ "exclude_paths_path": str(
883
+ existing.get("exclude_paths_path") or self._workspace_relative(quest_root, exclude_paths_path) or ""
884
+ ).strip()
745
885
  or "release/open_source/exclude_paths.json",
746
886
  "created_at": existing.get("created_at") or utc_now(),
747
887
  "updated_at": utc_now(),
@@ -1064,6 +1204,7 @@ class ArtifactService:
1064
1204
  "metric_contract": metric_contract,
1065
1205
  "primary_metric": entry.get("primary_metric"),
1066
1206
  "metrics_summary": metrics_summary,
1207
+ "metric_details": entry.get("metric_details") or [],
1067
1208
  }
1068
1209
  json_path = ensure_dir(baseline_root / "json") / "metric_contract.json"
1069
1210
  write_json(json_path, payload)
@@ -1169,9 +1310,43 @@ class ArtifactService:
1169
1310
  "Use `artifact.confirm_baseline(...)` or `artifact.waive_baseline(...)` first."
1170
1311
  )
1171
1312
 
1313
+ @staticmethod
1314
+ def _artifact_record_identity(path: Path, payload: dict[str, Any], *, kind: str | None = None) -> str:
1315
+ normalized_kind = str(kind or payload.get("kind") or path.parent.name or "artifact").strip() or "artifact"
1316
+ branch_name = str(payload.get("branch") or "").strip()
1317
+ run_id = str(payload.get("run_id") or "").strip()
1318
+ if normalized_kind == "run" and run_id and branch_name:
1319
+ return f"{normalized_kind}:branch_run:{branch_name}:{run_id}"
1320
+ artifact_id = str(payload.get("artifact_id") or payload.get("id") or "").strip()
1321
+ if artifact_id:
1322
+ return f"{normalized_kind}:artifact:{artifact_id}"
1323
+ if normalized_kind == "run" and run_id:
1324
+ return f"{normalized_kind}:run:{run_id}"
1325
+ idea_id = str(payload.get("idea_id") or "").strip()
1326
+ if normalized_kind == "idea" and idea_id and branch_name:
1327
+ return f"{normalized_kind}:branch_idea:{branch_name}:{idea_id}"
1328
+ if normalized_kind == "idea" and idea_id:
1329
+ return f"{normalized_kind}:idea:{idea_id}"
1330
+ baseline_id = str(payload.get("baseline_id") or payload.get("entry_id") or "").strip()
1331
+ if baseline_id:
1332
+ return f"{normalized_kind}:baseline:{baseline_id}"
1333
+ interaction_id = str(payload.get("interaction_id") or "").strip()
1334
+ if interaction_id:
1335
+ return f"{normalized_kind}:interaction:{interaction_id}"
1336
+ return f"path:{path.resolve()}"
1337
+
1338
+ @staticmethod
1339
+ def _artifact_record_rank(payload: dict[str, Any], *, path: Path, mtime_ns: int) -> tuple[str, str, int, int, str]:
1340
+ return (
1341
+ str(payload.get("updated_at") or ""),
1342
+ str(payload.get("created_at") or ""),
1343
+ len(payload),
1344
+ mtime_ns,
1345
+ str(path),
1346
+ )
1347
+
1172
1348
  def _main_run_artifacts(self, quest_root: Path) -> list[dict[str, Any]]:
1173
- records: list[dict[str, Any]] = []
1174
- seen_paths: set[str] = set()
1349
+ records_by_identity: dict[str, dict[str, Any]] = {}
1175
1350
  for root in self.quest_service.workspace_roots(quest_root):
1176
1351
  artifacts_root = root / "artifacts" / "runs"
1177
1352
  if not artifacts_root.exists():
@@ -1179,10 +1354,6 @@ class ArtifactService:
1179
1354
  for path in sorted(artifacts_root.glob("*.json")):
1180
1355
  if not path.is_file():
1181
1356
  continue
1182
- key = str(path.resolve())
1183
- if key in seen_paths:
1184
- continue
1185
- seen_paths.add(key)
1186
1357
  payload = read_json(path, {})
1187
1358
  if not isinstance(payload, dict) or not payload:
1188
1359
  continue
@@ -1194,7 +1365,19 @@ class ArtifactService:
1194
1365
  enriched["_artifact_mtime_ns"] = path.stat().st_mtime_ns
1195
1366
  except OSError:
1196
1367
  enriched["_artifact_mtime_ns"] = 0
1197
- records.append(enriched)
1368
+ identity = self._artifact_record_identity(path, enriched, kind="run")
1369
+ existing = records_by_identity.get(identity)
1370
+ if existing is None or self._artifact_record_rank(
1371
+ enriched,
1372
+ path=path,
1373
+ mtime_ns=int(enriched.get("_artifact_mtime_ns") or 0),
1374
+ ) >= self._artifact_record_rank(
1375
+ existing,
1376
+ path=Path(str(existing.get("_artifact_path") or path)),
1377
+ mtime_ns=int(existing.get("_artifact_mtime_ns") or 0),
1378
+ ):
1379
+ records_by_identity[identity] = enriched
1380
+ records = list(records_by_identity.values())
1198
1381
  records.sort(
1199
1382
  key=lambda item: (
1200
1383
  str(item.get("updated_at") or item.get("created_at") or ""),
@@ -1277,6 +1460,173 @@ class ArtifactService:
1277
1460
  continue
1278
1461
  return None
1279
1462
 
1463
+ def _branch_activation_worktree_root(
1464
+ self,
1465
+ quest_root: Path,
1466
+ *,
1467
+ branch_name: str,
1468
+ idea_id: str | None = None,
1469
+ run_id: str | None = None,
1470
+ ) -> Path:
1471
+ normalized_branch = str(branch_name or "").strip()
1472
+ branch_kind = self._branch_kind_from_name(normalized_branch)
1473
+ normalized_idea_id = str(idea_id or "").strip() or None
1474
+ if branch_kind == "paper":
1475
+ normalized_run_id = str(run_id or "").strip() or None
1476
+ return canonical_worktree_root(
1477
+ quest_root,
1478
+ f"paper-{normalized_run_id or slugify(normalized_branch, 'paper')}",
1479
+ )
1480
+ if normalized_idea_id and branch_kind == "idea":
1481
+ return canonical_worktree_root(quest_root, f"idea-{normalized_idea_id}")
1482
+ normalized_run_id = str(run_id or "").strip() or None
1483
+ if normalized_run_id and branch_kind == "run":
1484
+ return canonical_worktree_root(quest_root, normalized_run_id)
1485
+ return canonical_worktree_root(quest_root, f"branch-{slugify(normalized_branch, 'branch')}")
1486
+
1487
+ @staticmethod
1488
+ def _resolve_activate_branch_anchor(
1489
+ *,
1490
+ anchor: str | None,
1491
+ has_idea: bool,
1492
+ has_main_result: bool,
1493
+ ) -> str:
1494
+ normalized_anchor = str(anchor or "auto").strip().lower() or "auto"
1495
+ if normalized_anchor == "auto":
1496
+ if has_main_result:
1497
+ return "decision"
1498
+ if has_idea:
1499
+ return "experiment"
1500
+ return "idea"
1501
+ aliases = {
1502
+ "analysis": "analysis-campaign",
1503
+ }
1504
+ resolved_anchor = aliases.get(normalized_anchor, normalized_anchor)
1505
+ allowed = {
1506
+ "scout",
1507
+ "baseline",
1508
+ "idea",
1509
+ "experiment",
1510
+ "analysis-campaign",
1511
+ "write",
1512
+ "finalize",
1513
+ "decision",
1514
+ }
1515
+ if resolved_anchor not in allowed:
1516
+ allowed_text = ", ".join(sorted(allowed | {"auto"}))
1517
+ raise ValueError(f"Unsupported activate_branch anchor `{anchor}`. Allowed values: {allowed_text}.")
1518
+ return resolved_anchor
1519
+
1520
+ def _resolve_branch_activation_target(
1521
+ self,
1522
+ quest_root: Path,
1523
+ *,
1524
+ branch: str | None = None,
1525
+ idea_id: str | None = None,
1526
+ run_id: str | None = None,
1527
+ ) -> dict[str, Any]:
1528
+ provided = sum(
1529
+ 1
1530
+ for value in (
1531
+ str(branch or "").strip(),
1532
+ str(idea_id or "").strip(),
1533
+ str(run_id or "").strip(),
1534
+ )
1535
+ if value
1536
+ )
1537
+ if provided != 1:
1538
+ raise ValueError("activate_branch requires exactly one of `branch`, `idea_id`, or `run_id`.")
1539
+
1540
+ latest_idea: dict[str, Any] | None = None
1541
+ latest_run: dict[str, Any] | None = None
1542
+ normalized_branch = str(branch or "").strip()
1543
+ normalized_idea_id = str(idea_id or "").strip()
1544
+ normalized_run_id = str(run_id or "").strip()
1545
+
1546
+ if normalized_idea_id:
1547
+ candidates = [
1548
+ item for item in self._idea_artifacts(quest_root) if str(item.get("idea_id") or "").strip() == normalized_idea_id
1549
+ ]
1550
+ if not candidates:
1551
+ raise FileNotFoundError(f"Unknown idea `{normalized_idea_id}`.")
1552
+ latest_idea = candidates[-1]
1553
+ normalized_branch = str(latest_idea.get("branch") or "").strip()
1554
+ elif normalized_run_id:
1555
+ candidates = [
1556
+ item for item in self._main_run_artifacts(quest_root) if str(item.get("run_id") or "").strip() == normalized_run_id
1557
+ ]
1558
+ if not candidates:
1559
+ raise FileNotFoundError(f"Unknown main run `{normalized_run_id}`.")
1560
+ latest_run = candidates[-1]
1561
+ normalized_branch = str(latest_run.get("branch") or "").strip()
1562
+ else:
1563
+ if normalized_branch.startswith("analysis/"):
1564
+ raise ValueError(
1565
+ "activate_branch only supports durable idea/main branches. "
1566
+ "Analysis slice branches remain managed by analysis campaigns."
1567
+ )
1568
+ if not branch_exists(quest_root, normalized_branch):
1569
+ raise FileNotFoundError(f"Unknown branch `{normalized_branch}`.")
1570
+
1571
+ if not normalized_branch:
1572
+ raise ValueError("Unable to resolve a durable branch to activate.")
1573
+
1574
+ prepare_record = self._latest_prepare_branch_record(quest_root, normalized_branch)
1575
+ prepare_details = dict(prepare_record.get("details") or {}) if isinstance(prepare_record.get("details"), dict) else {}
1576
+ recorded_parent_branch = (
1577
+ str(prepare_record.get("parent_branch") or prepare_details.get("parent_branch") or "").strip() or None
1578
+ )
1579
+ recorded_branch_kind = (
1580
+ str(prepare_record.get("branch_kind") or prepare_details.get("branch_kind") or "").strip().lower()
1581
+ or self._branch_kind_from_name(normalized_branch)
1582
+ )
1583
+
1584
+ latest_idea = latest_idea or self._latest_idea_for_branch(quest_root, normalized_branch)
1585
+ latest_run = latest_run or self._latest_main_run_for_branch(quest_root, normalized_branch)
1586
+ if not latest_run and recorded_branch_kind == "idea":
1587
+ latest_run = self._latest_child_main_run_for_branch(quest_root, normalized_branch)
1588
+ if not latest_run and recorded_parent_branch:
1589
+ latest_run = self._latest_main_run_for_branch(quest_root, recorded_parent_branch)
1590
+ resolved_idea_id = (
1591
+ normalized_idea_id
1592
+ or str((latest_run or {}).get("idea_id") or "").strip()
1593
+ or str((latest_idea or {}).get("idea_id") or "").strip()
1594
+ or str(prepare_record.get("idea_id") or "").strip()
1595
+ or self._latest_branch_idea_id(quest_root, normalized_branch)
1596
+ or None
1597
+ )
1598
+ idea_paths = dict((latest_idea or {}).get("paths") or {}) if isinstance((latest_idea or {}).get("paths"), dict) else {}
1599
+ recorded_root = (
1600
+ str((latest_idea or {}).get("worktree_root") or "").strip()
1601
+ or str((latest_run or {}).get("worktree_root") or "").strip()
1602
+ or str(prepare_record.get("worktree_root") or "").strip()
1603
+ or None
1604
+ )
1605
+ return {
1606
+ "branch": normalized_branch,
1607
+ "idea_id": resolved_idea_id,
1608
+ "run_id": normalized_run_id or str((latest_run or {}).get("run_id") or "").strip() or None,
1609
+ "has_main_result": bool((latest_run or {}).get("run_id")),
1610
+ "latest_idea": latest_idea,
1611
+ "latest_main_run": latest_run,
1612
+ "branch_kind": recorded_branch_kind,
1613
+ "parent_branch": recorded_parent_branch,
1614
+ "recorded_worktree_root": recorded_root,
1615
+ "idea_md_path": str(idea_paths.get("idea_md") or "").strip() or None,
1616
+ "idea_draft_path": str(idea_paths.get("idea_draft_md") or "").strip() or None,
1617
+ "suggested_worktree_root": self._branch_activation_worktree_root(
1618
+ quest_root,
1619
+ branch_name=normalized_branch,
1620
+ idea_id=resolved_idea_id,
1621
+ run_id=(
1622
+ normalized_run_id
1623
+ or str(prepare_record.get("run_id") or "").strip()
1624
+ or str((latest_run or {}).get("run_id") or "").strip()
1625
+ or None
1626
+ ),
1627
+ ),
1628
+ }
1629
+
1280
1630
  def _normalize_foundation_ref(self, foundation_ref: dict[str, Any] | str | None) -> dict[str, Any]:
1281
1631
  if foundation_ref is None:
1282
1632
  return {"kind": "current_head", "ref": None}
@@ -1445,6 +1795,17 @@ class ArtifactService:
1445
1795
  ]
1446
1796
  return candidates[-1] if candidates else None
1447
1797
 
1798
+ def _latest_child_main_run_for_branch(self, quest_root: Path, branch_name: str) -> dict[str, Any] | None:
1799
+ normalized_branch = str(branch_name or "").strip()
1800
+ if not normalized_branch:
1801
+ return None
1802
+ candidates = [
1803
+ item
1804
+ for item in self._main_run_artifacts(quest_root)
1805
+ if str(item.get("parent_branch") or "").strip() == normalized_branch
1806
+ ]
1807
+ return candidates[-1] if candidates else None
1808
+
1448
1809
  def _latest_idea_for_branch(self, quest_root: Path, branch_name: str) -> dict[str, Any] | None:
1449
1810
  normalized_branch = str(branch_name or "").strip()
1450
1811
  if not normalized_branch:
@@ -1527,6 +1888,16 @@ class ArtifactService:
1527
1888
  if not parent_branch:
1528
1889
  raise ValueError("Unable to resolve a parent branch for the analysis campaign.")
1529
1890
 
1891
+ if self._branch_kind_from_name(parent_branch) == "idea":
1892
+ latest_child_run = self._latest_child_main_run_for_branch(quest_root, parent_branch)
1893
+ if isinstance(latest_child_run, dict) and str(latest_child_run.get("branch") or "").strip():
1894
+ parent_branch = str(latest_child_run.get("branch") or "").strip()
1895
+ recorded_worktree_root = str(latest_child_run.get("worktree_root") or "").strip()
1896
+ if recorded_worktree_root:
1897
+ candidate = Path(recorded_worktree_root)
1898
+ if candidate.exists():
1899
+ parent_worktree_root = candidate
1900
+
1530
1901
  idea_id = self._latest_branch_idea_id(quest_root, parent_branch) or str(state.get("active_idea_id") or "").strip() or None
1531
1902
  return parent_branch, parent_worktree_root, idea_id
1532
1903
 
@@ -1568,15 +1939,22 @@ class ArtifactService:
1568
1939
  state=state,
1569
1940
  foundation_ref={"kind": "idea", "ref": str(latest_idea.get("idea_id") or "").strip()},
1570
1941
  )
1942
+ current_workspace_branch = str(state.get("current_workspace_branch") or "").strip()
1943
+ research_head_branch = str(state.get("research_head_branch") or "").strip()
1571
1944
  active_branch = (
1572
- str(state.get("research_head_branch") or "").strip()
1573
- or str(state.get("current_workspace_branch") or "").strip()
1945
+ current_workspace_branch
1946
+ or research_head_branch
1947
+ or current_branch(self._workspace_root_for(quest_root))
1574
1948
  )
1575
1949
  if normalized_branch and active_branch and normalized_branch == active_branch:
1576
1950
  return self._resolve_idea_foundation(
1577
1951
  quest_root,
1578
1952
  state=state,
1579
- foundation_ref=None,
1953
+ foundation_ref=(
1954
+ {"kind": "branch", "ref": normalized_branch}
1955
+ if current_workspace_branch and research_head_branch and current_workspace_branch != research_head_branch
1956
+ else None
1957
+ ),
1580
1958
  )
1581
1959
  return self._resolve_idea_foundation(
1582
1960
  quest_root,
@@ -1614,8 +1992,8 @@ class ArtifactService:
1614
1992
  ) -> tuple[str, str, dict[str, Any]]:
1615
1993
  normalized_intent = self._normalize_lineage_intent(lineage_intent) or "continue_line"
1616
1994
  active_branch = (
1617
- str(state.get("research_head_branch") or "").strip()
1618
- or str(state.get("current_workspace_branch") or "").strip()
1995
+ str(state.get("current_workspace_branch") or "").strip()
1996
+ or str(state.get("research_head_branch") or "").strip()
1619
1997
  )
1620
1998
  if not active_branch:
1621
1999
  active_branch = current_branch(self._workspace_root_for(quest_root))
@@ -1643,6 +2021,7 @@ class ArtifactService:
1643
2021
  def list_research_branches(self, quest_root: Path) -> dict[str, Any]:
1644
2022
  state = self.quest_service.read_research_state(quest_root)
1645
2023
  active_head_branch = str(state.get("research_head_branch") or "").strip() or None
2024
+ active_workspace_branch = str(state.get("current_workspace_branch") or "").strip() or None
1646
2025
  idea_records = self._idea_artifacts(quest_root)
1647
2026
  main_runs = self._main_run_artifacts(quest_root)
1648
2027
 
@@ -1709,6 +2088,7 @@ class ArtifactService:
1709
2088
  "verdict": record.get("verdict"),
1710
2089
  "status": record.get("status"),
1711
2090
  "idea_id": record.get("idea_id"),
2091
+ "parent_branch": record.get("parent_branch"),
1712
2092
  "primary_metric_id": details.get("primary_metric_id"),
1713
2093
  "primary_value": details.get("primary_value"),
1714
2094
  "delta_vs_baseline": details.get("delta_vs_baseline"),
@@ -1721,6 +2101,8 @@ class ArtifactService:
1721
2101
 
1722
2102
  if active_head_branch:
1723
2103
  ensure_branch_entry(active_head_branch)
2104
+ if active_workspace_branch:
2105
+ ensure_branch_entry(active_workspace_branch)
1724
2106
 
1725
2107
  ordered_branches = sorted(
1726
2108
  grouped.values(),
@@ -1756,10 +2138,15 @@ class ArtifactService:
1756
2138
  else {}
1757
2139
  )
1758
2140
  parent_branch = str(latest_idea.get("parent_branch") or "").strip() or None
2141
+ experiment_parent_branch = (
2142
+ str((latest_experiment or {}).get("parent_branch") or "").strip()
2143
+ if isinstance(latest_experiment, dict)
2144
+ else None
2145
+ ) or None
1759
2146
  foundation_branch = (
1760
2147
  str(latest_foundation.get("branch") or latest_foundation.get("ref") or "").strip() or None
1761
2148
  )
1762
- resolved_parent_branch = parent_branch or foundation_branch
2149
+ resolved_parent_branch = parent_branch or experiment_parent_branch or foundation_branch
1763
2150
  has_main_result = isinstance(latest_experiment, dict) and bool(latest_experiment.get("run_id"))
1764
2151
  numeric_branch_no = recorded_branch_numbers.get(branch_name)
1765
2152
  if numeric_branch_no is None:
@@ -1774,7 +2161,8 @@ class ArtifactService:
1774
2161
  "branch_name": branch_name,
1775
2162
  "worktree_root": item.get("worktree_root"),
1776
2163
  "is_active_head": branch_name == active_head_branch,
1777
- "idea_id": latest_idea.get("idea_id"),
2164
+ "is_active_workspace": branch_name == active_workspace_branch,
2165
+ "idea_id": latest_idea.get("idea_id") or (latest_experiment.get("idea_id") if isinstance(latest_experiment, dict) else None),
1778
2166
  "idea_title": latest_idea.get("title"),
1779
2167
  "idea_problem": latest_idea.get("problem"),
1780
2168
  "next_target": latest_idea.get("next_target"),
@@ -1810,6 +2198,7 @@ class ArtifactService:
1810
2198
  return {
1811
2199
  "ok": True,
1812
2200
  "active_head_branch": active_head_branch,
2201
+ "active_workspace_branch": active_workspace_branch,
1813
2202
  "count": len(branches),
1814
2203
  "branches": branches,
1815
2204
  }
@@ -1819,9 +2208,10 @@ class ArtifactService:
1819
2208
  snapshot = self.quest_service.snapshot(self._quest_id(quest_root))
1820
2209
  active_campaign_id = str(state.get("active_analysis_campaign_id") or "").strip() or None
1821
2210
  analysis_parent_branch = str(state.get("analysis_parent_branch") or "").strip() or None
2211
+ paper_parent_branch = str(state.get("paper_parent_branch") or "").strip() or None
1822
2212
  current_workspace_branch = str(state.get("current_workspace_branch") or "").strip() or None
1823
2213
  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
2214
+ canonical_branch = analysis_parent_branch or paper_parent_branch or current_workspace_branch or research_head_branch
1825
2215
  latest_main_run = self._latest_main_run_for_branch(quest_root, canonical_branch or "")
1826
2216
  selected_outline = read_json(self._paper_selected_outline_path(quest_root), {})
1827
2217
  selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
@@ -2155,14 +2545,27 @@ class ArtifactService:
2155
2545
  create_worktree_flag: bool = True,
2156
2546
  start_point: str | None = None,
2157
2547
  ) -> dict:
2158
- parent_branch = current_branch(quest_root)
2548
+ state = self.quest_service.read_research_state(quest_root)
2549
+ parent_branch = (
2550
+ str(start_point or "").strip()
2551
+ or str(state.get("current_workspace_branch") or "").strip()
2552
+ or str(state.get("research_head_branch") or "").strip()
2553
+ or current_branch(self._workspace_root_for(quest_root))
2554
+ or current_branch(quest_root)
2555
+ )
2159
2556
  start_ref = start_point or parent_branch
2160
2557
  branch_name = branch or self._default_branch_name(quest_root, run_id=run_id, idea_id=idea_id, branch_kind=branch_kind)
2161
2558
  branch_result = ensure_branch(quest_root, branch_name, start_point=start_ref, checkout=False)
2162
2559
  worktree_result = None
2163
2560
  worktree_root = None
2164
2561
  if create_worktree_flag:
2165
- worktree_root = canonical_worktree_root(quest_root, run_id or branch_name)
2562
+ worktree_root = self._prepare_branch_worktree_root(
2563
+ quest_root,
2564
+ branch_name=branch_name,
2565
+ branch_kind=branch_kind,
2566
+ run_id=run_id,
2567
+ idea_id=idea_id,
2568
+ )
2166
2569
  worktree_result = create_worktree(
2167
2570
  quest_root,
2168
2571
  branch=branch_name,
@@ -2184,9 +2587,11 @@ class ArtifactService:
2184
2587
  "parent_branch": parent_branch,
2185
2588
  "start_point": start_ref,
2186
2589
  "worktree_root": str(worktree_root) if worktree_root else None,
2590
+ "workspace_mode": self._workspace_mode_for_branch(branch_name, has_idea=bool(idea_id)),
2187
2591
  "source": {"kind": "system", "role": "artifact"},
2188
2592
  },
2189
2593
  checkpoint=False,
2594
+ workspace_root=worktree_root if worktree_root else None,
2190
2595
  )
2191
2596
  return {
2192
2597
  "ok": True,
@@ -2200,6 +2605,364 @@ class ArtifactService:
2200
2605
  "artifact": artifact_result,
2201
2606
  }
2202
2607
 
2608
+ def activate_branch(
2609
+ self,
2610
+ quest_root: Path,
2611
+ *,
2612
+ branch: str | None = None,
2613
+ idea_id: str | None = None,
2614
+ run_id: str | None = None,
2615
+ anchor: str | None = "auto",
2616
+ promote_to_head: bool = False,
2617
+ create_worktree_if_missing: bool = True,
2618
+ ) -> dict[str, Any]:
2619
+ state = self.quest_service.read_research_state(quest_root)
2620
+ active_campaign_id = str(state.get("active_analysis_campaign_id") or "").strip() or None
2621
+ if active_campaign_id:
2622
+ raise ValueError(
2623
+ "activate_branch cannot run while an analysis campaign is active. "
2624
+ "Finish or close the campaign first."
2625
+ )
2626
+
2627
+ target = self._resolve_branch_activation_target(
2628
+ quest_root,
2629
+ branch=branch,
2630
+ idea_id=idea_id,
2631
+ run_id=run_id,
2632
+ )
2633
+ branch_name = str(target.get("branch") or "").strip()
2634
+ if str(target.get("branch_kind") or self._branch_kind_from_name(branch_name)).strip().lower() != "paper":
2635
+ self._require_baseline_gate_open(quest_root, action="activate_branch")
2636
+ resolved_idea_id = str(target.get("idea_id") or "").strip() or None
2637
+ latest_main_run = (
2638
+ dict(target.get("latest_main_run") or {})
2639
+ if isinstance(target.get("latest_main_run"), dict)
2640
+ else {}
2641
+ )
2642
+ latest_idea = (
2643
+ dict(target.get("latest_idea") or {})
2644
+ if isinstance(target.get("latest_idea"), dict)
2645
+ else {}
2646
+ )
2647
+ branch_kind = str(target.get("branch_kind") or self._branch_kind_from_name(branch_name)).strip().lower() or "branch"
2648
+ source_parent_branch = str(target.get("parent_branch") or "").strip() or None
2649
+
2650
+ workspace_root = self._branch_workspace_root(quest_root, branch_name)
2651
+ worktree_result = None
2652
+ worktree_created = False
2653
+ if workspace_root is None:
2654
+ recorded_root = str(target.get("recorded_worktree_root") or "").strip()
2655
+ if recorded_root:
2656
+ candidate = Path(recorded_root)
2657
+ if candidate.exists():
2658
+ workspace_root = candidate
2659
+ if workspace_root is None:
2660
+ if not create_worktree_if_missing:
2661
+ raise FileNotFoundError(
2662
+ f"No existing worktree is available for branch `{branch_name}` and create_worktree_if_missing=False."
2663
+ )
2664
+ workspace_root = Path(target.get("suggested_worktree_root") or "")
2665
+ worktree_result = create_worktree(
2666
+ quest_root,
2667
+ branch=branch_name,
2668
+ worktree_root=workspace_root,
2669
+ start_point=branch_name,
2670
+ )
2671
+ if not bool(worktree_result.get("ok")):
2672
+ raise RuntimeError(
2673
+ f"Failed to activate branch `{branch_name}`: {worktree_result.get('stderr') or 'worktree creation failed.'}"
2674
+ )
2675
+ worktree_created = True
2676
+
2677
+ resolved_workspace_root = workspace_root or quest_root
2678
+ idea_md_path = (
2679
+ str(target.get("idea_md_path") or "").strip()
2680
+ or str((dict(latest_idea.get("paths") or {}) if isinstance(latest_idea.get("paths"), dict) else {}).get("idea_md") or "").strip()
2681
+ or (str(resolved_workspace_root / "memory" / "ideas" / resolved_idea_id / "idea.md") if resolved_idea_id else "")
2682
+ )
2683
+ idea_draft_path = (
2684
+ str(target.get("idea_draft_path") or "").strip()
2685
+ or str((dict(latest_idea.get("paths") or {}) if isinstance(latest_idea.get("paths"), dict) else {}).get("idea_draft_md") or "").strip()
2686
+ or (str(resolved_workspace_root / "memory" / "ideas" / resolved_idea_id / "draft.md") if resolved_idea_id else "")
2687
+ )
2688
+ resolved_idea_md_path = idea_md_path if resolved_idea_id else None
2689
+ resolved_idea_draft_path = idea_draft_path if resolved_idea_id else None
2690
+ has_main_result = bool(latest_main_run.get("run_id"))
2691
+ if branch_kind == "paper":
2692
+ next_anchor = "write" if str(anchor or "auto").strip().lower() == "auto" else self._resolve_activate_branch_anchor(
2693
+ anchor=anchor,
2694
+ has_idea=bool(resolved_idea_id),
2695
+ has_main_result=has_main_result,
2696
+ )
2697
+ else:
2698
+ next_anchor = self._resolve_activate_branch_anchor(
2699
+ anchor=anchor,
2700
+ has_idea=bool(resolved_idea_id),
2701
+ has_main_result=has_main_result,
2702
+ )
2703
+ workspace_mode = self._workspace_mode_for_branch(branch_name, has_idea=bool(resolved_idea_id))
2704
+ source_run_id = (
2705
+ str(target.get("run_id") or "").strip()
2706
+ or str(latest_main_run.get("run_id") or "").strip()
2707
+ or None
2708
+ )
2709
+
2710
+ artifact = self.record(
2711
+ quest_root,
2712
+ {
2713
+ "kind": "decision",
2714
+ "status": "completed",
2715
+ "verdict": "continue",
2716
+ "action": "activate_branch",
2717
+ "summary": f"Activated durable branch `{branch_name}` as the current workspace.",
2718
+ "reason": (
2719
+ "Return to an existing research branch without creating a new lineage node, "
2720
+ "so follow-up experiments or decisions continue from the correct historical context."
2721
+ ),
2722
+ "idea_id": resolved_idea_id,
2723
+ "run_id": str(latest_main_run.get("run_id") or "").strip() or None,
2724
+ "branch": branch_name,
2725
+ "worktree_root": str(resolved_workspace_root),
2726
+ "worktree_rel_path": self._workspace_relative(quest_root, resolved_workspace_root),
2727
+ "flow_type": "branch_activation",
2728
+ "protocol_step": "activate",
2729
+ "details": {
2730
+ "activate_branch_by": (
2731
+ "idea_id"
2732
+ if str(idea_id or "").strip()
2733
+ else "run_id"
2734
+ if str(run_id or "").strip()
2735
+ else "branch"
2736
+ ),
2737
+ "promote_to_head": bool(promote_to_head),
2738
+ "worktree_created": worktree_created,
2739
+ "next_anchor": next_anchor,
2740
+ "workspace_mode": workspace_mode,
2741
+ "latest_main_run_id": str(latest_main_run.get("run_id") or "").strip() or None,
2742
+ "branch_kind": branch_kind,
2743
+ "paper_parent_branch": source_parent_branch if branch_kind == "paper" else None,
2744
+ },
2745
+ },
2746
+ checkpoint=False,
2747
+ workspace_root=resolved_workspace_root,
2748
+ )
2749
+
2750
+ research_state_updates: dict[str, Any] = {
2751
+ "active_idea_id": resolved_idea_id,
2752
+ "current_workspace_branch": branch_name,
2753
+ "current_workspace_root": str(resolved_workspace_root),
2754
+ "active_idea_md_path": resolved_idea_md_path,
2755
+ "active_idea_draft_path": resolved_idea_draft_path,
2756
+ "active_analysis_campaign_id": None,
2757
+ "analysis_parent_branch": None,
2758
+ "analysis_parent_worktree_root": None,
2759
+ "paper_parent_branch": source_parent_branch if branch_kind == "paper" else None,
2760
+ "paper_parent_worktree_root": (
2761
+ str(self._branch_workspace_root(quest_root, source_parent_branch))
2762
+ if branch_kind == "paper" and source_parent_branch and self._branch_workspace_root(quest_root, source_parent_branch)
2763
+ else None
2764
+ ),
2765
+ "paper_parent_run_id": source_run_id if branch_kind == "paper" else None,
2766
+ "next_pending_slice_id": None,
2767
+ "workspace_mode": workspace_mode,
2768
+ "last_flow_type": "branch_activation",
2769
+ }
2770
+ if promote_to_head:
2771
+ research_state_updates["research_head_branch"] = branch_name
2772
+ research_state_updates["research_head_worktree_root"] = str(resolved_workspace_root)
2773
+ research_state = self.quest_service.update_research_state(quest_root, **research_state_updates)
2774
+ self.quest_service.update_settings(self._quest_id(quest_root), active_anchor=next_anchor)
2775
+
2776
+ interaction = self.interact(
2777
+ quest_root,
2778
+ kind="milestone",
2779
+ message=(
2780
+ f"Activated branch `{branch_name}`.\n"
2781
+ f"- Worktree: `{resolved_workspace_root}`\n"
2782
+ f"- Active idea: `{resolved_idea_id or 'none'}`\n"
2783
+ f"- Latest main run: `{str(latest_main_run.get('run_id') or '').strip() or 'none'}`\n"
2784
+ f"- Promoted to head: `{bool(promote_to_head)}`\n"
2785
+ f"- Next anchor: `{next_anchor}`"
2786
+ ),
2787
+ deliver_to_bound_conversations=True,
2788
+ include_recent_inbound_messages=False,
2789
+ attachments=[
2790
+ {
2791
+ "kind": "branch_activation",
2792
+ "branch": branch_name,
2793
+ "worktree_root": str(resolved_workspace_root),
2794
+ "idea_id": resolved_idea_id,
2795
+ "latest_main_run_id": str(latest_main_run.get("run_id") or "").strip() or None,
2796
+ "next_anchor": next_anchor,
2797
+ "promote_to_head": bool(promote_to_head),
2798
+ }
2799
+ ],
2800
+ )
2801
+ return {
2802
+ "ok": True,
2803
+ "branch": branch_name,
2804
+ "worktree_root": str(resolved_workspace_root),
2805
+ "idea_id": resolved_idea_id,
2806
+ "latest_main_run_id": str(latest_main_run.get("run_id") or "").strip() or None,
2807
+ "branch_kind": branch_kind,
2808
+ "source_parent_branch": source_parent_branch,
2809
+ "idea_md_path": resolved_idea_md_path,
2810
+ "idea_draft_path": resolved_idea_draft_path,
2811
+ "workspace_mode": workspace_mode,
2812
+ "next_anchor": next_anchor,
2813
+ "promote_to_head": bool(promote_to_head),
2814
+ "worktree_created": worktree_created,
2815
+ "worktree": worktree_result,
2816
+ "artifact": artifact,
2817
+ "interaction": interaction,
2818
+ "research_state": research_state,
2819
+ }
2820
+
2821
+ def _promote_workspace_to_run_branch(
2822
+ self,
2823
+ quest_root: Path,
2824
+ *,
2825
+ run_id: str,
2826
+ idea_id: str | None,
2827
+ workspace_root: Path,
2828
+ current_branch_name: str,
2829
+ ) -> tuple[str, str | None, bool]:
2830
+ branch_kind = self._branch_kind_from_name(current_branch_name)
2831
+ if branch_kind == "paper":
2832
+ raise ValueError(
2833
+ "record_main_experiment cannot run while the active workspace is a paper branch. "
2834
+ "Return to the evidence branch or create a new run branch first."
2835
+ )
2836
+ if branch_kind == "run":
2837
+ prepare_record = self._latest_prepare_branch_record(quest_root, current_branch_name)
2838
+ parent_branch = str(prepare_record.get("parent_branch") or "").strip() or None
2839
+ return current_branch_name, parent_branch, False
2840
+
2841
+ target_branch = self._default_branch_name(quest_root, run_id=run_id, idea_id=idea_id, branch_kind="run")
2842
+ if branch_exists(quest_root, target_branch):
2843
+ raise ValueError(
2844
+ f"Run branch `{target_branch}` already exists. Reuse that run branch or choose a new `run_id`."
2845
+ )
2846
+
2847
+ ensure_branch(quest_root, target_branch, start_point=current_branch_name, checkout=False)
2848
+ run_command(["git", "switch", target_branch], cwd=workspace_root, check=True)
2849
+ self.record(
2850
+ quest_root,
2851
+ {
2852
+ "kind": "decision",
2853
+ "status": "prepared",
2854
+ "verdict": "prepared",
2855
+ "action": "prepare_branch",
2856
+ "reason": f"Materialized a dedicated main-experiment branch `{target_branch}` before durable recording.",
2857
+ "branch": target_branch,
2858
+ "run_id": run_id,
2859
+ "idea_id": idea_id,
2860
+ "branch_kind": "run",
2861
+ "parent_branch": current_branch_name,
2862
+ "start_point": current_branch_name,
2863
+ "worktree_root": str(workspace_root),
2864
+ "workspace_mode": "run",
2865
+ "source": {"kind": "system", "role": "artifact"},
2866
+ },
2867
+ checkpoint=False,
2868
+ workspace_root=workspace_root,
2869
+ )
2870
+ self.quest_service.update_research_state(
2871
+ quest_root,
2872
+ active_idea_id=idea_id,
2873
+ current_workspace_branch=target_branch,
2874
+ current_workspace_root=str(workspace_root),
2875
+ research_head_branch=target_branch,
2876
+ research_head_worktree_root=str(workspace_root),
2877
+ active_analysis_campaign_id=None,
2878
+ analysis_parent_branch=None,
2879
+ analysis_parent_worktree_root=None,
2880
+ paper_parent_branch=None,
2881
+ paper_parent_worktree_root=None,
2882
+ paper_parent_run_id=None,
2883
+ workspace_mode="run",
2884
+ last_flow_type="main_experiment_branch",
2885
+ )
2886
+ return target_branch, current_branch_name, True
2887
+
2888
+ def _ensure_active_paper_workspace(
2889
+ self,
2890
+ quest_root: Path,
2891
+ *,
2892
+ source_branch: str | None = None,
2893
+ source_run_id: str | None = None,
2894
+ source_idea_id: str | None = None,
2895
+ ) -> dict[str, Any]:
2896
+ state = self.quest_service.read_research_state(quest_root)
2897
+ current_branch_name = (
2898
+ str(state.get("current_workspace_branch") or "").strip()
2899
+ or current_branch(self._workspace_root_for(quest_root))
2900
+ )
2901
+ current_workspace_root = self._workspace_root_for(quest_root)
2902
+ if (
2903
+ str(state.get("workspace_mode") or "").strip() == "paper"
2904
+ and self._branch_kind_from_name(current_branch_name) == "paper"
2905
+ ):
2906
+ return {
2907
+ "ok": True,
2908
+ "branch": current_branch_name,
2909
+ "worktree_root": str(current_workspace_root),
2910
+ "source_branch": str(state.get("paper_parent_branch") or "").strip() or None,
2911
+ "source_run_id": str(state.get("paper_parent_run_id") or "").strip() or None,
2912
+ "source_idea_id": str(state.get("active_idea_id") or "").strip() or None,
2913
+ }
2914
+
2915
+ resolved_source_branch = (
2916
+ str(source_branch or "").strip()
2917
+ or str(state.get("paper_parent_branch") or "").strip()
2918
+ or str(state.get("current_workspace_branch") or "").strip()
2919
+ or str(state.get("research_head_branch") or "").strip()
2920
+ or current_branch(current_workspace_root)
2921
+ )
2922
+ if not resolved_source_branch:
2923
+ raise ValueError("Unable to resolve the source branch for the paper workspace.")
2924
+
2925
+ latest_main_run = self._latest_main_run_for_branch(quest_root, resolved_source_branch)
2926
+ resolved_run_id = (
2927
+ str(source_run_id or "").strip()
2928
+ or str((latest_main_run or {}).get("run_id") or "").strip()
2929
+ or None
2930
+ )
2931
+ resolved_idea_id = (
2932
+ str(source_idea_id or "").strip()
2933
+ or str((latest_main_run or {}).get("idea_id") or "").strip()
2934
+ or str(state.get("active_idea_id") or "").strip()
2935
+ or None
2936
+ )
2937
+ paper_branch = (
2938
+ self._default_branch_name(quest_root, run_id=resolved_run_id, idea_id=resolved_idea_id, branch_kind="paper")
2939
+ if resolved_run_id
2940
+ else f"paper/{slugify(resolved_source_branch, 'paper')}"
2941
+ )
2942
+ if not branch_exists(quest_root, paper_branch):
2943
+ self.prepare_branch(
2944
+ quest_root,
2945
+ run_id=resolved_run_id,
2946
+ idea_id=resolved_idea_id,
2947
+ branch=paper_branch,
2948
+ branch_kind="paper",
2949
+ create_worktree_flag=True,
2950
+ start_point=resolved_source_branch,
2951
+ )
2952
+ activated = self.activate_branch(
2953
+ quest_root,
2954
+ branch=paper_branch,
2955
+ anchor="write",
2956
+ promote_to_head=False,
2957
+ create_worktree_if_missing=True,
2958
+ )
2959
+ return {
2960
+ **activated,
2961
+ "source_branch": resolved_source_branch,
2962
+ "source_run_id": resolved_run_id,
2963
+ "source_idea_id": resolved_idea_id,
2964
+ }
2965
+
2203
2966
  def submit_idea(
2204
2967
  self,
2205
2968
  quest_root: Path,
@@ -2235,8 +2998,8 @@ class ArtifactService:
2235
2998
  if normalized_mode == "create":
2236
2999
  resolved_idea_id = str(idea_id or generate_id("idea")).strip()
2237
3000
  active_branch = (
2238
- str(state.get("research_head_branch") or "").strip()
2239
- or str(state.get("current_workspace_branch") or "").strip()
3001
+ str(state.get("current_workspace_branch") or "").strip()
3002
+ or str(state.get("research_head_branch") or "").strip()
2240
3003
  or current_branch(self._workspace_root_for(quest_root))
2241
3004
  )
2242
3005
  active_parent_branch = self._idea_parent_branch(self._latest_idea_for_branch(quest_root, active_branch))
@@ -2441,9 +3204,17 @@ class ArtifactService:
2441
3204
  raise ValueError("submit_idea(mode='revise') requires an existing active `idea_id`.")
2442
3205
  if normalized_lineage_intent:
2443
3206
  raise ValueError("submit_idea(mode='revise') does not accept `lineage_intent`; use mode='create' for new branch lineage.")
2444
- branch_name = str(state.get("research_head_branch") or f"idea/{quest_id}-{resolved_idea_id}").strip()
3207
+ branch_name = str(
3208
+ state.get("current_workspace_branch")
3209
+ or state.get("research_head_branch")
3210
+ or f"idea/{quest_id}-{resolved_idea_id}"
3211
+ ).strip()
2445
3212
  worktree_root = Path(
2446
- str(state.get("research_head_worktree_root") or canonical_worktree_root(quest_root, f"idea-{resolved_idea_id}"))
3213
+ str(
3214
+ state.get("current_workspace_root")
3215
+ or state.get("research_head_worktree_root")
3216
+ or canonical_worktree_root(quest_root, f"idea-{resolved_idea_id}")
3217
+ )
2447
3218
  )
2448
3219
  ensure_dir(worktree_root / "memory" / "ideas" / resolved_idea_id)
2449
3220
  idea_md_path = worktree_root / "memory" / "ideas" / resolved_idea_id / "idea.md"
@@ -2545,17 +3316,22 @@ class ArtifactService:
2545
3316
  checkpoint=False,
2546
3317
  workspace_root=worktree_root,
2547
3318
  )
3319
+ research_state_updates: dict[str, Any] = {
3320
+ "active_idea_id": resolved_idea_id,
3321
+ "current_workspace_branch": branch_name,
3322
+ "current_workspace_root": str(worktree_root),
3323
+ "active_idea_md_path": str(idea_md_path),
3324
+ "active_idea_draft_path": str(idea_draft_path),
3325
+ "workspace_mode": "idea",
3326
+ "last_flow_type": "idea_revision",
3327
+ }
3328
+ current_head_branch = str(state.get("research_head_branch") or "").strip()
3329
+ if not current_head_branch or current_head_branch == branch_name:
3330
+ research_state_updates["research_head_branch"] = branch_name
3331
+ research_state_updates["research_head_worktree_root"] = str(worktree_root)
2548
3332
  research_state = self.quest_service.update_research_state(
2549
3333
  quest_root,
2550
- active_idea_id=resolved_idea_id,
2551
- research_head_branch=branch_name,
2552
- research_head_worktree_root=str(worktree_root),
2553
- current_workspace_branch=branch_name,
2554
- current_workspace_root=str(worktree_root),
2555
- active_idea_md_path=str(idea_md_path),
2556
- active_idea_draft_path=str(idea_draft_path),
2557
- workspace_mode="idea",
2558
- last_flow_type="idea_revision",
3334
+ **research_state_updates,
2559
3335
  )
2560
3336
  self.quest_service.update_settings(quest_id, active_anchor="experiment")
2561
3337
  checkpoint_result = self._checkpoint_with_optional_push(
@@ -2712,14 +3488,21 @@ class ArtifactService:
2712
3488
  baseline_id: str | None = None,
2713
3489
  baseline_variant_id: str | None = None,
2714
3490
  evaluation_summary: dict[str, Any] | None = None,
3491
+ strict_metric_contract: bool = False,
2715
3492
  ) -> dict[str, Any]:
2716
3493
  self._require_baseline_gate_open(quest_root, action="record_main_experiment")
2717
3494
  state = self.quest_service.read_research_state(quest_root)
2718
- if str(state.get("workspace_mode") or "").strip() == "analysis":
3495
+ workspace_mode = str(state.get("workspace_mode") or "").strip()
3496
+ if workspace_mode == "analysis":
2719
3497
  raise ValueError(
2720
3498
  "record_main_experiment cannot run while the active workspace is an analysis slice. "
2721
3499
  "Finish or close the analysis campaign first."
2722
3500
  )
3501
+ if workspace_mode == "paper":
3502
+ raise ValueError(
3503
+ "record_main_experiment cannot run while the active workspace is a paper branch. "
3504
+ "Return to the source evidence branch or create a new run branch first."
3505
+ )
2723
3506
 
2724
3507
  run_identifier = str(run_id or "").strip()
2725
3508
  if not run_identifier:
@@ -2727,7 +3510,18 @@ class ArtifactService:
2727
3510
 
2728
3511
  active_idea_id = str(state.get("active_idea_id") or "").strip() or None
2729
3512
  workspace_root = self._workspace_root_for(quest_root)
2730
- branch_name = str(state.get("research_head_branch") or current_branch(workspace_root)).strip()
3513
+ current_branch_name = str(
3514
+ state.get("current_workspace_branch")
3515
+ or state.get("research_head_branch")
3516
+ or current_branch(workspace_root)
3517
+ ).strip()
3518
+ branch_name, parent_branch, auto_promoted_run_branch = self._promote_workspace_to_run_branch(
3519
+ quest_root,
3520
+ run_id=run_identifier,
3521
+ idea_id=active_idea_id,
3522
+ workspace_root=workspace_root,
3523
+ current_branch_name=current_branch_name,
3524
+ )
2731
3525
  attachment = self._active_baseline_attachment(quest_root, workspace_root=workspace_root)
2732
3526
  baseline_entry = dict(attachment.get("entry") or {}) if isinstance(attachment, dict) else {}
2733
3527
  selected_variant = dict(attachment.get("selected_variant") or {}) if isinstance(attachment, dict) else {}
@@ -2761,12 +3555,24 @@ class ArtifactService:
2761
3555
  metric_contract or baseline_entry.get("metric_contract"),
2762
3556
  baseline_id=resolved_baseline_id,
2763
3557
  metrics_summary=normalized_metrics_summary,
3558
+ metric_rows=normalized_metric_rows,
2764
3559
  primary_metric=baseline_entry.get("primary_metric"),
2765
3560
  baseline_variants=baseline_entry.get("baseline_variants"),
2766
3561
  )
3562
+ baseline_contract_payload = self._load_metric_contract_payload(quest_root, metric_contract_json_rel_path)
3563
+ metric_validation: dict[str, Any] | None = None
3564
+ if strict_metric_contract:
3565
+ metric_validation = validate_main_experiment_against_baseline_contract(
3566
+ baseline_contract_payload=baseline_contract_payload,
3567
+ run_metric_contract=effective_metric_contract,
3568
+ metric_rows=normalized_metric_rows,
3569
+ metrics_summary=normalized_metrics_summary,
3570
+ dataset_scope=dataset_scope,
3571
+ )
2767
3572
  baseline_metrics = selected_baseline_metrics(baseline_entry, resolved_variant_id)
2768
3573
  comparisons = compare_with_baseline(
2769
3574
  metrics_summary=normalized_metrics_summary,
3575
+ metric_rows=normalized_metric_rows,
2770
3576
  metric_contract=effective_metric_contract,
2771
3577
  baseline_metrics=baseline_metrics,
2772
3578
  )
@@ -2827,6 +3633,7 @@ class ArtifactService:
2827
3633
  "",
2828
3634
  f"- Run id: `{run_identifier}`",
2829
3635
  f"- Branch: `{branch_name}`",
3636
+ f"- Parent branch: `{parent_branch or 'none'}`",
2830
3637
  f"- Worktree: `{workspace_root}`",
2831
3638
  f"- Idea: `{active_idea_id or 'none'}`",
2832
3639
  f"- Baseline: `{resolved_baseline_id or 'none'}`",
@@ -2924,6 +3731,7 @@ class ArtifactService:
2924
3731
  "verdict": verdict,
2925
3732
  "idea_id": active_idea_id,
2926
3733
  "branch": branch_name,
3734
+ "parent_branch": parent_branch,
2927
3735
  "worktree_root": str(workspace_root),
2928
3736
  "head_commit": head_commit(workspace_root),
2929
3737
  "baseline_ref": {
@@ -2956,6 +3764,7 @@ class ArtifactService:
2956
3764
  "evidence_paths": resolved_evidence_paths,
2957
3765
  "files_changed": resolved_changed_files,
2958
3766
  "run_md_path": str(run_md_path),
3767
+ "metric_validation": metric_validation,
2959
3768
  }
2960
3769
  write_json(result_json_path, result_payload)
2961
3770
 
@@ -2970,6 +3779,7 @@ class ArtifactService:
2970
3779
  "reason": conclusion.strip() or progress_eval.get("reason") or "Main experiment result recorded.",
2971
3780
  "idea_id": active_idea_id,
2972
3781
  "branch": branch_name,
3782
+ "parent_branch": parent_branch,
2973
3783
  "worktree_root": str(workspace_root),
2974
3784
  "worktree_rel_path": self._workspace_relative(quest_root, workspace_root),
2975
3785
  "flow_type": "main_experiment",
@@ -2989,6 +3799,7 @@ class ArtifactService:
2989
3799
  "breakthrough_level": progress_eval.get("breakthrough_level"),
2990
3800
  "need_research_paper": delivery_policy.get("need_research_paper"),
2991
3801
  "recommended_next_route": delivery_policy.get("recommended_next_route"),
3802
+ "auto_promoted_run_branch": auto_promoted_run_branch,
2992
3803
  "changed_file_count": len(resolved_changed_files),
2993
3804
  "evidence_count": len(resolved_evidence_paths),
2994
3805
  "evaluation_summary": normalized_evaluation_summary,
@@ -3008,6 +3819,7 @@ class ArtifactService:
3008
3819
  },
3009
3820
  "progress_eval": progress_eval,
3010
3821
  "evaluation_summary": normalized_evaluation_summary,
3822
+ "metric_validation": metric_validation,
3011
3823
  "files_changed": resolved_changed_files,
3012
3824
  "evidence_paths": resolved_evidence_paths,
3013
3825
  "verdict": verdict,
@@ -3049,6 +3861,22 @@ class ArtifactService:
3049
3861
  ],
3050
3862
  )
3051
3863
  self.quest_service.update_settings(self._quest_id(quest_root), active_anchor="decision")
3864
+ research_state = self.quest_service.update_research_state(
3865
+ quest_root,
3866
+ active_idea_id=active_idea_id,
3867
+ current_workspace_branch=branch_name,
3868
+ current_workspace_root=str(workspace_root),
3869
+ research_head_branch=branch_name,
3870
+ research_head_worktree_root=str(workspace_root),
3871
+ active_analysis_campaign_id=None,
3872
+ analysis_parent_branch=None,
3873
+ analysis_parent_worktree_root=None,
3874
+ paper_parent_branch=None,
3875
+ paper_parent_worktree_root=None,
3876
+ paper_parent_run_id=None,
3877
+ workspace_mode="run",
3878
+ last_flow_type="main_experiment_recorded",
3879
+ )
3052
3880
  return {
3053
3881
  "ok": True,
3054
3882
  "guidance": artifact.get("guidance"),
@@ -3058,10 +3886,14 @@ class ArtifactService:
3058
3886
  "suggested_artifact_calls": artifact.get("suggested_artifact_calls"),
3059
3887
  "next_instruction": artifact.get("next_instruction"),
3060
3888
  "run_id": run_identifier,
3889
+ "branch": branch_name,
3890
+ "parent_branch": parent_branch,
3891
+ "auto_promoted_run_branch": auto_promoted_run_branch,
3061
3892
  "run_md_path": str(run_md_path),
3062
3893
  "result_json_path": str(result_json_path),
3063
3894
  "artifact": artifact,
3064
3895
  "interaction": interaction,
3896
+ "research_state": research_state,
3065
3897
  "metrics_summary": normalized_metrics_summary,
3066
3898
  "baseline_comparisons": {
3067
3899
  key: value for key, value in comparisons.items() if key != "primary"
@@ -3069,6 +3901,7 @@ class ArtifactService:
3069
3901
  "progress_eval": progress_eval,
3070
3902
  "evaluation_summary": normalized_evaluation_summary,
3071
3903
  "delivery_policy": delivery_policy,
3904
+ "metric_validation": metric_validation,
3072
3905
  }
3073
3906
 
3074
3907
  def create_analysis_campaign(
@@ -3560,11 +4393,31 @@ class ArtifactService:
3560
4393
  if normalized_mode not in {"candidate", "select", "revise"}:
3561
4394
  raise ValueError("submit_paper_outline mode must be `candidate`, `select`, or `revise`.")
3562
4395
 
3563
- existing_selected = read_json(self._paper_selected_outline_path(quest_root), {})
4396
+ paper_context = (
4397
+ self._ensure_active_paper_workspace(quest_root)
4398
+ if normalized_mode in {"select", "revise"}
4399
+ else {
4400
+ "worktree_root": str(self._workspace_root_for(quest_root)),
4401
+ "branch": str(self.quest_service.read_research_state(quest_root).get("current_workspace_branch") or "").strip() or None,
4402
+ }
4403
+ )
4404
+ workspace_root = Path(str(paper_context.get("worktree_root") or self._workspace_root_for(quest_root)))
4405
+ paper_root = (
4406
+ ensure_dir(workspace_root / "paper")
4407
+ if normalized_mode in {"select", "revise"}
4408
+ else self._paper_root(quest_root, workspace_root=workspace_root, create=True)
4409
+ )
4410
+ if normalized_mode in {"select", "revise"}:
4411
+ selected_outline_path = paper_root / "selected_outline.json"
4412
+ else:
4413
+ selected_outline_path = self._paper_selected_outline_path(quest_root, workspace_root=workspace_root)
4414
+ existing_selected = read_json(selected_outline_path, {})
4415
+ if not isinstance(existing_selected, dict) or not existing_selected:
4416
+ existing_selected = read_json(quest_root / "paper" / "selected_outline.json", {})
3564
4417
  existing_selected = existing_selected if isinstance(existing_selected, dict) else {}
3565
4418
  if normalized_mode == "candidate":
3566
4419
  resolved_outline_id = str(outline_id or self._next_paper_outline_id(quest_root)).strip()
3567
- candidate_path = self._paper_outline_candidates_root(quest_root) / f"{resolved_outline_id}.json"
4420
+ candidate_path = self._paper_outline_candidates_root(quest_root, workspace_root=workspace_root) / f"{resolved_outline_id}.json"
3568
4421
  existing = read_json(candidate_path, {})
3569
4422
  existing = existing if isinstance(existing, dict) else {}
3570
4423
  record = self._normalize_paper_outline_record(
@@ -3599,7 +4452,7 @@ class ArtifactService:
3599
4452
  },
3600
4453
  },
3601
4454
  checkpoint=False,
3602
- workspace_root=self._workspace_root_for(quest_root),
4455
+ workspace_root=workspace_root,
3603
4456
  )
3604
4457
  return {
3605
4458
  "ok": True,
@@ -3613,8 +4466,13 @@ class ArtifactService:
3613
4466
  source_outline_id = str(outline_id or existing_selected.get("outline_id") or "").strip()
3614
4467
  if not source_outline_id:
3615
4468
  raise ValueError("submit_paper_outline(select/revise) requires an existing `outline_id` or selected outline.")
3616
- source_candidate_path = self._paper_outline_candidates_root(quest_root) / f"{source_outline_id}.json"
4469
+ source_candidate_path = paper_root / "outlines" / "candidates" / f"{source_outline_id}.json"
3617
4470
  source_record = read_json(source_candidate_path, {})
4471
+ if not isinstance(source_record, dict) or not source_record:
4472
+ fallback_candidate_path = quest_root / "paper" / "outlines" / "candidates" / f"{source_outline_id}.json"
4473
+ source_record = read_json(fallback_candidate_path, {})
4474
+ if isinstance(source_record, dict) and source_record:
4475
+ source_candidate_path = fallback_candidate_path
3618
4476
  if not isinstance(source_record, dict) or not source_record:
3619
4477
  source_record = existing_selected if str(existing_selected.get("outline_id") or "").strip() == source_outline_id else {}
3620
4478
  if not source_record:
@@ -3632,7 +4490,6 @@ class ArtifactService:
3632
4490
  created_at=str(source_record.get("created_at") or "") or None,
3633
4491
  )
3634
4492
 
3635
- selected_outline_path = self._paper_selected_outline_path(quest_root)
3636
4493
  write_json(selected_outline_path, resolved_record)
3637
4494
  if source_candidate_path.exists():
3638
4495
  source_record["status"] = "selected" if normalized_mode == "select" else "revised"
@@ -3640,10 +4497,10 @@ class ArtifactService:
3640
4497
  write_json(source_candidate_path, source_record)
3641
4498
  revised_outline_path = None
3642
4499
  if normalized_mode == "revise":
3643
- revised_outline_path = self._paper_outline_revisions_root(quest_root) / f"{source_outline_id}.json"
4500
+ revised_outline_path = ensure_dir(paper_root / "outlines" / "revisions") / f"{source_outline_id}.json"
3644
4501
  write_json(revised_outline_path, resolved_record)
3645
4502
 
3646
- outline_selection_path = self._paper_outline_selection_path(quest_root)
4503
+ outline_selection_path = paper_root / "outline_selection.md"
3647
4504
  action_label = "selected" if normalized_mode == "select" else "revised"
3648
4505
  selection_lines = [
3649
4506
  f"# Outline {normalized_mode.capitalize()}",
@@ -3682,7 +4539,7 @@ class ArtifactService:
3682
4539
  },
3683
4540
  },
3684
4541
  checkpoint=False,
3685
- workspace_root=self._workspace_root_for(quest_root),
4542
+ workspace_root=workspace_root,
3686
4543
  )
3687
4544
  return {
3688
4545
  "ok": True,
@@ -3710,24 +4567,44 @@ class ArtifactService:
3710
4567
  pdf_path: str | None = None,
3711
4568
  latex_root_path: str | None = None,
3712
4569
  ) -> dict[str, Any]:
3713
- selected_outline_path = self._paper_selected_outline_path(quest_root)
4570
+ paper_context = self._ensure_active_paper_workspace(quest_root)
4571
+ workspace_root = Path(str(paper_context.get("worktree_root") or self._workspace_root_for(quest_root)))
4572
+ paper_root = self._paper_root(quest_root, workspace_root=workspace_root, create=True)
4573
+ selected_outline_path = self._paper_selected_outline_path(quest_root, workspace_root=workspace_root)
3714
4574
  selected_outline = read_json(selected_outline_path, {})
4575
+ if not isinstance(selected_outline, dict) or not selected_outline:
4576
+ fallback_selected_outline_path = quest_root / "paper" / "selected_outline.json"
4577
+ selected_outline = read_json(fallback_selected_outline_path, {})
4578
+ if isinstance(selected_outline, dict) and selected_outline:
4579
+ selected_outline_path = fallback_selected_outline_path
3715
4580
  selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
3716
4581
  if not selected_outline and not str(outline_path or "").strip():
3717
4582
  raise ValueError("submit_paper_bundle requires a selected outline or explicit `outline_path`.")
3718
4583
 
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
- )
4584
+ manifest_path = self._paper_bundle_manifest_path(quest_root, workspace_root=workspace_root)
4585
+ baseline_inventory = self._write_paper_baseline_inventory(quest_root, workspace_root=workspace_root)
4586
+ baseline_inventory_path = self._paper_baseline_inventory_path(quest_root, workspace_root=workspace_root)
4587
+ source_branch = str(paper_context.get("source_branch") or "").strip() or None
4588
+ paper_branch = str(paper_context.get("branch") or "").strip() or current_branch(workspace_root)
4589
+ source_run_id = str(paper_context.get("source_run_id") or "").strip() or None
4590
+ source_idea_id = str(paper_context.get("source_idea_id") or "").strip() or None
4591
+ paper_manifest_rel = self._workspace_relative(quest_root, manifest_path) or "paper/paper_bundle_manifest.json"
4592
+ paper_inventory_rel = self._workspace_relative(quest_root, baseline_inventory_path) or "paper/baseline_inventory.json"
3726
4593
  open_source_manifest = self._ensure_open_source_prep(
3727
4594
  quest_root,
4595
+ workspace_root=workspace_root,
3728
4596
  source_branch=source_branch,
3729
- source_bundle_manifest_path="paper/paper_bundle_manifest.json",
3730
- baseline_inventory_path="paper/baseline_inventory.json",
4597
+ source_bundle_manifest_path=paper_manifest_rel,
4598
+ baseline_inventory_path=paper_inventory_rel,
4599
+ )
4600
+ default_draft_path = self._workspace_relative(quest_root, paper_root / "draft.md") or "paper/draft.md"
4601
+ default_writing_plan_path = self._workspace_relative(quest_root, paper_root / "writing_plan.md") or "paper/writing_plan.md"
4602
+ default_references_path = self._workspace_relative(quest_root, paper_root / "references.bib") or "paper/references.bib"
4603
+ default_claim_map_path = (
4604
+ self._workspace_relative(quest_root, paper_root / "claim_evidence_map.json") or "paper/claim_evidence_map.json"
4605
+ )
4606
+ default_compile_report_path = (
4607
+ self._workspace_relative(quest_root, paper_root / "build" / "compile_report.json") or "paper/build/compile_report.json"
3731
4608
  )
3732
4609
  manifest = {
3733
4610
  "schema_version": 1,
@@ -3740,15 +4617,23 @@ class ArtifactService:
3740
4617
  or "paper",
3741
4618
  "summary": str(summary or "").strip() or None,
3742
4619
  "outline_path": str(outline_path or selected_outline_path).strip() or None,
3743
- "draft_path": str(draft_path or "paper/draft.md").strip() or None,
3744
- "writing_plan_path": str(writing_plan_path or "paper/writing_plan.md").strip() or None,
3745
- "references_path": str(references_path or "paper/references.bib").strip() or None,
3746
- "claim_evidence_map_path": str(claim_evidence_map_path or "paper/claim_evidence_map.json").strip() or None,
3747
- "compile_report_path": str(compile_report_path or "paper/build/compile_report.json").strip() or None,
4620
+ "paper_branch": paper_branch,
4621
+ "source_branch": source_branch,
4622
+ "source_run_id": source_run_id,
4623
+ "source_idea_id": source_idea_id,
4624
+ "draft_path": str(draft_path or default_draft_path).strip() or None,
4625
+ "writing_plan_path": str(writing_plan_path or default_writing_plan_path).strip() or None,
4626
+ "references_path": str(references_path or default_references_path).strip() or None,
4627
+ "claim_evidence_map_path": str(claim_evidence_map_path or default_claim_map_path).strip() or None,
4628
+ "compile_report_path": str(compile_report_path or default_compile_report_path).strip() or None,
3748
4629
  "pdf_path": str(pdf_path or "").strip() or None,
3749
4630
  "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",
4631
+ "baseline_inventory_path": paper_inventory_rel,
4632
+ "open_source_manifest_path": self._workspace_relative(
4633
+ quest_root,
4634
+ self._open_source_manifest_path(quest_root, workspace_root=workspace_root),
4635
+ )
4636
+ or "release/open_source/manifest.json",
3752
4637
  "open_source_cleanup_plan_path": str(open_source_manifest.get("cleanup_plan_path") or "").strip()
3753
4638
  or "release/open_source/cleanup_plan.md",
3754
4639
  "selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
@@ -3773,24 +4658,27 @@ class ArtifactService:
3773
4658
  "draft_path": manifest.get("draft_path"),
3774
4659
  "pdf_path": manifest.get("pdf_path"),
3775
4660
  "baseline_inventory_path": str(baseline_inventory_path),
3776
- "open_source_manifest_path": str(self._open_source_manifest_path(quest_root)),
4661
+ "open_source_manifest_path": str(self._open_source_manifest_path(quest_root, workspace_root=workspace_root)),
3777
4662
  },
3778
4663
  "details": {
3779
4664
  "title": manifest.get("title"),
3780
4665
  "selected_outline_ref": manifest.get("selected_outline_ref"),
3781
4666
  "baseline_inventory_count": len(baseline_inventory.get("supplementary_baselines") or []),
3782
4667
  "open_source_status": open_source_manifest.get("status"),
4668
+ "paper_branch": paper_branch,
4669
+ "source_branch": source_branch,
4670
+ "source_run_id": source_run_id,
3783
4671
  },
3784
4672
  },
3785
4673
  checkpoint=False,
3786
- workspace_root=self._workspace_root_for(quest_root),
4674
+ workspace_root=workspace_root,
3787
4675
  )
3788
4676
  return {
3789
4677
  "ok": True,
3790
4678
  "manifest_path": str(manifest_path),
3791
4679
  "manifest": manifest,
3792
4680
  "baseline_inventory_path": str(baseline_inventory_path),
3793
- "open_source_manifest_path": str(self._open_source_manifest_path(quest_root)),
4681
+ "open_source_manifest_path": str(self._open_source_manifest_path(quest_root, workspace_root=workspace_root)),
3794
4682
  "artifact": artifact,
3795
4683
  }
3796
4684
 
@@ -4229,17 +5117,42 @@ class ArtifactService:
4229
5117
  message=f"analysis: summarize {campaign_id}",
4230
5118
  )
4231
5119
  restored_idea_id = self._latest_branch_idea_id(quest_root, parent_branch) or str(manifest.get("active_idea_id") or "").strip() or None
4232
- research_state = self.quest_service.update_research_state(
5120
+ startup_contract = self._startup_contract(quest_root)
5121
+ raw_need_research_paper = startup_contract.get("need_research_paper")
5122
+ need_research_paper = raw_need_research_paper if isinstance(raw_need_research_paper, bool) else True
5123
+ base_research_state = self.quest_service.update_research_state(
4233
5124
  quest_root,
4234
5125
  active_idea_id=restored_idea_id,
4235
5126
  active_analysis_campaign_id=None,
5127
+ analysis_parent_branch=None,
5128
+ analysis_parent_worktree_root=None,
5129
+ paper_parent_branch=None,
5130
+ paper_parent_worktree_root=None,
5131
+ paper_parent_run_id=None,
4236
5132
  next_pending_slice_id=None,
4237
5133
  current_workspace_branch=parent_branch,
4238
5134
  current_workspace_root=str(parent_worktree_root),
4239
- workspace_mode="idea",
5135
+ workspace_mode="run" if self._branch_kind_from_name(parent_branch) == "run" else "idea",
4240
5136
  last_flow_type="analysis_campaign_complete",
4241
5137
  )
4242
- self.quest_service.update_settings(self._quest_id(quest_root), active_anchor="decision")
5138
+ writing_workspace: dict[str, Any] | None = None
5139
+ if need_research_paper:
5140
+ try:
5141
+ writing_workspace = self._ensure_active_paper_workspace(
5142
+ quest_root,
5143
+ source_branch=parent_branch,
5144
+ source_run_id=str(manifest.get("parent_run_id") or "").strip() or None,
5145
+ source_idea_id=restored_idea_id,
5146
+ )
5147
+ except Exception:
5148
+ writing_workspace = None
5149
+
5150
+ if writing_workspace:
5151
+ research_state = self.quest_service.read_research_state(quest_root)
5152
+ self.quest_service.update_settings(self._quest_id(quest_root), active_anchor="write")
5153
+ else:
5154
+ research_state = base_research_state
5155
+ self.quest_service.update_settings(self._quest_id(quest_root), active_anchor="decision")
4243
5156
  interaction = self.interact(
4244
5157
  quest_root,
4245
5158
  kind="milestone",
@@ -4248,7 +5161,15 @@ class ArtifactService:
4248
5161
  f"- Returned to parent branch: `{parent_branch}`\n"
4249
5162
  f"- Parent worktree: `{parent_worktree_root}`\n"
4250
5163
  f"- Analysis summary: `{summary_path}`\n"
4251
- "Use the completed analysis evidence to make the next durable route decision."
5164
+ + (
5165
+ (
5166
+ f"- Writing branch: `{writing_workspace.get('branch')}`\n"
5167
+ f"- Writing worktree: `{writing_workspace.get('worktree_root')}`\n"
5168
+ "Writing is now active on the dedicated paper branch."
5169
+ )
5170
+ if writing_workspace
5171
+ else "Use the completed analysis evidence to make the next durable route decision."
5172
+ )
4252
5173
  ),
4253
5174
  deliver_to_bound_conversations=True,
4254
5175
  include_recent_inbound_messages=False,
@@ -4259,6 +5180,8 @@ class ArtifactService:
4259
5180
  "parent_branch": parent_branch,
4260
5181
  "parent_worktree_root": str(parent_worktree_root),
4261
5182
  "summary_path": str(summary_path),
5183
+ "writing_branch": writing_workspace.get("branch") if writing_workspace else None,
5184
+ "writing_worktree_root": writing_workspace.get("worktree_root") if writing_workspace else None,
4262
5185
  }
4263
5186
  ],
4264
5187
  )
@@ -4284,6 +5207,8 @@ class ArtifactService:
4284
5207
  "completed": True,
4285
5208
  "returned_to_branch": parent_branch,
4286
5209
  "returned_to_worktree_root": str(parent_worktree_root),
5210
+ "writing_branch": writing_workspace.get("branch") if writing_workspace else None,
5211
+ "writing_worktree_root": writing_workspace.get("worktree_root") if writing_workspace else None,
4287
5212
  }
4288
5213
 
4289
5214
  def publish_baseline(self, quest_root: Path, payload: dict) -> dict:
@@ -4348,6 +5273,7 @@ class ArtifactService:
4348
5273
  metrics_summary: dict[str, Any] | None = None,
4349
5274
  primary_metric: dict[str, Any] | None = None,
4350
5275
  auto_advance: bool = True,
5276
+ strict_metric_contract: bool = False,
4351
5277
  ) -> dict[str, Any]:
4352
5278
  resolved = self._resolve_baseline_path(quest_root, baseline_path, baseline_id=baseline_id)
4353
5279
  resolved_baseline_id = str(resolved["baseline_id"] or "").strip()
@@ -4439,6 +5365,72 @@ class ArtifactService:
4439
5365
  or ""
4440
5366
  ).strip() or None
4441
5367
 
5368
+ source_metrics_summary = (
5369
+ selected_variant.get("metrics_summary")
5370
+ if isinstance(selected_variant, dict) and selected_variant.get("metrics_summary") is not None
5371
+ else entry.get("metrics_summary")
5372
+ )
5373
+ canonical_baseline = (
5374
+ validate_baseline_metric_contract_submission(
5375
+ metric_contract=entry.get("metric_contract"),
5376
+ metrics_summary=source_metrics_summary,
5377
+ primary_metric=entry.get("primary_metric"),
5378
+ )
5379
+ if strict_metric_contract
5380
+ else canonicalize_baseline_submission(
5381
+ metric_contract=entry.get("metric_contract"),
5382
+ metrics_summary=source_metrics_summary,
5383
+ primary_metric=entry.get("primary_metric"),
5384
+ )
5385
+ )
5386
+ entry = {
5387
+ **entry,
5388
+ "metrics_summary": canonical_baseline["metrics_summary"],
5389
+ "metric_contract": canonical_baseline["metric_contract"],
5390
+ "metric_details": canonical_baseline["metric_details"],
5391
+ }
5392
+ if isinstance(selected_variant, dict):
5393
+ selected_variant = {
5394
+ **selected_variant,
5395
+ "metrics_summary": canonical_baseline["metrics_summary"],
5396
+ }
5397
+ if isinstance(entry.get("baseline_variants"), list):
5398
+ entry["baseline_variants"] = [
5399
+ (
5400
+ {
5401
+ **variant,
5402
+ "metrics_summary": canonical_baseline["metrics_summary"],
5403
+ }
5404
+ if isinstance(variant, dict)
5405
+ and str(variant.get("variant_id") or "").strip() == str(resolved_variant_id or "").strip()
5406
+ else variant
5407
+ )
5408
+ for variant in entry.get("baseline_variants", [])
5409
+ ]
5410
+ primary_metric_id = str(
5411
+ (entry.get("primary_metric") or {}).get("metric_id")
5412
+ or (entry.get("primary_metric") or {}).get("name")
5413
+ or (entry.get("primary_metric") or {}).get("id")
5414
+ or (canonical_baseline["metric_contract"] or {}).get("primary_metric_id")
5415
+ or ""
5416
+ ).strip()
5417
+ if primary_metric_id and primary_metric_id in canonical_baseline["metrics_summary"]:
5418
+ primary_metric_meta = next(
5419
+ (
5420
+ item
5421
+ for item in (canonical_baseline["metric_contract"] or {}).get("metrics", [])
5422
+ if isinstance(item, dict) and str(item.get("metric_id") or "").strip() == primary_metric_id
5423
+ ),
5424
+ {},
5425
+ )
5426
+ entry["primary_metric"] = {
5427
+ **(dict(entry.get("primary_metric") or {}) if isinstance(entry.get("primary_metric"), dict) else {}),
5428
+ "metric_id": primary_metric_id,
5429
+ "value": canonical_baseline["metrics_summary"][primary_metric_id],
5430
+ "direction": primary_metric_meta.get("direction")
5431
+ or (entry.get("primary_metric") or {}).get("direction"),
5432
+ }
5433
+
4442
5434
  metric_contract_json = self._write_baseline_metric_contract_json(
4443
5435
  quest_root,
4444
5436
  baseline_root=resolved_root,
@@ -4540,6 +5532,7 @@ class ArtifactService:
4540
5532
  "artifact": artifact,
4541
5533
  "baseline_registry_entry": registry_entry,
4542
5534
  "snapshot": self.quest_service.snapshot(self._quest_id(quest_root)),
5535
+ "metric_details": canonical_baseline["metric_details"],
4543
5536
  "legacy_guidance": "Baseline gate confirmed. Idea selection is now the default next anchor.",
4544
5537
  }
4545
5538
 
@@ -5159,6 +6152,8 @@ class ArtifactService:
5159
6152
  return f"idea/{quest_id}-{idea_id}"
5160
6153
  if branch_kind == "quest":
5161
6154
  return f"quest/{quest_id}"
6155
+ if branch_kind == "paper":
6156
+ return f"paper/{run_id or generate_id('paper')}"
5162
6157
  return f"run/{run_id or generate_id('run')}"
5163
6158
 
5164
6159
  def _bound_conversations(self, quest_root: Path) -> list[str]:
@@ -5187,7 +6182,14 @@ class ArtifactService:
5187
6182
  return targets
5188
6183
 
5189
6184
  def _connectors_config(self) -> dict[str, Any]:
5190
- return ConfigManager(self.home).load_named_normalized("connectors")
6185
+ manager = ConfigManager(self.home)
6186
+ connectors = manager.load_named_normalized("connectors")
6187
+ for name, config in list(connectors.items()):
6188
+ if str(name).startswith("_") or not isinstance(config, dict):
6189
+ continue
6190
+ if not manager.is_connector_system_enabled(str(name)):
6191
+ config["enabled"] = False
6192
+ return connectors
5191
6193
 
5192
6194
  @staticmethod
5193
6195
  def _delivery_policy(connectors: dict[str, Any]) -> str: