@researai/deepscientist 1.5.7 → 1.5.9

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 (156) hide show
  1. package/LICENSE +186 -21
  2. package/README.md +8 -4
  3. package/bin/ds.js +224 -9
  4. package/docs/en/00_QUICK_START.md +2 -2
  5. package/docs/en/07_MEMORY_AND_MCP.md +40 -3
  6. package/docs/en/99_ACKNOWLEDGEMENTS.md +1 -0
  7. package/docs/zh/00_QUICK_START.md +2 -2
  8. package/docs/zh/07_MEMORY_AND_MCP.md +40 -3
  9. package/docs/zh/99_ACKNOWLEDGEMENTS.md +1 -0
  10. package/install.sh +34 -0
  11. package/package.json +2 -2
  12. package/pyproject.toml +2 -2
  13. package/src/deepscientist/__init__.py +1 -1
  14. package/src/deepscientist/acp/envelope.py +1 -0
  15. package/src/deepscientist/artifact/metrics.py +814 -83
  16. package/src/deepscientist/artifact/schemas.py +1 -0
  17. package/src/deepscientist/artifact/service.py +2001 -229
  18. package/src/deepscientist/bash_exec/monitor.py +1 -1
  19. package/src/deepscientist/bash_exec/service.py +17 -9
  20. package/src/deepscientist/channels/qq.py +17 -0
  21. package/src/deepscientist/channels/relay.py +16 -0
  22. package/src/deepscientist/config/models.py +6 -0
  23. package/src/deepscientist/config/service.py +70 -2
  24. package/src/deepscientist/daemon/api/handlers.py +414 -14
  25. package/src/deepscientist/daemon/api/router.py +4 -0
  26. package/src/deepscientist/daemon/app.py +292 -21
  27. package/src/deepscientist/gitops/diff.py +6 -10
  28. package/src/deepscientist/mcp/server.py +191 -40
  29. package/src/deepscientist/prompts/builder.py +65 -19
  30. package/src/deepscientist/quest/node_traces.py +129 -2
  31. package/src/deepscientist/quest/service.py +140 -34
  32. package/src/deepscientist/quest/stage_views.py +175 -33
  33. package/src/deepscientist/registries/baseline.py +56 -4
  34. package/src/deepscientist/runners/codex.py +1 -1
  35. package/src/prompts/connectors/qq.md +1 -1
  36. package/src/prompts/contracts/shared_interaction.md +14 -0
  37. package/src/prompts/system.md +113 -32
  38. package/src/skills/analysis-campaign/SKILL.md +10 -14
  39. package/src/skills/baseline/SKILL.md +51 -38
  40. package/src/skills/baseline/references/baseline-plan-template.md +2 -0
  41. package/src/skills/decision/SKILL.md +12 -8
  42. package/src/skills/experiment/SKILL.md +28 -16
  43. package/src/skills/experiment/references/main-experiment-plan-template.md +2 -0
  44. package/src/skills/figure-polish/SKILL.md +1 -0
  45. package/src/skills/finalize/SKILL.md +3 -8
  46. package/src/skills/idea/SKILL.md +18 -8
  47. package/src/skills/idea/references/literature-survey-template.md +24 -0
  48. package/src/skills/idea/references/related-work-playbook.md +4 -0
  49. package/src/skills/idea/references/selection-gate.md +9 -0
  50. package/src/skills/intake-audit/SKILL.md +2 -8
  51. package/src/skills/rebuttal/SKILL.md +2 -8
  52. package/src/skills/review/SKILL.md +2 -8
  53. package/src/skills/scout/SKILL.md +2 -8
  54. package/src/skills/write/SKILL.md +53 -17
  55. package/src/skills/write/templates/DEEPSCIENTIST_NOTES.md +21 -0
  56. package/src/skills/write/templates/README.md +408 -0
  57. package/src/skills/write/templates/UPSTREAM_LICENSE.txt +21 -0
  58. package/src/skills/write/templates/aaai2026/README.md +534 -0
  59. package/src/skills/write/templates/aaai2026/aaai2026-unified-supp.tex +144 -0
  60. package/src/skills/write/templates/aaai2026/aaai2026-unified-template.tex +952 -0
  61. package/src/skills/write/templates/aaai2026/aaai2026.bib +111 -0
  62. package/src/skills/write/templates/aaai2026/aaai2026.bst +1493 -0
  63. package/src/skills/write/templates/aaai2026/aaai2026.sty +315 -0
  64. package/src/skills/write/templates/acl/README.md +50 -0
  65. package/src/skills/write/templates/acl/acl.sty +312 -0
  66. package/src/skills/write/templates/acl/acl_latex.tex +377 -0
  67. package/src/skills/write/templates/acl/acl_lualatex.tex +101 -0
  68. package/src/skills/write/templates/acl/acl_natbib.bst +1940 -0
  69. package/src/skills/write/templates/acl/anthology.bib.txt +26 -0
  70. package/src/skills/write/templates/acl/custom.bib +70 -0
  71. package/src/skills/write/templates/acl/formatting.md +326 -0
  72. package/src/skills/write/templates/asplos2027/main.tex +459 -0
  73. package/src/skills/write/templates/asplos2027/references.bib +135 -0
  74. package/src/skills/write/templates/colm2025/README.md +3 -0
  75. package/src/skills/write/templates/colm2025/colm2025_conference.bib +11 -0
  76. package/src/skills/write/templates/colm2025/colm2025_conference.bst +1440 -0
  77. package/src/skills/write/templates/colm2025/colm2025_conference.sty +218 -0
  78. package/src/skills/write/templates/colm2025/colm2025_conference.tex +305 -0
  79. package/src/skills/write/templates/colm2025/fancyhdr.sty +485 -0
  80. package/src/skills/write/templates/colm2025/math_commands.tex +508 -0
  81. package/src/skills/write/templates/colm2025/natbib.sty +1246 -0
  82. package/src/skills/write/templates/iclr2026/fancyhdr.sty +485 -0
  83. package/src/skills/write/templates/iclr2026/iclr2026_conference.bib +24 -0
  84. package/src/skills/write/templates/iclr2026/iclr2026_conference.bst +1440 -0
  85. package/src/skills/write/templates/iclr2026/iclr2026_conference.sty +246 -0
  86. package/src/skills/write/templates/iclr2026/iclr2026_conference.tex +414 -0
  87. package/src/skills/write/templates/iclr2026/math_commands.tex +508 -0
  88. package/src/skills/write/templates/iclr2026/natbib.sty +1246 -0
  89. package/src/skills/write/templates/icml2026/algorithm.sty +79 -0
  90. package/src/skills/write/templates/icml2026/algorithmic.sty +201 -0
  91. package/src/skills/write/templates/icml2026/example_paper.bib +75 -0
  92. package/src/skills/write/templates/icml2026/example_paper.tex +662 -0
  93. package/src/skills/write/templates/icml2026/fancyhdr.sty +864 -0
  94. package/src/skills/write/templates/icml2026/icml2026.bst +1443 -0
  95. package/src/skills/write/templates/icml2026/icml2026.sty +767 -0
  96. package/src/skills/write/templates/neurips2025/Makefile +36 -0
  97. package/src/skills/write/templates/neurips2025/extra_pkgs.tex +53 -0
  98. package/src/skills/write/templates/neurips2025/main.tex +38 -0
  99. package/src/skills/write/templates/neurips2025/neurips.sty +382 -0
  100. package/src/skills/write/templates/nsdi2027/main.tex +426 -0
  101. package/src/skills/write/templates/nsdi2027/references.bib +151 -0
  102. package/src/skills/write/templates/nsdi2027/usenix-2020-09.sty +83 -0
  103. package/src/skills/write/templates/osdi2026/main.tex +429 -0
  104. package/src/skills/write/templates/osdi2026/references.bib +150 -0
  105. package/src/skills/write/templates/osdi2026/usenix-2020-09.sty +83 -0
  106. package/src/skills/write/templates/sosp2026/main.tex +532 -0
  107. package/src/skills/write/templates/sosp2026/references.bib +148 -0
  108. package/src/tui/package.json +1 -1
  109. package/src/ui/dist/assets/{AiManusChatView-BS3V4ZOk.js → AiManusChatView-BKZ103sn.js} +110 -14
  110. package/src/ui/dist/assets/{AnalysisPlugin-DLPXQsmr.js → AnalysisPlugin-mTTzGAlK.js} +1 -1
  111. package/src/ui/dist/assets/{AutoFigurePlugin-C-Fr9knQ.js → AutoFigurePlugin-C_wWw4AP.js} +5 -5
  112. package/src/ui/dist/assets/{CliPlugin-Dd8AHzFg.js → CliPlugin-BH58n3GY.js} +9 -9
  113. package/src/ui/dist/assets/{CodeEditorPlugin-Dg-RepTl.js → CodeEditorPlugin-BKGRUH7e.js} +8 -8
  114. package/src/ui/dist/assets/{CodeViewerPlugin-D2J_3nyt.js → CodeViewerPlugin-BMADwFWJ.js} +5 -5
  115. package/src/ui/dist/assets/{DocViewerPlugin-ChRLLKNb.js → DocViewerPlugin-ZOnTIHLN.js} +3 -3
  116. package/src/ui/dist/assets/{GitDiffViewerPlugin-DgHfcved.js → GitDiffViewerPlugin-CQ7h1Djm.js} +830 -86
  117. package/src/ui/dist/assets/{ImageViewerPlugin-C89GZMBy.js → ImageViewerPlugin-GVS5MsnC.js} +5 -5
  118. package/src/ui/dist/assets/{LabCopilotPanel-BUfIwUcb.js → LabCopilotPanel-BZNv1JML.js} +10 -10
  119. package/src/ui/dist/assets/{LabPlugin-zvUmQUMq.js → LabPlugin-TWcJsdQA.js} +1 -1
  120. package/src/ui/dist/assets/{LatexPlugin-C1SSNuWp.js → LatexPlugin-DIjHiR2x.js} +7 -7
  121. package/src/ui/dist/assets/{MarkdownViewerPlugin-D2Mf5tU5.js → MarkdownViewerPlugin-D3ooGAH0.js} +4 -4
  122. package/src/ui/dist/assets/{MarketplacePlugin-CF4LgiS2.js → MarketplacePlugin-DfVfE9hN.js} +3 -3
  123. package/src/ui/dist/assets/{NotebookEditor-BM7Bgwlv.js → NotebookEditor-DDl0_Mc0.js} +1 -1
  124. package/src/ui/dist/assets/{index-Be0NAmh8.js → NotebookEditor-s8JhzuX1.js} +12 -155
  125. package/src/ui/dist/assets/{PdfLoader-Bc5qfD-Z.js → PdfLoader-C2Sf6SJM.js} +1 -1
  126. package/src/ui/dist/assets/{PdfMarkdownPlugin-sh1-IRcp.js → PdfMarkdownPlugin-CXFLoIsa.js} +3 -3
  127. package/src/ui/dist/assets/{PdfViewerPlugin-C_a7CpWG.js → PdfViewerPlugin-BYTmz2fK.js} +10 -10
  128. package/src/ui/dist/assets/{SearchPlugin-L4z3HcLf.js → SearchPlugin-CjWBI1O9.js} +1 -1
  129. package/src/ui/dist/assets/{Stepper-Dk4aQ3fN.js → Stepper-B0Dd8CxK.js} +1 -1
  130. package/src/ui/dist/assets/{TextViewerPlugin-BsNtlKVo.js → TextViewerPlugin-DdOBU3-S.js} +4 -4
  131. package/src/ui/dist/assets/{VNCViewer-BpeDcZ5_.js → VNCViewer-B8HGgLwQ.js} +9 -9
  132. package/src/ui/dist/assets/{bibtex-C4QI-bbj.js → bibtex-CKaefIN2.js} +1 -1
  133. package/src/ui/dist/assets/{code-DuMINRsg.js → code-BWAY76JP.js} +1 -1
  134. package/src/ui/dist/assets/{file-content-C3N-432K.js → file-content-C1NwU5oQ.js} +1 -1
  135. package/src/ui/dist/assets/{file-diff-panel-CffQ4ZMg.js → file-diff-panel-CywslwB9.js} +1 -1
  136. package/src/ui/dist/assets/{file-socket-CRH59PCO.js → file-socket-B4kzuOBQ.js} +1 -1
  137. package/src/ui/dist/assets/{file-utils-vYGtW2mI.js → file-utils-H2fjA46S.js} +1 -1
  138. package/src/ui/dist/assets/{image-DBVGaooo.js → image-D-NZM-6P.js} +1 -1
  139. package/src/ui/dist/assets/{index-B1P6hQRJ.js → index-7Chr1g9c.js} +3734 -1862
  140. package/src/ui/dist/assets/{index-DjSFDmgB.js → index-BdM1Gqfr.js} +2 -2
  141. package/src/ui/dist/assets/{index-BpjYH9Vg.js → index-CDxNdQdz.js} +1 -1
  142. package/src/ui/dist/assets/{index-Do9N28uB.css → index-DGIYDuTv.css} +163 -34
  143. package/src/ui/dist/assets/index-DHZJ_0TI.js +159 -0
  144. package/src/ui/dist/assets/{message-square-BsPDBhiY.js → message-square-BzjLiXir.js} +1 -1
  145. package/src/ui/dist/assets/{monaco-BTkdPojV.js → monaco-Cb2uKKe6.js} +1 -1
  146. package/src/ui/dist/assets/{popover-cWjCk-vc.js → popover-Bg72DGgT.js} +1 -1
  147. package/src/ui/dist/assets/{project-sync-CXn530xb.js → project-sync-Ce_0BglY.js} +1 -1
  148. package/src/ui/dist/assets/{sigma-04Jr12jg.js → sigma-DPaACDrh.js} +1 -1
  149. package/src/ui/dist/assets/{tooltip-BdVDl0G5.js → tooltip-C_mA6R0w.js} +1 -1
  150. package/src/ui/dist/assets/{trash-CB_GlQyC.js → trash-BvTgE5__.js} +1 -1
  151. package/src/ui/dist/assets/{useCliAccess-BL932NwS.js → useCliAccess-CgPeMOwP.js} +1 -1
  152. package/src/ui/dist/assets/{useFileDiffOverlay-B2WK7Tvq.js → useFileDiffOverlay-xPhz7P5B.js} +1 -1
  153. package/src/ui/dist/assets/{wrap-text-YC68g12z.js → wrap-text-C3Un3YQr.js} +1 -1
  154. package/src/ui/dist/assets/{zoom-out-C0RJvFiJ.js → zoom-out-BgxLa0Ri.js} +1 -1
  155. package/src/ui/dist/index.html +5 -2
  156. /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,18 @@ 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,
51
+ normalize_metric_direction,
48
52
  normalize_metric_rows,
49
53
  normalize_metrics_summary,
50
54
  selected_baseline_metrics,
51
55
  to_number,
56
+ validate_baseline_metric_contract_submission,
57
+ validate_main_experiment_against_baseline_contract,
52
58
  )
53
59
  from .schemas import ARTIFACT_DIRS, guidance_for_kind, validate_artifact_payload
54
60
 
@@ -91,6 +97,16 @@ class ArtifactService:
91
97
  self.baselines = BaselineRegistry(home)
92
98
  self.quest_service = QuestService(home)
93
99
 
100
+ @staticmethod
101
+ def _notification_text(value: object, *, limit: int = 220) -> str | None:
102
+ text = str(value or "").strip()
103
+ if not text:
104
+ return None
105
+ text = re.sub(r"\s+", " ", text)
106
+ if len(text) <= limit:
107
+ return text
108
+ return text[: limit - 1].rstrip() + "…"
109
+
94
110
  def _normalize_evaluation_summary(self, payload: dict[str, Any] | None) -> dict[str, str] | None:
95
111
  if not isinstance(payload, dict):
96
112
  return None
@@ -126,6 +142,418 @@ class ArtifactService:
126
142
  lines = [f"- {label}: {normalized[key]}" for key, label in labels if normalized.get(key)]
127
143
  return lines or ["- Not recorded."]
128
144
 
145
+ @staticmethod
146
+ def _format_route_label(value: object) -> str | None:
147
+ normalized = str(value or "").strip().replace("_", " ").replace("-", " ")
148
+ if not normalized:
149
+ return None
150
+ return " ".join(part.capitalize() for part in normalized.split())
151
+
152
+ def _format_foundation_label(self, foundation_ref: dict[str, Any] | None, *, fallback: str | None = None) -> str:
153
+ payload = dict(foundation_ref or {})
154
+ label = self._notification_text(payload.get("label"))
155
+ if label:
156
+ return label
157
+ kind = self._notification_text(payload.get("kind"))
158
+ ref = self._notification_text(payload.get("ref"))
159
+ branch = self._notification_text(payload.get("branch"))
160
+ if kind and ref:
161
+ return f"{kind} {ref}"
162
+ if branch:
163
+ return branch
164
+ return fallback or "current head"
165
+
166
+ def _build_idea_interaction_message(
167
+ self,
168
+ *,
169
+ action: str,
170
+ idea_id: str,
171
+ title: str | None,
172
+ problem: str | None,
173
+ hypothesis: str | None,
174
+ mechanism: str | None,
175
+ foundation_label: str | None,
176
+ branch_name: str,
177
+ next_target: str | None,
178
+ idea_md_rel_path: str | None,
179
+ draft_md_rel_path: str | None,
180
+ ) -> str:
181
+ lead = "is now active" if action == "create" else "was revised"
182
+ lines = [f"Idea `{idea_id}` {lead} on branch `{branch_name}`."]
183
+ if self._notification_text(title):
184
+ lines.append(f"Title: {self._notification_text(title)}")
185
+ if self._notification_text(problem):
186
+ lines.append(f"Problem: {self._notification_text(problem)}")
187
+ if self._notification_text(hypothesis):
188
+ lines.append(f"Hypothesis: {self._notification_text(hypothesis)}")
189
+ if self._notification_text(mechanism):
190
+ lines.append(f"Mechanism: {self._notification_text(mechanism)}")
191
+ if foundation_label:
192
+ lines.append(f"Foundation: {foundation_label}")
193
+ if next_target:
194
+ lines.append(f"Next route: {self._format_route_label(next_target) or next_target}")
195
+ if idea_md_rel_path:
196
+ lines.append(f"Idea doc: `{idea_md_rel_path}`")
197
+ if draft_md_rel_path:
198
+ lines.append(f"Draft: `{draft_md_rel_path}`")
199
+ return "\n".join(lines)
200
+
201
+ def _build_main_experiment_interaction_message(
202
+ self,
203
+ *,
204
+ run_id: str,
205
+ branch_name: str,
206
+ verdict: str,
207
+ primary_metric_id: str | None,
208
+ primary_value: object,
209
+ primary_baseline: object,
210
+ primary_delta: object,
211
+ decimals: int | None,
212
+ conclusion: str | None,
213
+ evaluation_summary: dict[str, str] | None,
214
+ breakthrough_level: str | None,
215
+ recommended_next_route: str | None,
216
+ run_md_rel_path: str | None,
217
+ result_json_rel_path: str | None,
218
+ ) -> str:
219
+ lines = [f"Main experiment `{run_id}` finished on branch `{branch_name}`."]
220
+ if primary_metric_id and primary_value is not None:
221
+ metric_text = f"{primary_metric_id}={self._format_metric_value(primary_value, decimals)}"
222
+ if primary_baseline is not None and primary_delta is not None:
223
+ metric_text += (
224
+ f", baseline={self._format_metric_value(primary_baseline, decimals)}, "
225
+ f"delta={self._format_metric_value(primary_delta, decimals)}"
226
+ )
227
+ lines.append(f"Metric: {metric_text}")
228
+ lines.append(f"Verdict: {self._format_route_label(verdict) or verdict}")
229
+ takeaway = (
230
+ self._notification_text((evaluation_summary or {}).get("takeaway"))
231
+ or self._notification_text(conclusion)
232
+ )
233
+ if takeaway:
234
+ lines.append(f"Takeaway: {takeaway}")
235
+ claim_update = self._notification_text((evaluation_summary or {}).get("claim_update"))
236
+ if claim_update:
237
+ lines.append(f"Claim update: {claim_update}")
238
+ if self._notification_text(breakthrough_level):
239
+ lines.append(f"Breakthrough level: {self._notification_text(breakthrough_level)}")
240
+ if recommended_next_route:
241
+ lines.append(
242
+ f"Recommended next route: {self._format_route_label(recommended_next_route) or recommended_next_route}"
243
+ )
244
+ if run_md_rel_path:
245
+ lines.append(f"Run log: `{run_md_rel_path}`")
246
+ if result_json_rel_path:
247
+ lines.append(f"Result: `{result_json_rel_path}`")
248
+ return "\n".join(lines)
249
+
250
+ def _build_outline_interaction_message(
251
+ self,
252
+ *,
253
+ action: str,
254
+ outline_id: str,
255
+ title: str | None,
256
+ selected_reason: str | None,
257
+ story: str | None,
258
+ research_questions: object,
259
+ experimental_designs: object,
260
+ selected_outline_rel_path: str | None,
261
+ outline_selection_rel_path: str | None,
262
+ revised_outline_rel_path: str | None = None,
263
+ ) -> str:
264
+ verb = "selected" if action == "select" else "revised"
265
+ lines = [f"Paper outline `{outline_id}` was {verb} and promoted into the writing stage."]
266
+ if self._notification_text(title):
267
+ lines.append(f"Title: {self._notification_text(title)}")
268
+ if self._notification_text(selected_reason):
269
+ lines.append(f"Reason: {self._notification_text(selected_reason)}")
270
+ if self._notification_text(story):
271
+ lines.append(f"Story: {self._notification_text(story)}")
272
+ if research_questions:
273
+ lines.append(f"Research questions: {self._notification_text(research_questions)}")
274
+ if experimental_designs:
275
+ lines.append(f"Experimental designs: {self._notification_text(experimental_designs)}")
276
+ lines.append("Next route: Continue writing on the paper branch, or launch outline-bound analysis if evidence is still missing.")
277
+ if selected_outline_rel_path:
278
+ lines.append(f"Selected outline: `{selected_outline_rel_path}`")
279
+ if outline_selection_rel_path:
280
+ lines.append(f"Selection note: `{outline_selection_rel_path}`")
281
+ if revised_outline_rel_path:
282
+ lines.append(f"Revision record: `{revised_outline_rel_path}`")
283
+ return "\n".join(lines)
284
+
285
+ def _build_analysis_campaign_interaction_message(
286
+ self,
287
+ *,
288
+ campaign_id: str,
289
+ goal: str | None,
290
+ parent_branch: str,
291
+ selected_outline_ref: str | None,
292
+ first_slice: dict[str, Any],
293
+ todo_manifest_rel_path: str | None,
294
+ ) -> str:
295
+ lines = [f"Analysis campaign `{campaign_id}` is ready from parent branch `{parent_branch}`."]
296
+ if self._notification_text(goal):
297
+ lines.append(f"Goal: {self._notification_text(goal)}")
298
+ if selected_outline_ref:
299
+ lines.append(f"Selected outline: `{selected_outline_ref}`")
300
+ lines.append(
301
+ f"Next slice: `{first_slice.get('slice_id')}` on branch `{first_slice.get('branch')}`"
302
+ )
303
+ if self._notification_text(first_slice.get("title")):
304
+ lines.append(f"Slice focus: {self._notification_text(first_slice.get('title'))}")
305
+ requirement = self._notification_text(first_slice.get("must_not_simplify") or first_slice.get("goal"))
306
+ if requirement:
307
+ lines.append(f"Core requirement: {requirement}")
308
+ if todo_manifest_rel_path:
309
+ lines.append(f"Todo manifest: `{todo_manifest_rel_path}`")
310
+ return "\n".join(lines)
311
+
312
+ def _build_analysis_slice_interaction_message(
313
+ self,
314
+ *,
315
+ campaign_id: str,
316
+ slice_id: str,
317
+ evaluation_summary: dict[str, str] | None,
318
+ claim_impact: str | None,
319
+ next_slice: dict[str, Any],
320
+ mirror_rel_path: str | None,
321
+ ) -> str:
322
+ lines = [f"Analysis slice `{slice_id}` from campaign `{campaign_id}` is complete."]
323
+ takeaway = self._notification_text((evaluation_summary or {}).get("takeaway"))
324
+ if takeaway:
325
+ lines.append(f"Takeaway: {takeaway}")
326
+ if self._notification_text(claim_impact):
327
+ lines.append(f"Claim impact: {self._notification_text(claim_impact)}")
328
+ lines.append(
329
+ f"Next slice: `{next_slice.get('slice_id')}` on branch `{next_slice.get('branch')}`"
330
+ )
331
+ requirement = self._notification_text(next_slice.get("must_not_simplify") or next_slice.get("goal"))
332
+ if requirement:
333
+ lines.append(f"Core requirement: {requirement}")
334
+ if mirror_rel_path:
335
+ lines.append(f"Parent mirror: `{mirror_rel_path}`")
336
+ return "\n".join(lines)
337
+
338
+ def _build_analysis_complete_interaction_message(
339
+ self,
340
+ *,
341
+ campaign_id: str,
342
+ completed_slices: list[dict[str, Any]],
343
+ summary_rel_path: str | None,
344
+ writing_branch: str | None,
345
+ writing_worktree_rel_path: str | None,
346
+ ) -> str:
347
+ lines = [f"Analysis campaign `{campaign_id}` is complete."]
348
+ lines.append(f"Completed slices: {len(completed_slices)}")
349
+ strongest_takeaway = next(
350
+ (
351
+ self._notification_text(
352
+ ((item.get("evaluation_summary") or {}) if isinstance(item.get("evaluation_summary"), dict) else {}).get(
353
+ "takeaway"
354
+ )
355
+ )
356
+ for item in completed_slices
357
+ if self._notification_text(
358
+ ((item.get("evaluation_summary") or {}) if isinstance(item.get("evaluation_summary"), dict) else {}).get(
359
+ "takeaway"
360
+ )
361
+ )
362
+ ),
363
+ None,
364
+ )
365
+ if strongest_takeaway:
366
+ lines.append(f"Main takeaway: {strongest_takeaway}")
367
+ if summary_rel_path:
368
+ lines.append(f"Summary: `{summary_rel_path}`")
369
+ if writing_branch:
370
+ lines.append(f"Next route: writing is active on branch `{writing_branch}`")
371
+ if writing_worktree_rel_path:
372
+ lines.append(f"Writing workspace: `{writing_worktree_rel_path}`")
373
+ else:
374
+ lines.append("Next route: make the next durable decision from the merged analysis evidence.")
375
+ return "\n".join(lines)
376
+
377
+ def _load_metric_contract_payload(self, quest_root: Path, metric_contract_json_rel_path: str | None) -> dict[str, Any] | None:
378
+ rel_path = str(metric_contract_json_rel_path or "").strip()
379
+ if not rel_path:
380
+ return None
381
+ try:
382
+ resolved_path = resolve_within(quest_root, rel_path)
383
+ except ValueError:
384
+ return None
385
+ if not resolved_path.exists():
386
+ return None
387
+ payload = read_json(resolved_path, {})
388
+ return payload if isinstance(payload, dict) and payload else None
389
+
390
+ def _normalize_metric_directions(self, metric_directions: object) -> dict[str, str]:
391
+ if not isinstance(metric_directions, dict):
392
+ return {}
393
+ normalized: dict[str, str] = {}
394
+ for raw_metric_id, raw_direction in metric_directions.items():
395
+ metric_id = str(raw_metric_id or "").strip()
396
+ if not metric_id:
397
+ continue
398
+ normalized[metric_id] = normalize_metric_direction(raw_direction, metric_id=metric_id)
399
+ return normalized
400
+
401
+ def _apply_metric_directions_to_contract(
402
+ self,
403
+ *,
404
+ metric_contract: object,
405
+ metric_directions: object,
406
+ baseline_id: str | None = None,
407
+ metrics_summary: object = None,
408
+ metric_rows: object = None,
409
+ primary_metric: object = None,
410
+ baseline_variants: object = None,
411
+ ) -> tuple[dict[str, Any], dict[str, Any] | None]:
412
+ normalized_contract = normalize_metric_contract(
413
+ metric_contract,
414
+ baseline_id=baseline_id,
415
+ metrics_summary=metrics_summary,
416
+ metric_rows=metric_rows,
417
+ primary_metric=primary_metric,
418
+ baseline_variants=baseline_variants,
419
+ )
420
+ normalized_primary_metric = dict(primary_metric or {}) if isinstance(primary_metric, dict) else None
421
+ overrides = self._normalize_metric_directions(metric_directions)
422
+ if not overrides:
423
+ return normalized_contract, normalized_primary_metric
424
+
425
+ metrics_by_id: dict[str, dict[str, Any]] = {}
426
+ ordered_metric_ids: list[str] = []
427
+ for raw_metric in normalized_contract.get("metrics", []):
428
+ if not isinstance(raw_metric, dict):
429
+ continue
430
+ metric_id = str(raw_metric.get("metric_id") or "").strip()
431
+ if not metric_id:
432
+ continue
433
+ metrics_by_id[metric_id] = dict(raw_metric)
434
+ ordered_metric_ids.append(metric_id)
435
+ for metric_id, direction in overrides.items():
436
+ current = metrics_by_id.get(metric_id)
437
+ if current is None:
438
+ current = {
439
+ "metric_id": metric_id,
440
+ "label": metric_id,
441
+ "direction": direction,
442
+ "unit": None,
443
+ "decimals": None,
444
+ "chart_group": "default",
445
+ }
446
+ ordered_metric_ids.append(metric_id)
447
+ else:
448
+ current = {
449
+ **current,
450
+ "direction": direction,
451
+ }
452
+ metrics_by_id[metric_id] = current
453
+
454
+ primary_metric_id = str(
455
+ (normalized_primary_metric or {}).get("metric_id")
456
+ or (normalized_primary_metric or {}).get("name")
457
+ or (normalized_primary_metric or {}).get("id")
458
+ or normalized_contract.get("primary_metric_id")
459
+ or ""
460
+ ).strip()
461
+ if normalized_primary_metric and primary_metric_id in overrides:
462
+ normalized_primary_metric = {
463
+ **normalized_primary_metric,
464
+ "direction": overrides[primary_metric_id],
465
+ }
466
+
467
+ return {
468
+ **normalized_contract,
469
+ "metrics": [metrics_by_id[metric_id] for metric_id in ordered_metric_ids if metric_id in metrics_by_id],
470
+ }, normalized_primary_metric
471
+
472
+ def _merge_run_metric_contract(
473
+ self,
474
+ *,
475
+ baseline_metric_contract: object,
476
+ baseline_primary_metric: object,
477
+ baseline_variants: object,
478
+ run_metric_contract: object,
479
+ metrics_summary: object,
480
+ metric_rows: object,
481
+ baseline_id: str | None = None,
482
+ ) -> dict[str, Any]:
483
+ baseline_contract = normalize_metric_contract(
484
+ baseline_metric_contract,
485
+ baseline_id=baseline_id,
486
+ metrics_summary=metrics_summary,
487
+ metric_rows=metric_rows,
488
+ primary_metric=baseline_primary_metric,
489
+ baseline_variants=baseline_variants,
490
+ )
491
+ if not isinstance(run_metric_contract, dict) or not run_metric_contract:
492
+ return baseline_contract
493
+
494
+ overlay_contract = normalize_metric_contract(
495
+ run_metric_contract,
496
+ baseline_id=baseline_id,
497
+ metrics_summary=metrics_summary,
498
+ metric_rows=metric_rows,
499
+ primary_metric=baseline_contract.get("primary_metric_id"),
500
+ )
501
+ overlay_metrics: dict[str, dict[str, Any]] = {}
502
+ for raw_metric in overlay_contract.get("metrics", []):
503
+ if not isinstance(raw_metric, dict):
504
+ continue
505
+ metric_id = str(raw_metric.get("metric_id") or "").strip()
506
+ if metric_id:
507
+ overlay_metrics[metric_id] = raw_metric
508
+
509
+ merged_metrics: list[dict[str, Any]] = []
510
+ seen_metric_ids: set[str] = set()
511
+ for raw_metric in baseline_contract.get("metrics", []):
512
+ if not isinstance(raw_metric, dict):
513
+ continue
514
+ metric_id = str(raw_metric.get("metric_id") or "").strip()
515
+ if not metric_id:
516
+ continue
517
+ patch = overlay_metrics.get(metric_id) or {}
518
+ merged = dict(raw_metric)
519
+ for field in (
520
+ "label",
521
+ "unit",
522
+ "decimals",
523
+ "chart_group",
524
+ "description",
525
+ "derivation",
526
+ "source_ref",
527
+ "required",
528
+ "origin_path",
529
+ ):
530
+ value = patch.get(field)
531
+ if value is None:
532
+ continue
533
+ if isinstance(value, str) and not value.strip():
534
+ continue
535
+ merged[field] = value
536
+ merged_metrics.append(merged)
537
+ seen_metric_ids.add(metric_id)
538
+
539
+ for metric_id, raw_metric in overlay_metrics.items():
540
+ if metric_id in seen_metric_ids:
541
+ continue
542
+ merged_metrics.append(dict(raw_metric))
543
+
544
+ merged_contract = {
545
+ **baseline_contract,
546
+ "metrics": merged_metrics,
547
+ }
548
+ if not merged_contract.get("evaluation_protocol") and overlay_contract.get("evaluation_protocol") is not None:
549
+ merged_contract["evaluation_protocol"] = overlay_contract.get("evaluation_protocol")
550
+ for key, value in overlay_contract.items():
551
+ if key in {"contract_id", "primary_metric_id", "metrics", "evaluation_protocol"}:
552
+ continue
553
+ if key not in merged_contract and value is not None:
554
+ merged_contract[key] = value
555
+ return merged_contract
556
+
129
557
  def _workspace_root_for(self, quest_root: Path, workspace_root: Path | None = None) -> Path:
130
558
  if workspace_root is not None:
131
559
  return workspace_root
@@ -139,6 +567,73 @@ class ArtifactService:
139
567
  except ValueError:
140
568
  return str(path)
141
569
 
570
+ @staticmethod
571
+ def _branch_kind_from_name(branch_name: str | None) -> str:
572
+ normalized = str(branch_name or "").strip()
573
+ if normalized in {"main", "master"} or normalized.startswith("quest/"):
574
+ return "quest"
575
+ if normalized.startswith("idea/"):
576
+ return "idea"
577
+ if normalized.startswith("analysis/"):
578
+ return "analysis"
579
+ if normalized.startswith("paper/"):
580
+ return "paper"
581
+ if normalized.startswith("run/"):
582
+ return "run"
583
+ return "branch"
584
+
585
+ def _workspace_mode_for_branch(self, branch_name: str | None, *, has_idea: bool = False) -> str:
586
+ branch_kind = self._branch_kind_from_name(branch_name)
587
+ if branch_kind == "paper":
588
+ return "paper"
589
+ if branch_kind == "analysis":
590
+ return "analysis"
591
+ if branch_kind == "run":
592
+ return "run"
593
+ if branch_kind == "idea" or has_idea:
594
+ return "idea"
595
+ return "quest"
596
+
597
+ def _prepare_branch_worktree_root(
598
+ self,
599
+ quest_root: Path,
600
+ *,
601
+ branch_name: str,
602
+ branch_kind: str,
603
+ run_id: str | None = None,
604
+ idea_id: str | None = None,
605
+ ) -> Path:
606
+ normalized_kind = str(branch_kind or "").strip().lower() or "run"
607
+ normalized_run_id = str(run_id or "").strip() or None
608
+ normalized_idea_id = str(idea_id or "").strip() or None
609
+ if normalized_kind == "idea" and normalized_idea_id:
610
+ return canonical_worktree_root(quest_root, f"idea-{normalized_idea_id}")
611
+ if normalized_kind == "paper":
612
+ return canonical_worktree_root(
613
+ quest_root,
614
+ f"paper-{normalized_run_id or slugify(branch_name, 'paper')}",
615
+ )
616
+ if normalized_kind == "run" and normalized_run_id:
617
+ return canonical_worktree_root(quest_root, normalized_run_id)
618
+ return canonical_worktree_root(quest_root, slugify(branch_name, "branch"))
619
+
620
+ def _latest_prepare_branch_record(self, quest_root: Path, branch_name: str) -> dict[str, Any]:
621
+ normalized_branch = str(branch_name or "").strip()
622
+ if not normalized_branch:
623
+ return {}
624
+ for item in reversed(self.quest_service._collect_artifacts(quest_root)):
625
+ payload = dict(item.get("payload") or {}) if isinstance(item.get("payload"), dict) else {}
626
+ if not payload:
627
+ continue
628
+ if str(payload.get("kind") or "").strip() != "decision":
629
+ continue
630
+ if str(payload.get("action") or "").strip() != "prepare_branch":
631
+ continue
632
+ if str(payload.get("branch") or "").strip() != normalized_branch:
633
+ continue
634
+ return payload
635
+ return {}
636
+
142
637
  def _git_config(self) -> dict[str, Any]:
143
638
  config = ConfigManager(self.home).load_named("config")
144
639
  payload = config.get("git") if isinstance(config.get("git"), dict) else {}
@@ -623,43 +1118,91 @@ class ArtifactService:
623
1118
  )
624
1119
  return normalized
625
1120
 
626
- def _paper_root(self, quest_root: Path) -> Path:
627
- return ensure_dir(quest_root / "paper")
1121
+ def _paper_root(
1122
+ self,
1123
+ quest_root: Path,
1124
+ *,
1125
+ workspace_root: Path | None = None,
1126
+ prefer_workspace: bool = True,
1127
+ create: bool = False,
1128
+ ) -> Path:
1129
+ roots: list[Path] = []
1130
+ if prefer_workspace:
1131
+ roots.append(self._workspace_root_for(quest_root, workspace_root))
1132
+ roots.append(quest_root)
1133
+ seen: set[str] = set()
1134
+ first_candidate: Path | None = None
1135
+ for root in roots:
1136
+ key = str(root.resolve())
1137
+ if key in seen:
1138
+ continue
1139
+ seen.add(key)
1140
+ candidate = root / "paper"
1141
+ if first_candidate is None:
1142
+ first_candidate = candidate
1143
+ if candidate.exists():
1144
+ return candidate
1145
+ fallback = first_candidate or (quest_root / "paper")
1146
+ return ensure_dir(fallback) if create else fallback
628
1147
 
629
- def _paper_outline_candidates_root(self, quest_root: Path) -> Path:
630
- return ensure_dir(self._paper_root(quest_root) / "outlines" / "candidates")
1148
+ def _paper_outline_candidates_root(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
1149
+ return ensure_dir(self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "outlines" / "candidates")
631
1150
 
632
- def _paper_outline_revisions_root(self, quest_root: Path) -> Path:
633
- return ensure_dir(self._paper_root(quest_root) / "outlines" / "revisions")
1151
+ def _paper_outline_revisions_root(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
1152
+ return ensure_dir(self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "outlines" / "revisions")
634
1153
 
635
- def _paper_selected_outline_path(self, quest_root: Path) -> Path:
636
- return self._paper_root(quest_root) / "selected_outline.json"
1154
+ def _paper_selected_outline_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
1155
+ return self._paper_root(quest_root, workspace_root=workspace_root) / "selected_outline.json"
637
1156
 
638
- def _paper_outline_selection_path(self, quest_root: Path) -> Path:
639
- return self._paper_root(quest_root) / "outline_selection.md"
1157
+ def _paper_outline_selection_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
1158
+ return self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "outline_selection.md"
640
1159
 
641
- def _paper_bundle_manifest_path(self, quest_root: Path) -> Path:
642
- return self._paper_root(quest_root) / "paper_bundle_manifest.json"
1160
+ def _paper_bundle_manifest_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
1161
+ return self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "paper_bundle_manifest.json"
643
1162
 
644
- def _paper_baseline_inventory_path(self, quest_root: Path) -> Path:
645
- return self._paper_root(quest_root) / "baseline_inventory.json"
1163
+ def _paper_baseline_inventory_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
1164
+ return self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "baseline_inventory.json"
646
1165
 
647
- def _open_source_root(self, quest_root: Path) -> Path:
648
- return ensure_dir(quest_root / "release" / "open_source")
1166
+ def _open_source_root(
1167
+ self,
1168
+ quest_root: Path,
1169
+ *,
1170
+ workspace_root: Path | None = None,
1171
+ prefer_workspace: bool = True,
1172
+ create: bool = False,
1173
+ ) -> Path:
1174
+ roots: list[Path] = []
1175
+ if prefer_workspace:
1176
+ roots.append(self._workspace_root_for(quest_root, workspace_root))
1177
+ roots.append(quest_root)
1178
+ seen: set[str] = set()
1179
+ first_candidate: Path | None = None
1180
+ for root in roots:
1181
+ key = str(root.resolve())
1182
+ if key in seen:
1183
+ continue
1184
+ seen.add(key)
1185
+ candidate = root / "release" / "open_source"
1186
+ if first_candidate is None:
1187
+ first_candidate = candidate
1188
+ if candidate.exists():
1189
+ return candidate
1190
+ fallback = first_candidate or (quest_root / "release" / "open_source")
1191
+ return ensure_dir(fallback) if create else fallback
649
1192
 
650
- def _open_source_manifest_path(self, quest_root: Path) -> Path:
651
- return self._open_source_root(quest_root) / "manifest.json"
1193
+ def _open_source_manifest_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
1194
+ return self._open_source_root(quest_root, workspace_root=workspace_root, create=True) / "manifest.json"
652
1195
 
653
- def _open_source_cleanup_plan_path(self, quest_root: Path) -> Path:
654
- return self._open_source_root(quest_root) / "cleanup_plan.md"
1196
+ def _open_source_cleanup_plan_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
1197
+ return self._open_source_root(quest_root, workspace_root=workspace_root, create=True) / "cleanup_plan.md"
655
1198
 
656
- def _open_source_include_paths_path(self, quest_root: Path) -> Path:
657
- return self._open_source_root(quest_root) / "include_paths.json"
1199
+ def _open_source_include_paths_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
1200
+ return self._open_source_root(quest_root, workspace_root=workspace_root, create=True) / "include_paths.json"
658
1201
 
659
- def _open_source_exclude_paths_path(self, quest_root: Path) -> Path:
660
- return self._open_source_root(quest_root) / "exclude_paths.json"
1202
+ def _open_source_exclude_paths_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
1203
+ return self._open_source_root(quest_root, workspace_root=workspace_root, create=True) / "exclude_paths.json"
661
1204
 
662
- def _write_paper_baseline_inventory(self, quest_root: Path) -> dict[str, Any]:
1205
+ def _write_paper_baseline_inventory(self, quest_root: Path, *, workspace_root: Path | None = None) -> dict[str, Any]:
663
1206
  quest_yaml = self.quest_service.read_quest_yaml(quest_root)
664
1207
  confirmed_baseline_ref = (
665
1208
  dict(quest_yaml.get("confirmed_baseline_ref") or {})
@@ -675,22 +1218,23 @@ class ArtifactService:
675
1218
  ],
676
1219
  "updated_at": utc_now(),
677
1220
  }
678
- write_json(self._paper_baseline_inventory_path(quest_root), payload)
1221
+ write_json(self._paper_baseline_inventory_path(quest_root, workspace_root=workspace_root), payload)
679
1222
  return payload
680
1223
 
681
1224
  def _ensure_open_source_prep(
682
1225
  self,
683
1226
  quest_root: Path,
684
1227
  *,
1228
+ workspace_root: Path | None,
685
1229
  source_branch: str | None,
686
1230
  source_bundle_manifest_path: str,
687
1231
  baseline_inventory_path: str,
688
1232
  ) -> 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)
1233
+ root = self._open_source_root(quest_root, workspace_root=workspace_root, create=True)
1234
+ cleanup_plan_path = self._open_source_cleanup_plan_path(quest_root, workspace_root=workspace_root)
1235
+ include_paths_path = self._open_source_include_paths_path(quest_root, workspace_root=workspace_root)
1236
+ exclude_paths_path = self._open_source_exclude_paths_path(quest_root, workspace_root=workspace_root)
1237
+ manifest_path = self._open_source_manifest_path(quest_root, workspace_root=workspace_root)
694
1238
  if not cleanup_plan_path.exists():
695
1239
  write_text(
696
1240
  cleanup_plan_path,
@@ -737,11 +1281,17 @@ class ArtifactService:
737
1281
  or source_bundle_manifest_path,
738
1282
  "baseline_inventory_path": str(existing.get("baseline_inventory_path") or baseline_inventory_path or "").strip()
739
1283
  or baseline_inventory_path,
740
- "cleanup_plan_path": str(existing.get("cleanup_plan_path") or "release/open_source/cleanup_plan.md").strip()
1284
+ "cleanup_plan_path": str(
1285
+ existing.get("cleanup_plan_path") or self._workspace_relative(quest_root, cleanup_plan_path) or ""
1286
+ ).strip()
741
1287
  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()
1288
+ "include_paths_path": str(
1289
+ existing.get("include_paths_path") or self._workspace_relative(quest_root, include_paths_path) or ""
1290
+ ).strip()
743
1291
  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()
1292
+ "exclude_paths_path": str(
1293
+ existing.get("exclude_paths_path") or self._workspace_relative(quest_root, exclude_paths_path) or ""
1294
+ ).strip()
745
1295
  or "release/open_source/exclude_paths.json",
746
1296
  "created_at": existing.get("created_at") or utc_now(),
747
1297
  "updated_at": utc_now(),
@@ -859,6 +1409,9 @@ class ArtifactService:
859
1409
  continue
860
1410
  seen_paths.add(key)
861
1411
  payload = read_yaml(path, {})
1412
+ baseline_id = str(payload.get("source_baseline_id") or "").strip() if isinstance(payload, dict) else ""
1413
+ if baseline_id and self.baselines.is_deleted(baseline_id):
1414
+ continue
862
1415
  if isinstance(payload, dict) and payload:
863
1416
  attachments.append(payload)
864
1417
  if not attachments:
@@ -871,6 +1424,48 @@ class ArtifactService:
871
1424
  ),
872
1425
  )
873
1426
 
1427
+ def _baseline_workspace_roots(self, quest_root: Path) -> list[Path]:
1428
+ roots: list[Path] = [quest_root]
1429
+ research_state = read_json(quest_root / ".ds" / "research_state.json", {})
1430
+ if isinstance(research_state, dict):
1431
+ for key in (
1432
+ "research_head_worktree_root",
1433
+ "current_workspace_root",
1434
+ "analysis_parent_worktree_root",
1435
+ "paper_parent_worktree_root",
1436
+ ):
1437
+ raw = str(research_state.get(key) or "").strip()
1438
+ if raw:
1439
+ roots.append(Path(raw))
1440
+ worktrees_root = quest_root / ".ds" / "worktrees"
1441
+ if worktrees_root.exists():
1442
+ roots.extend(path for path in sorted(worktrees_root.iterdir()) if path.is_dir())
1443
+ deduped: list[Path] = []
1444
+ seen: set[str] = set()
1445
+ for root in roots:
1446
+ key = str(root.resolve(strict=False))
1447
+ if key in seen:
1448
+ continue
1449
+ seen.add(key)
1450
+ deduped.append(root)
1451
+ return deduped
1452
+
1453
+ @staticmethod
1454
+ def _remove_baseline_materialization(root: Path, baseline_id: str) -> list[str]:
1455
+ deleted_paths: list[str] = []
1456
+ for candidate in (
1457
+ root / "baselines" / "imported" / baseline_id,
1458
+ root / "baselines" / "local" / baseline_id,
1459
+ ):
1460
+ if not candidate.exists():
1461
+ continue
1462
+ if candidate.is_dir():
1463
+ shutil.rmtree(candidate)
1464
+ else:
1465
+ candidate.unlink()
1466
+ deleted_paths.append(str(candidate))
1467
+ return deleted_paths
1468
+
874
1469
  def _resolve_baseline_path(
875
1470
  self,
876
1471
  quest_root: Path,
@@ -1064,6 +1659,7 @@ class ArtifactService:
1064
1659
  "metric_contract": metric_contract,
1065
1660
  "primary_metric": entry.get("primary_metric"),
1066
1661
  "metrics_summary": metrics_summary,
1662
+ "metric_details": entry.get("metric_details") or [],
1067
1663
  }
1068
1664
  json_path = ensure_dir(baseline_root / "json") / "metric_contract.json"
1069
1665
  write_json(json_path, payload)
@@ -1169,9 +1765,43 @@ class ArtifactService:
1169
1765
  "Use `artifact.confirm_baseline(...)` or `artifact.waive_baseline(...)` first."
1170
1766
  )
1171
1767
 
1768
+ @staticmethod
1769
+ def _artifact_record_identity(path: Path, payload: dict[str, Any], *, kind: str | None = None) -> str:
1770
+ normalized_kind = str(kind or payload.get("kind") or path.parent.name or "artifact").strip() or "artifact"
1771
+ branch_name = str(payload.get("branch") or "").strip()
1772
+ run_id = str(payload.get("run_id") or "").strip()
1773
+ if normalized_kind == "run" and run_id and branch_name:
1774
+ return f"{normalized_kind}:branch_run:{branch_name}:{run_id}"
1775
+ artifact_id = str(payload.get("artifact_id") or payload.get("id") or "").strip()
1776
+ if artifact_id:
1777
+ return f"{normalized_kind}:artifact:{artifact_id}"
1778
+ if normalized_kind == "run" and run_id:
1779
+ return f"{normalized_kind}:run:{run_id}"
1780
+ idea_id = str(payload.get("idea_id") or "").strip()
1781
+ if normalized_kind == "idea" and idea_id and branch_name:
1782
+ return f"{normalized_kind}:branch_idea:{branch_name}:{idea_id}"
1783
+ if normalized_kind == "idea" and idea_id:
1784
+ return f"{normalized_kind}:idea:{idea_id}"
1785
+ baseline_id = str(payload.get("baseline_id") or payload.get("entry_id") or "").strip()
1786
+ if baseline_id:
1787
+ return f"{normalized_kind}:baseline:{baseline_id}"
1788
+ interaction_id = str(payload.get("interaction_id") or "").strip()
1789
+ if interaction_id:
1790
+ return f"{normalized_kind}:interaction:{interaction_id}"
1791
+ return f"path:{path.resolve()}"
1792
+
1793
+ @staticmethod
1794
+ def _artifact_record_rank(payload: dict[str, Any], *, path: Path, mtime_ns: int) -> tuple[str, str, int, int, str]:
1795
+ return (
1796
+ str(payload.get("updated_at") or ""),
1797
+ str(payload.get("created_at") or ""),
1798
+ len(payload),
1799
+ mtime_ns,
1800
+ str(path),
1801
+ )
1802
+
1172
1803
  def _main_run_artifacts(self, quest_root: Path) -> list[dict[str, Any]]:
1173
- records: list[dict[str, Any]] = []
1174
- seen_paths: set[str] = set()
1804
+ records_by_identity: dict[str, dict[str, Any]] = {}
1175
1805
  for root in self.quest_service.workspace_roots(quest_root):
1176
1806
  artifacts_root = root / "artifacts" / "runs"
1177
1807
  if not artifacts_root.exists():
@@ -1179,10 +1809,6 @@ class ArtifactService:
1179
1809
  for path in sorted(artifacts_root.glob("*.json")):
1180
1810
  if not path.is_file():
1181
1811
  continue
1182
- key = str(path.resolve())
1183
- if key in seen_paths:
1184
- continue
1185
- seen_paths.add(key)
1186
1812
  payload = read_json(path, {})
1187
1813
  if not isinstance(payload, dict) or not payload:
1188
1814
  continue
@@ -1194,7 +1820,19 @@ class ArtifactService:
1194
1820
  enriched["_artifact_mtime_ns"] = path.stat().st_mtime_ns
1195
1821
  except OSError:
1196
1822
  enriched["_artifact_mtime_ns"] = 0
1197
- records.append(enriched)
1823
+ identity = self._artifact_record_identity(path, enriched, kind="run")
1824
+ existing = records_by_identity.get(identity)
1825
+ if existing is None or self._artifact_record_rank(
1826
+ enriched,
1827
+ path=path,
1828
+ mtime_ns=int(enriched.get("_artifact_mtime_ns") or 0),
1829
+ ) >= self._artifact_record_rank(
1830
+ existing,
1831
+ path=Path(str(existing.get("_artifact_path") or path)),
1832
+ mtime_ns=int(existing.get("_artifact_mtime_ns") or 0),
1833
+ ):
1834
+ records_by_identity[identity] = enriched
1835
+ records = list(records_by_identity.values())
1198
1836
  records.sort(
1199
1837
  key=lambda item: (
1200
1838
  str(item.get("updated_at") or item.get("created_at") or ""),
@@ -1277,6 +1915,173 @@ class ArtifactService:
1277
1915
  continue
1278
1916
  return None
1279
1917
 
1918
+ def _branch_activation_worktree_root(
1919
+ self,
1920
+ quest_root: Path,
1921
+ *,
1922
+ branch_name: str,
1923
+ idea_id: str | None = None,
1924
+ run_id: str | None = None,
1925
+ ) -> Path:
1926
+ normalized_branch = str(branch_name or "").strip()
1927
+ branch_kind = self._branch_kind_from_name(normalized_branch)
1928
+ normalized_idea_id = str(idea_id or "").strip() or None
1929
+ if branch_kind == "paper":
1930
+ normalized_run_id = str(run_id or "").strip() or None
1931
+ return canonical_worktree_root(
1932
+ quest_root,
1933
+ f"paper-{normalized_run_id or slugify(normalized_branch, 'paper')}",
1934
+ )
1935
+ if normalized_idea_id and branch_kind == "idea":
1936
+ return canonical_worktree_root(quest_root, f"idea-{normalized_idea_id}")
1937
+ normalized_run_id = str(run_id or "").strip() or None
1938
+ if normalized_run_id and branch_kind == "run":
1939
+ return canonical_worktree_root(quest_root, normalized_run_id)
1940
+ return canonical_worktree_root(quest_root, f"branch-{slugify(normalized_branch, 'branch')}")
1941
+
1942
+ @staticmethod
1943
+ def _resolve_activate_branch_anchor(
1944
+ *,
1945
+ anchor: str | None,
1946
+ has_idea: bool,
1947
+ has_main_result: bool,
1948
+ ) -> str:
1949
+ normalized_anchor = str(anchor or "auto").strip().lower() or "auto"
1950
+ if normalized_anchor == "auto":
1951
+ if has_main_result:
1952
+ return "decision"
1953
+ if has_idea:
1954
+ return "experiment"
1955
+ return "idea"
1956
+ aliases = {
1957
+ "analysis": "analysis-campaign",
1958
+ }
1959
+ resolved_anchor = aliases.get(normalized_anchor, normalized_anchor)
1960
+ allowed = {
1961
+ "scout",
1962
+ "baseline",
1963
+ "idea",
1964
+ "experiment",
1965
+ "analysis-campaign",
1966
+ "write",
1967
+ "finalize",
1968
+ "decision",
1969
+ }
1970
+ if resolved_anchor not in allowed:
1971
+ allowed_text = ", ".join(sorted(allowed | {"auto"}))
1972
+ raise ValueError(f"Unsupported activate_branch anchor `{anchor}`. Allowed values: {allowed_text}.")
1973
+ return resolved_anchor
1974
+
1975
+ def _resolve_branch_activation_target(
1976
+ self,
1977
+ quest_root: Path,
1978
+ *,
1979
+ branch: str | None = None,
1980
+ idea_id: str | None = None,
1981
+ run_id: str | None = None,
1982
+ ) -> dict[str, Any]:
1983
+ provided = sum(
1984
+ 1
1985
+ for value in (
1986
+ str(branch or "").strip(),
1987
+ str(idea_id or "").strip(),
1988
+ str(run_id or "").strip(),
1989
+ )
1990
+ if value
1991
+ )
1992
+ if provided != 1:
1993
+ raise ValueError("activate_branch requires exactly one of `branch`, `idea_id`, or `run_id`.")
1994
+
1995
+ latest_idea: dict[str, Any] | None = None
1996
+ latest_run: dict[str, Any] | None = None
1997
+ normalized_branch = str(branch or "").strip()
1998
+ normalized_idea_id = str(idea_id or "").strip()
1999
+ normalized_run_id = str(run_id or "").strip()
2000
+
2001
+ if normalized_idea_id:
2002
+ candidates = [
2003
+ item for item in self._idea_artifacts(quest_root) if str(item.get("idea_id") or "").strip() == normalized_idea_id
2004
+ ]
2005
+ if not candidates:
2006
+ raise FileNotFoundError(f"Unknown idea `{normalized_idea_id}`.")
2007
+ latest_idea = candidates[-1]
2008
+ normalized_branch = str(latest_idea.get("branch") or "").strip()
2009
+ elif normalized_run_id:
2010
+ candidates = [
2011
+ item for item in self._main_run_artifacts(quest_root) if str(item.get("run_id") or "").strip() == normalized_run_id
2012
+ ]
2013
+ if not candidates:
2014
+ raise FileNotFoundError(f"Unknown main run `{normalized_run_id}`.")
2015
+ latest_run = candidates[-1]
2016
+ normalized_branch = str(latest_run.get("branch") or "").strip()
2017
+ else:
2018
+ if normalized_branch.startswith("analysis/"):
2019
+ raise ValueError(
2020
+ "activate_branch only supports durable idea/main branches. "
2021
+ "Analysis slice branches remain managed by analysis campaigns."
2022
+ )
2023
+ if not branch_exists(quest_root, normalized_branch):
2024
+ raise FileNotFoundError(f"Unknown branch `{normalized_branch}`.")
2025
+
2026
+ if not normalized_branch:
2027
+ raise ValueError("Unable to resolve a durable branch to activate.")
2028
+
2029
+ prepare_record = self._latest_prepare_branch_record(quest_root, normalized_branch)
2030
+ prepare_details = dict(prepare_record.get("details") or {}) if isinstance(prepare_record.get("details"), dict) else {}
2031
+ recorded_parent_branch = (
2032
+ str(prepare_record.get("parent_branch") or prepare_details.get("parent_branch") or "").strip() or None
2033
+ )
2034
+ recorded_branch_kind = (
2035
+ str(prepare_record.get("branch_kind") or prepare_details.get("branch_kind") or "").strip().lower()
2036
+ or self._branch_kind_from_name(normalized_branch)
2037
+ )
2038
+
2039
+ latest_idea = latest_idea or self._latest_idea_for_branch(quest_root, normalized_branch)
2040
+ latest_run = latest_run or self._latest_main_run_for_branch(quest_root, normalized_branch)
2041
+ if not latest_run and recorded_branch_kind == "idea":
2042
+ latest_run = self._latest_child_main_run_for_branch(quest_root, normalized_branch)
2043
+ if not latest_run and recorded_parent_branch:
2044
+ latest_run = self._latest_main_run_for_branch(quest_root, recorded_parent_branch)
2045
+ resolved_idea_id = (
2046
+ normalized_idea_id
2047
+ or str((latest_run or {}).get("idea_id") or "").strip()
2048
+ or str((latest_idea or {}).get("idea_id") or "").strip()
2049
+ or str(prepare_record.get("idea_id") or "").strip()
2050
+ or self._latest_branch_idea_id(quest_root, normalized_branch)
2051
+ or None
2052
+ )
2053
+ idea_paths = dict((latest_idea or {}).get("paths") or {}) if isinstance((latest_idea or {}).get("paths"), dict) else {}
2054
+ recorded_root = (
2055
+ str((latest_idea or {}).get("worktree_root") or "").strip()
2056
+ or str((latest_run or {}).get("worktree_root") or "").strip()
2057
+ or str(prepare_record.get("worktree_root") or "").strip()
2058
+ or None
2059
+ )
2060
+ return {
2061
+ "branch": normalized_branch,
2062
+ "idea_id": resolved_idea_id,
2063
+ "run_id": normalized_run_id or str((latest_run or {}).get("run_id") or "").strip() or None,
2064
+ "has_main_result": bool((latest_run or {}).get("run_id")),
2065
+ "latest_idea": latest_idea,
2066
+ "latest_main_run": latest_run,
2067
+ "branch_kind": recorded_branch_kind,
2068
+ "parent_branch": recorded_parent_branch,
2069
+ "recorded_worktree_root": recorded_root,
2070
+ "idea_md_path": str(idea_paths.get("idea_md") or "").strip() or None,
2071
+ "idea_draft_path": str(idea_paths.get("idea_draft_md") or "").strip() or None,
2072
+ "suggested_worktree_root": self._branch_activation_worktree_root(
2073
+ quest_root,
2074
+ branch_name=normalized_branch,
2075
+ idea_id=resolved_idea_id,
2076
+ run_id=(
2077
+ normalized_run_id
2078
+ or str(prepare_record.get("run_id") or "").strip()
2079
+ or str((latest_run or {}).get("run_id") or "").strip()
2080
+ or None
2081
+ ),
2082
+ ),
2083
+ }
2084
+
1280
2085
  def _normalize_foundation_ref(self, foundation_ref: dict[str, Any] | str | None) -> dict[str, Any]:
1281
2086
  if foundation_ref is None:
1282
2087
  return {"kind": "current_head", "ref": None}
@@ -1445,6 +2250,17 @@ class ArtifactService:
1445
2250
  ]
1446
2251
  return candidates[-1] if candidates else None
1447
2252
 
2253
+ def _latest_child_main_run_for_branch(self, quest_root: Path, branch_name: str) -> dict[str, Any] | None:
2254
+ normalized_branch = str(branch_name or "").strip()
2255
+ if not normalized_branch:
2256
+ return None
2257
+ candidates = [
2258
+ item
2259
+ for item in self._main_run_artifacts(quest_root)
2260
+ if str(item.get("parent_branch") or "").strip() == normalized_branch
2261
+ ]
2262
+ return candidates[-1] if candidates else None
2263
+
1448
2264
  def _latest_idea_for_branch(self, quest_root: Path, branch_name: str) -> dict[str, Any] | None:
1449
2265
  normalized_branch = str(branch_name or "").strip()
1450
2266
  if not normalized_branch:
@@ -1506,8 +2322,19 @@ class ArtifactService:
1506
2322
  ) -> tuple[str, Path, str | None]:
1507
2323
  current_root_raw = str(state.get("current_workspace_root") or "").strip()
1508
2324
  head_root_raw = str(state.get("research_head_worktree_root") or "").strip()
2325
+ paper_parent_root_raw = str(state.get("paper_parent_worktree_root") or "").strip()
2326
+ current_branch_raw = str(state.get("current_workspace_branch") or "").strip()
2327
+ research_head_branch_raw = str(state.get("research_head_branch") or "").strip()
2328
+ paper_parent_branch_raw = str(state.get("paper_parent_branch") or "").strip()
2329
+ workspace_mode = str(state.get("workspace_mode") or "").strip().lower()
2330
+ prefer_paper_parent = workspace_mode == "paper" or self._branch_kind_from_name(current_branch_raw) == "paper"
1509
2331
  parent_worktree_root: Path | None = None
1510
- for raw in (current_root_raw, head_root_raw):
2332
+ root_candidates = (
2333
+ (paper_parent_root_raw, head_root_raw, current_root_raw)
2334
+ if prefer_paper_parent
2335
+ else (current_root_raw, head_root_raw, paper_parent_root_raw)
2336
+ )
2337
+ for raw in root_candidates:
1511
2338
  if not raw:
1512
2339
  continue
1513
2340
  candidate = Path(raw)
@@ -1518,15 +2345,36 @@ class ArtifactService:
1518
2345
  parent_worktree_root = self._workspace_root_for(quest_root)
1519
2346
 
1520
2347
  parent_branch = (
1521
- str(state.get("current_workspace_branch") or "").strip()
1522
- or str(state.get("research_head_branch") or "").strip()
1523
- or current_branch(parent_worktree_root)
1524
- or current_branch(self._workspace_root_for(quest_root))
2348
+ (
2349
+ paper_parent_branch_raw
2350
+ or research_head_branch_raw
2351
+ or current_branch_raw
2352
+ or current_branch(parent_worktree_root)
2353
+ or current_branch(self._workspace_root_for(quest_root))
2354
+ )
2355
+ if prefer_paper_parent
2356
+ else (
2357
+ current_branch_raw
2358
+ or research_head_branch_raw
2359
+ or paper_parent_branch_raw
2360
+ or current_branch(parent_worktree_root)
2361
+ or current_branch(self._workspace_root_for(quest_root))
2362
+ )
1525
2363
  )
1526
2364
  parent_branch = str(parent_branch or "").strip()
1527
2365
  if not parent_branch:
1528
2366
  raise ValueError("Unable to resolve a parent branch for the analysis campaign.")
1529
2367
 
2368
+ if self._branch_kind_from_name(parent_branch) == "idea":
2369
+ latest_child_run = self._latest_child_main_run_for_branch(quest_root, parent_branch)
2370
+ if isinstance(latest_child_run, dict) and str(latest_child_run.get("branch") or "").strip():
2371
+ parent_branch = str(latest_child_run.get("branch") or "").strip()
2372
+ recorded_worktree_root = str(latest_child_run.get("worktree_root") or "").strip()
2373
+ if recorded_worktree_root:
2374
+ candidate = Path(recorded_worktree_root)
2375
+ if candidate.exists():
2376
+ parent_worktree_root = candidate
2377
+
1530
2378
  idea_id = self._latest_branch_idea_id(quest_root, parent_branch) or str(state.get("active_idea_id") or "").strip() or None
1531
2379
  return parent_branch, parent_worktree_root, idea_id
1532
2380
 
@@ -1568,15 +2416,22 @@ class ArtifactService:
1568
2416
  state=state,
1569
2417
  foundation_ref={"kind": "idea", "ref": str(latest_idea.get("idea_id") or "").strip()},
1570
2418
  )
2419
+ current_workspace_branch = str(state.get("current_workspace_branch") or "").strip()
2420
+ research_head_branch = str(state.get("research_head_branch") or "").strip()
1571
2421
  active_branch = (
1572
- str(state.get("research_head_branch") or "").strip()
1573
- or str(state.get("current_workspace_branch") or "").strip()
2422
+ current_workspace_branch
2423
+ or research_head_branch
2424
+ or current_branch(self._workspace_root_for(quest_root))
1574
2425
  )
1575
2426
  if normalized_branch and active_branch and normalized_branch == active_branch:
1576
2427
  return self._resolve_idea_foundation(
1577
2428
  quest_root,
1578
2429
  state=state,
1579
- foundation_ref=None,
2430
+ foundation_ref=(
2431
+ {"kind": "branch", "ref": normalized_branch}
2432
+ if current_workspace_branch and research_head_branch and current_workspace_branch != research_head_branch
2433
+ else None
2434
+ ),
1580
2435
  )
1581
2436
  return self._resolve_idea_foundation(
1582
2437
  quest_root,
@@ -1614,8 +2469,8 @@ class ArtifactService:
1614
2469
  ) -> tuple[str, str, dict[str, Any]]:
1615
2470
  normalized_intent = self._normalize_lineage_intent(lineage_intent) or "continue_line"
1616
2471
  active_branch = (
1617
- str(state.get("research_head_branch") or "").strip()
1618
- or str(state.get("current_workspace_branch") or "").strip()
2472
+ str(state.get("current_workspace_branch") or "").strip()
2473
+ or str(state.get("research_head_branch") or "").strip()
1619
2474
  )
1620
2475
  if not active_branch:
1621
2476
  active_branch = current_branch(self._workspace_root_for(quest_root))
@@ -1643,6 +2498,7 @@ class ArtifactService:
1643
2498
  def list_research_branches(self, quest_root: Path) -> dict[str, Any]:
1644
2499
  state = self.quest_service.read_research_state(quest_root)
1645
2500
  active_head_branch = str(state.get("research_head_branch") or "").strip() or None
2501
+ active_workspace_branch = str(state.get("current_workspace_branch") or "").strip() or None
1646
2502
  idea_records = self._idea_artifacts(quest_root)
1647
2503
  main_runs = self._main_run_artifacts(quest_root)
1648
2504
 
@@ -1709,6 +2565,7 @@ class ArtifactService:
1709
2565
  "verdict": record.get("verdict"),
1710
2566
  "status": record.get("status"),
1711
2567
  "idea_id": record.get("idea_id"),
2568
+ "parent_branch": record.get("parent_branch"),
1712
2569
  "primary_metric_id": details.get("primary_metric_id"),
1713
2570
  "primary_value": details.get("primary_value"),
1714
2571
  "delta_vs_baseline": details.get("delta_vs_baseline"),
@@ -1721,6 +2578,8 @@ class ArtifactService:
1721
2578
 
1722
2579
  if active_head_branch:
1723
2580
  ensure_branch_entry(active_head_branch)
2581
+ if active_workspace_branch:
2582
+ ensure_branch_entry(active_workspace_branch)
1724
2583
 
1725
2584
  ordered_branches = sorted(
1726
2585
  grouped.values(),
@@ -1756,10 +2615,15 @@ class ArtifactService:
1756
2615
  else {}
1757
2616
  )
1758
2617
  parent_branch = str(latest_idea.get("parent_branch") or "").strip() or None
2618
+ experiment_parent_branch = (
2619
+ str((latest_experiment or {}).get("parent_branch") or "").strip()
2620
+ if isinstance(latest_experiment, dict)
2621
+ else None
2622
+ ) or None
1759
2623
  foundation_branch = (
1760
2624
  str(latest_foundation.get("branch") or latest_foundation.get("ref") or "").strip() or None
1761
2625
  )
1762
- resolved_parent_branch = parent_branch or foundation_branch
2626
+ resolved_parent_branch = parent_branch or experiment_parent_branch or foundation_branch
1763
2627
  has_main_result = isinstance(latest_experiment, dict) and bool(latest_experiment.get("run_id"))
1764
2628
  numeric_branch_no = recorded_branch_numbers.get(branch_name)
1765
2629
  if numeric_branch_no is None:
@@ -1774,7 +2638,8 @@ class ArtifactService:
1774
2638
  "branch_name": branch_name,
1775
2639
  "worktree_root": item.get("worktree_root"),
1776
2640
  "is_active_head": branch_name == active_head_branch,
1777
- "idea_id": latest_idea.get("idea_id"),
2641
+ "is_active_workspace": branch_name == active_workspace_branch,
2642
+ "idea_id": latest_idea.get("idea_id") or (latest_experiment.get("idea_id") if isinstance(latest_experiment, dict) else None),
1778
2643
  "idea_title": latest_idea.get("title"),
1779
2644
  "idea_problem": latest_idea.get("problem"),
1780
2645
  "next_target": latest_idea.get("next_target"),
@@ -1810,6 +2675,7 @@ class ArtifactService:
1810
2675
  return {
1811
2676
  "ok": True,
1812
2677
  "active_head_branch": active_head_branch,
2678
+ "active_workspace_branch": active_workspace_branch,
1813
2679
  "count": len(branches),
1814
2680
  "branches": branches,
1815
2681
  }
@@ -1819,9 +2685,10 @@ class ArtifactService:
1819
2685
  snapshot = self.quest_service.snapshot(self._quest_id(quest_root))
1820
2686
  active_campaign_id = str(state.get("active_analysis_campaign_id") or "").strip() or None
1821
2687
  analysis_parent_branch = str(state.get("analysis_parent_branch") or "").strip() or None
2688
+ paper_parent_branch = str(state.get("paper_parent_branch") or "").strip() or None
1822
2689
  current_workspace_branch = str(state.get("current_workspace_branch") or "").strip() or None
1823
2690
  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
2691
+ canonical_branch = analysis_parent_branch or paper_parent_branch or current_workspace_branch or research_head_branch
1825
2692
  latest_main_run = self._latest_main_run_for_branch(quest_root, canonical_branch or "")
1826
2693
  selected_outline = read_json(self._paper_selected_outline_path(quest_root), {})
1827
2694
  selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
@@ -1888,32 +2755,82 @@ class ArtifactService:
1888
2755
  }
1889
2756
 
1890
2757
  def list_paper_outlines(self, quest_root: Path) -> dict[str, Any]:
1891
- selected_outline = read_json(self._paper_selected_outline_path(quest_root), {})
2758
+ selected_outline_path = self._paper_selected_outline_path(quest_root)
2759
+ selected_outline = read_json(selected_outline_path, {})
1892
2760
  selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
1893
- outlines: list[dict[str, Any]] = []
1894
- for status, root in (
1895
- ("candidate", self._paper_outline_candidates_root(quest_root)),
1896
- ("revised", self._paper_outline_revisions_root(quest_root)),
1897
- ):
1898
- for path in sorted(root.glob("outline-*.json")):
1899
- record = read_json(path, {})
1900
- if not isinstance(record, dict) or not record:
2761
+ if not selected_outline:
2762
+ fallback_selected_outline_path = quest_root / "paper" / "selected_outline.json"
2763
+ fallback_selected_outline = read_json(fallback_selected_outline_path, {})
2764
+ if isinstance(fallback_selected_outline, dict) and fallback_selected_outline:
2765
+ selected_outline = fallback_selected_outline
2766
+ selected_outline_path = fallback_selected_outline_path
2767
+
2768
+ selected_outline_id = str(selected_outline.get("outline_id") or "").strip()
2769
+ status_rank = {"candidate": 1, "revised": 2, "selected": 3}
2770
+ outlines_by_id: dict[str, dict[str, Any]] = {}
2771
+ seen_paper_roots: set[str] = set()
2772
+ paper_roots: list[Path] = []
2773
+ for root in (self._paper_root(quest_root), quest_root / "paper"):
2774
+ try:
2775
+ key = str(root.resolve())
2776
+ except FileNotFoundError:
2777
+ key = str(root)
2778
+ if key in seen_paper_roots:
2779
+ continue
2780
+ seen_paper_roots.add(key)
2781
+ paper_roots.append(root)
2782
+
2783
+ for paper_root in paper_roots:
2784
+ for default_status, relative_parts in (
2785
+ ("candidate", ("outlines", "candidates")),
2786
+ ("revised", ("outlines", "revisions")),
2787
+ ):
2788
+ root = paper_root.joinpath(*relative_parts)
2789
+ if not root.exists():
1901
2790
  continue
1902
- outline_id = str(record.get("outline_id") or path.stem).strip() or path.stem
1903
- outlines.append(
1904
- {
2791
+ for path in sorted(root.glob("outline-*.json")):
2792
+ record = read_json(path, {})
2793
+ if not isinstance(record, dict) or not record:
2794
+ continue
2795
+ outline_id = str(record.get("outline_id") or path.stem).strip() or path.stem
2796
+ item = {
1905
2797
  "outline_id": outline_id,
1906
2798
  "title": str(record.get("title") or outline_id).strip() or outline_id,
1907
- "status": str(record.get("status") or status).strip() or status,
2799
+ "status": str(record.get("status") or default_status).strip() or default_status,
1908
2800
  "review_result": str(record.get("review_result") or "").strip() or None,
1909
2801
  "path": str(path),
1910
- "is_selected": outline_id == str(selected_outline.get("outline_id") or "").strip(),
2802
+ "is_selected": outline_id == selected_outline_id,
1911
2803
  }
1912
- )
2804
+ current = outlines_by_id.get(outline_id)
2805
+ if current is None or status_rank.get(str(item.get("status") or ""), 0) >= status_rank.get(
2806
+ str(current.get("status") or ""),
2807
+ 0,
2808
+ ):
2809
+ outlines_by_id[outline_id] = item
2810
+
2811
+ if selected_outline_id:
2812
+ selected_item = {
2813
+ "outline_id": selected_outline_id,
2814
+ "title": str(selected_outline.get("title") or selected_outline_id).strip() or selected_outline_id,
2815
+ "status": str(selected_outline.get("status") or "selected").strip() or "selected",
2816
+ "review_result": str(selected_outline.get("review_result") or "").strip() or None,
2817
+ "path": str(selected_outline_path),
2818
+ "is_selected": True,
2819
+ }
2820
+ current = outlines_by_id.get(selected_outline_id)
2821
+ if current is None or status_rank.get(str(selected_item.get("status") or ""), 0) >= status_rank.get(
2822
+ str(current.get("status") or ""),
2823
+ 0,
2824
+ ):
2825
+ outlines_by_id[selected_outline_id] = selected_item
2826
+ else:
2827
+ current["is_selected"] = True
2828
+
2829
+ outlines = list(outlines_by_id.values())
1913
2830
  outlines.sort(key=lambda item: (str(item.get("outline_id") or ""), str(item.get("status") or "")))
1914
2831
  return {
1915
2832
  "ok": True,
1916
- "selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
2833
+ "selected_outline_ref": selected_outline_id or None,
1917
2834
  "selected_outline": selected_outline or None,
1918
2835
  "count": len(outlines),
1919
2836
  "outlines": outlines,
@@ -2139,65 +3056,438 @@ class ArtifactService:
2139
3056
  self._touch_quest_updated_at(quest_root)
2140
3057
  return {
2141
3058
  "ok": True,
2142
- "message": message,
2143
- "guidance": "Checkpoint created. Continue from the updated quest branch state.",
2144
- **result,
3059
+ "message": message,
3060
+ "guidance": "Checkpoint created. Continue from the updated quest branch state.",
3061
+ **result,
3062
+ }
3063
+
3064
+ def prepare_branch(
3065
+ self,
3066
+ quest_root: Path,
3067
+ *,
3068
+ run_id: str | None = None,
3069
+ idea_id: str | None = None,
3070
+ branch: str | None = None,
3071
+ branch_kind: str = "run",
3072
+ create_worktree_flag: bool = True,
3073
+ start_point: str | None = None,
3074
+ ) -> dict:
3075
+ state = self.quest_service.read_research_state(quest_root)
3076
+ parent_branch = (
3077
+ str(start_point or "").strip()
3078
+ or str(state.get("current_workspace_branch") or "").strip()
3079
+ or str(state.get("research_head_branch") or "").strip()
3080
+ or current_branch(self._workspace_root_for(quest_root))
3081
+ or current_branch(quest_root)
3082
+ )
3083
+ start_ref = start_point or parent_branch
3084
+ branch_name = branch or self._default_branch_name(quest_root, run_id=run_id, idea_id=idea_id, branch_kind=branch_kind)
3085
+ branch_result = ensure_branch(quest_root, branch_name, start_point=start_ref, checkout=False)
3086
+ worktree_result = None
3087
+ worktree_root = None
3088
+ if create_worktree_flag:
3089
+ worktree_root = self._prepare_branch_worktree_root(
3090
+ quest_root,
3091
+ branch_name=branch_name,
3092
+ branch_kind=branch_kind,
3093
+ run_id=run_id,
3094
+ idea_id=idea_id,
3095
+ )
3096
+ worktree_result = create_worktree(
3097
+ quest_root,
3098
+ branch=branch_name,
3099
+ worktree_root=worktree_root,
3100
+ start_point=start_ref,
3101
+ )
3102
+ artifact_result = self.record(
3103
+ quest_root,
3104
+ {
3105
+ "kind": "decision",
3106
+ "status": "prepared",
3107
+ "verdict": "prepared",
3108
+ "action": "prepare_branch",
3109
+ "reason": f"Prepared branch `{branch_name}` for the next quest step.",
3110
+ "branch": branch_name,
3111
+ "run_id": run_id,
3112
+ "idea_id": idea_id,
3113
+ "branch_kind": branch_kind,
3114
+ "parent_branch": parent_branch,
3115
+ "start_point": start_ref,
3116
+ "worktree_root": str(worktree_root) if worktree_root else None,
3117
+ "workspace_mode": self._workspace_mode_for_branch(branch_name, has_idea=bool(idea_id)),
3118
+ "source": {"kind": "system", "role": "artifact"},
3119
+ },
3120
+ checkpoint=False,
3121
+ workspace_root=worktree_root if worktree_root else None,
3122
+ )
3123
+ return {
3124
+ "ok": True,
3125
+ "branch": branch_name,
3126
+ "branch_result": branch_result,
3127
+ "worktree": worktree_result,
3128
+ "worktree_root": str(worktree_root) if worktree_root else None,
3129
+ "parent_branch": parent_branch,
3130
+ "start_point": start_ref,
3131
+ "guidance": "Use this branch/worktree for the isolated idea or run. Keep durable outputs under quest_root.",
3132
+ "artifact": artifact_result,
3133
+ }
3134
+
3135
+ def activate_branch(
3136
+ self,
3137
+ quest_root: Path,
3138
+ *,
3139
+ branch: str | None = None,
3140
+ idea_id: str | None = None,
3141
+ run_id: str | None = None,
3142
+ anchor: str | None = "auto",
3143
+ promote_to_head: bool = False,
3144
+ create_worktree_if_missing: bool = True,
3145
+ ) -> dict[str, Any]:
3146
+ state = self.quest_service.read_research_state(quest_root)
3147
+ active_campaign_id = str(state.get("active_analysis_campaign_id") or "").strip() or None
3148
+ if active_campaign_id:
3149
+ raise ValueError(
3150
+ "activate_branch cannot run while an analysis campaign is active. "
3151
+ "Finish or close the campaign first."
3152
+ )
3153
+
3154
+ target = self._resolve_branch_activation_target(
3155
+ quest_root,
3156
+ branch=branch,
3157
+ idea_id=idea_id,
3158
+ run_id=run_id,
3159
+ )
3160
+ branch_name = str(target.get("branch") or "").strip()
3161
+ if str(target.get("branch_kind") or self._branch_kind_from_name(branch_name)).strip().lower() != "paper":
3162
+ self._require_baseline_gate_open(quest_root, action="activate_branch")
3163
+ resolved_idea_id = str(target.get("idea_id") or "").strip() or None
3164
+ latest_main_run = (
3165
+ dict(target.get("latest_main_run") or {})
3166
+ if isinstance(target.get("latest_main_run"), dict)
3167
+ else {}
3168
+ )
3169
+ latest_idea = (
3170
+ dict(target.get("latest_idea") or {})
3171
+ if isinstance(target.get("latest_idea"), dict)
3172
+ else {}
3173
+ )
3174
+ branch_kind = str(target.get("branch_kind") or self._branch_kind_from_name(branch_name)).strip().lower() or "branch"
3175
+ source_parent_branch = str(target.get("parent_branch") or "").strip() or None
3176
+
3177
+ workspace_root = self._branch_workspace_root(quest_root, branch_name)
3178
+ worktree_result = None
3179
+ worktree_created = False
3180
+ if workspace_root is None:
3181
+ recorded_root = str(target.get("recorded_worktree_root") or "").strip()
3182
+ if recorded_root:
3183
+ candidate = Path(recorded_root)
3184
+ if candidate.exists():
3185
+ workspace_root = candidate
3186
+ if workspace_root is None:
3187
+ if not create_worktree_if_missing:
3188
+ raise FileNotFoundError(
3189
+ f"No existing worktree is available for branch `{branch_name}` and create_worktree_if_missing=False."
3190
+ )
3191
+ workspace_root = Path(target.get("suggested_worktree_root") or "")
3192
+ worktree_result = create_worktree(
3193
+ quest_root,
3194
+ branch=branch_name,
3195
+ worktree_root=workspace_root,
3196
+ start_point=branch_name,
3197
+ )
3198
+ if not bool(worktree_result.get("ok")):
3199
+ raise RuntimeError(
3200
+ f"Failed to activate branch `{branch_name}`: {worktree_result.get('stderr') or 'worktree creation failed.'}"
3201
+ )
3202
+ worktree_created = True
3203
+
3204
+ resolved_workspace_root = workspace_root or quest_root
3205
+ idea_md_path = (
3206
+ str(target.get("idea_md_path") or "").strip()
3207
+ or str((dict(latest_idea.get("paths") or {}) if isinstance(latest_idea.get("paths"), dict) else {}).get("idea_md") or "").strip()
3208
+ or (str(resolved_workspace_root / "memory" / "ideas" / resolved_idea_id / "idea.md") if resolved_idea_id else "")
3209
+ )
3210
+ idea_draft_path = (
3211
+ str(target.get("idea_draft_path") or "").strip()
3212
+ or str((dict(latest_idea.get("paths") or {}) if isinstance(latest_idea.get("paths"), dict) else {}).get("idea_draft_md") or "").strip()
3213
+ or (str(resolved_workspace_root / "memory" / "ideas" / resolved_idea_id / "draft.md") if resolved_idea_id else "")
3214
+ )
3215
+ resolved_idea_md_path = idea_md_path if resolved_idea_id else None
3216
+ resolved_idea_draft_path = idea_draft_path if resolved_idea_id else None
3217
+ has_main_result = bool(latest_main_run.get("run_id"))
3218
+ if branch_kind == "paper":
3219
+ next_anchor = "write" if str(anchor or "auto").strip().lower() == "auto" else self._resolve_activate_branch_anchor(
3220
+ anchor=anchor,
3221
+ has_idea=bool(resolved_idea_id),
3222
+ has_main_result=has_main_result,
3223
+ )
3224
+ else:
3225
+ next_anchor = self._resolve_activate_branch_anchor(
3226
+ anchor=anchor,
3227
+ has_idea=bool(resolved_idea_id),
3228
+ has_main_result=has_main_result,
3229
+ )
3230
+ workspace_mode = self._workspace_mode_for_branch(branch_name, has_idea=bool(resolved_idea_id))
3231
+ source_run_id = (
3232
+ str(target.get("run_id") or "").strip()
3233
+ or str(latest_main_run.get("run_id") or "").strip()
3234
+ or None
3235
+ )
3236
+
3237
+ artifact = self.record(
3238
+ quest_root,
3239
+ {
3240
+ "kind": "decision",
3241
+ "status": "completed",
3242
+ "verdict": "continue",
3243
+ "action": "activate_branch",
3244
+ "summary": f"Activated durable branch `{branch_name}` as the current workspace.",
3245
+ "reason": (
3246
+ "Return to an existing research branch without creating a new lineage node, "
3247
+ "so follow-up experiments or decisions continue from the correct historical context."
3248
+ ),
3249
+ "idea_id": resolved_idea_id,
3250
+ "run_id": str(latest_main_run.get("run_id") or "").strip() or None,
3251
+ "branch": branch_name,
3252
+ "worktree_root": str(resolved_workspace_root),
3253
+ "worktree_rel_path": self._workspace_relative(quest_root, resolved_workspace_root),
3254
+ "flow_type": "branch_activation",
3255
+ "protocol_step": "activate",
3256
+ "details": {
3257
+ "activate_branch_by": (
3258
+ "idea_id"
3259
+ if str(idea_id or "").strip()
3260
+ else "run_id"
3261
+ if str(run_id or "").strip()
3262
+ else "branch"
3263
+ ),
3264
+ "promote_to_head": bool(promote_to_head),
3265
+ "worktree_created": worktree_created,
3266
+ "next_anchor": next_anchor,
3267
+ "workspace_mode": workspace_mode,
3268
+ "latest_main_run_id": str(latest_main_run.get("run_id") or "").strip() or None,
3269
+ "branch_kind": branch_kind,
3270
+ "paper_parent_branch": source_parent_branch if branch_kind == "paper" else None,
3271
+ },
3272
+ },
3273
+ checkpoint=False,
3274
+ workspace_root=resolved_workspace_root,
3275
+ )
3276
+
3277
+ research_state_updates: dict[str, Any] = {
3278
+ "active_idea_id": resolved_idea_id,
3279
+ "current_workspace_branch": branch_name,
3280
+ "current_workspace_root": str(resolved_workspace_root),
3281
+ "active_idea_md_path": resolved_idea_md_path,
3282
+ "active_idea_draft_path": resolved_idea_draft_path,
3283
+ "active_analysis_campaign_id": None,
3284
+ "analysis_parent_branch": None,
3285
+ "analysis_parent_worktree_root": None,
3286
+ "paper_parent_branch": source_parent_branch if branch_kind == "paper" else None,
3287
+ "paper_parent_worktree_root": (
3288
+ str(self._branch_workspace_root(quest_root, source_parent_branch))
3289
+ if branch_kind == "paper" and source_parent_branch and self._branch_workspace_root(quest_root, source_parent_branch)
3290
+ else None
3291
+ ),
3292
+ "paper_parent_run_id": source_run_id if branch_kind == "paper" else None,
3293
+ "next_pending_slice_id": None,
3294
+ "workspace_mode": workspace_mode,
3295
+ "last_flow_type": "branch_activation",
3296
+ }
3297
+ if promote_to_head:
3298
+ research_state_updates["research_head_branch"] = branch_name
3299
+ research_state_updates["research_head_worktree_root"] = str(resolved_workspace_root)
3300
+ research_state = self.quest_service.update_research_state(quest_root, **research_state_updates)
3301
+ self.quest_service.update_settings(self._quest_id(quest_root), active_anchor=next_anchor)
3302
+
3303
+ interaction = self.interact(
3304
+ quest_root,
3305
+ kind="milestone",
3306
+ message=(
3307
+ f"Activated branch `{branch_name}`.\n"
3308
+ f"- Worktree: `{resolved_workspace_root}`\n"
3309
+ f"- Active idea: `{resolved_idea_id or 'none'}`\n"
3310
+ f"- Latest main run: `{str(latest_main_run.get('run_id') or '').strip() or 'none'}`\n"
3311
+ f"- Promoted to head: `{bool(promote_to_head)}`\n"
3312
+ f"- Next anchor: `{next_anchor}`"
3313
+ ),
3314
+ deliver_to_bound_conversations=True,
3315
+ include_recent_inbound_messages=False,
3316
+ attachments=[
3317
+ {
3318
+ "kind": "branch_activation",
3319
+ "branch": branch_name,
3320
+ "worktree_root": str(resolved_workspace_root),
3321
+ "idea_id": resolved_idea_id,
3322
+ "latest_main_run_id": str(latest_main_run.get("run_id") or "").strip() or None,
3323
+ "next_anchor": next_anchor,
3324
+ "promote_to_head": bool(promote_to_head),
3325
+ }
3326
+ ],
3327
+ )
3328
+ return {
3329
+ "ok": True,
3330
+ "branch": branch_name,
3331
+ "worktree_root": str(resolved_workspace_root),
3332
+ "idea_id": resolved_idea_id,
3333
+ "latest_main_run_id": str(latest_main_run.get("run_id") or "").strip() or None,
3334
+ "branch_kind": branch_kind,
3335
+ "source_parent_branch": source_parent_branch,
3336
+ "idea_md_path": resolved_idea_md_path,
3337
+ "idea_draft_path": resolved_idea_draft_path,
3338
+ "workspace_mode": workspace_mode,
3339
+ "next_anchor": next_anchor,
3340
+ "promote_to_head": bool(promote_to_head),
3341
+ "worktree_created": worktree_created,
3342
+ "worktree": worktree_result,
3343
+ "artifact": artifact,
3344
+ "interaction": interaction,
3345
+ "research_state": research_state,
2145
3346
  }
2146
3347
 
2147
- def prepare_branch(
3348
+ def _promote_workspace_to_run_branch(
2148
3349
  self,
2149
3350
  quest_root: Path,
2150
3351
  *,
2151
- run_id: str | None = None,
2152
- idea_id: str | None = None,
2153
- branch: str | None = None,
2154
- branch_kind: str = "run",
2155
- create_worktree_flag: bool = True,
2156
- start_point: str | None = None,
2157
- ) -> dict:
2158
- parent_branch = current_branch(quest_root)
2159
- start_ref = start_point or parent_branch
2160
- branch_name = branch or self._default_branch_name(quest_root, run_id=run_id, idea_id=idea_id, branch_kind=branch_kind)
2161
- branch_result = ensure_branch(quest_root, branch_name, start_point=start_ref, checkout=False)
2162
- worktree_result = None
2163
- worktree_root = None
2164
- if create_worktree_flag:
2165
- worktree_root = canonical_worktree_root(quest_root, run_id or branch_name)
2166
- worktree_result = create_worktree(
2167
- quest_root,
2168
- branch=branch_name,
2169
- worktree_root=worktree_root,
2170
- start_point=start_ref,
3352
+ run_id: str,
3353
+ idea_id: str | None,
3354
+ workspace_root: Path,
3355
+ current_branch_name: str,
3356
+ ) -> tuple[str, str | None, bool]:
3357
+ branch_kind = self._branch_kind_from_name(current_branch_name)
3358
+ if branch_kind == "paper":
3359
+ raise ValueError(
3360
+ "record_main_experiment cannot run while the active workspace is a paper branch. "
3361
+ "Return to the evidence branch or create a new run branch first."
2171
3362
  )
2172
- artifact_result = self.record(
3363
+ if branch_kind == "run":
3364
+ prepare_record = self._latest_prepare_branch_record(quest_root, current_branch_name)
3365
+ parent_branch = str(prepare_record.get("parent_branch") or "").strip() or None
3366
+ return current_branch_name, parent_branch, False
3367
+
3368
+ target_branch = self._default_branch_name(quest_root, run_id=run_id, idea_id=idea_id, branch_kind="run")
3369
+ if branch_exists(quest_root, target_branch):
3370
+ raise ValueError(
3371
+ f"Run branch `{target_branch}` already exists. Reuse that run branch or choose a new `run_id`."
3372
+ )
3373
+
3374
+ ensure_branch(quest_root, target_branch, start_point=current_branch_name, checkout=False)
3375
+ run_command(["git", "switch", target_branch], cwd=workspace_root, check=True)
3376
+ self.record(
2173
3377
  quest_root,
2174
3378
  {
2175
3379
  "kind": "decision",
2176
3380
  "status": "prepared",
2177
3381
  "verdict": "prepared",
2178
3382
  "action": "prepare_branch",
2179
- "reason": f"Prepared branch `{branch_name}` for the next quest step.",
2180
- "branch": branch_name,
3383
+ "reason": f"Materialized a dedicated main-experiment branch `{target_branch}` before durable recording.",
3384
+ "branch": target_branch,
2181
3385
  "run_id": run_id,
2182
3386
  "idea_id": idea_id,
2183
- "branch_kind": branch_kind,
2184
- "parent_branch": parent_branch,
2185
- "start_point": start_ref,
2186
- "worktree_root": str(worktree_root) if worktree_root else None,
3387
+ "branch_kind": "run",
3388
+ "parent_branch": current_branch_name,
3389
+ "start_point": current_branch_name,
3390
+ "worktree_root": str(workspace_root),
3391
+ "workspace_mode": "run",
2187
3392
  "source": {"kind": "system", "role": "artifact"},
2188
3393
  },
2189
3394
  checkpoint=False,
3395
+ workspace_root=workspace_root,
3396
+ )
3397
+ self.quest_service.update_research_state(
3398
+ quest_root,
3399
+ active_idea_id=idea_id,
3400
+ current_workspace_branch=target_branch,
3401
+ current_workspace_root=str(workspace_root),
3402
+ research_head_branch=target_branch,
3403
+ research_head_worktree_root=str(workspace_root),
3404
+ active_analysis_campaign_id=None,
3405
+ analysis_parent_branch=None,
3406
+ analysis_parent_worktree_root=None,
3407
+ paper_parent_branch=None,
3408
+ paper_parent_worktree_root=None,
3409
+ paper_parent_run_id=None,
3410
+ workspace_mode="run",
3411
+ last_flow_type="main_experiment_branch",
3412
+ )
3413
+ return target_branch, current_branch_name, True
3414
+
3415
+ def _ensure_active_paper_workspace(
3416
+ self,
3417
+ quest_root: Path,
3418
+ *,
3419
+ source_branch: str | None = None,
3420
+ source_run_id: str | None = None,
3421
+ source_idea_id: str | None = None,
3422
+ ) -> dict[str, Any]:
3423
+ state = self.quest_service.read_research_state(quest_root)
3424
+ current_branch_name = (
3425
+ str(state.get("current_workspace_branch") or "").strip()
3426
+ or current_branch(self._workspace_root_for(quest_root))
3427
+ )
3428
+ current_workspace_root = self._workspace_root_for(quest_root)
3429
+ if (
3430
+ str(state.get("workspace_mode") or "").strip() == "paper"
3431
+ and self._branch_kind_from_name(current_branch_name) == "paper"
3432
+ ):
3433
+ return {
3434
+ "ok": True,
3435
+ "branch": current_branch_name,
3436
+ "worktree_root": str(current_workspace_root),
3437
+ "source_branch": str(state.get("paper_parent_branch") or "").strip() or None,
3438
+ "source_run_id": str(state.get("paper_parent_run_id") or "").strip() or None,
3439
+ "source_idea_id": str(state.get("active_idea_id") or "").strip() or None,
3440
+ }
3441
+
3442
+ resolved_source_branch = (
3443
+ str(source_branch or "").strip()
3444
+ or str(state.get("paper_parent_branch") or "").strip()
3445
+ or str(state.get("current_workspace_branch") or "").strip()
3446
+ or str(state.get("research_head_branch") or "").strip()
3447
+ or current_branch(current_workspace_root)
3448
+ )
3449
+ if not resolved_source_branch:
3450
+ raise ValueError("Unable to resolve the source branch for the paper workspace.")
3451
+
3452
+ latest_main_run = self._latest_main_run_for_branch(quest_root, resolved_source_branch)
3453
+ resolved_run_id = (
3454
+ str(source_run_id or "").strip()
3455
+ or str((latest_main_run or {}).get("run_id") or "").strip()
3456
+ or None
3457
+ )
3458
+ resolved_idea_id = (
3459
+ str(source_idea_id or "").strip()
3460
+ or str((latest_main_run or {}).get("idea_id") or "").strip()
3461
+ or str(state.get("active_idea_id") or "").strip()
3462
+ or None
3463
+ )
3464
+ paper_branch = (
3465
+ self._default_branch_name(quest_root, run_id=resolved_run_id, idea_id=resolved_idea_id, branch_kind="paper")
3466
+ if resolved_run_id
3467
+ else f"paper/{slugify(resolved_source_branch, 'paper')}"
3468
+ )
3469
+ if not branch_exists(quest_root, paper_branch):
3470
+ self.prepare_branch(
3471
+ quest_root,
3472
+ run_id=resolved_run_id,
3473
+ idea_id=resolved_idea_id,
3474
+ branch=paper_branch,
3475
+ branch_kind="paper",
3476
+ create_worktree_flag=True,
3477
+ start_point=resolved_source_branch,
3478
+ )
3479
+ activated = self.activate_branch(
3480
+ quest_root,
3481
+ branch=paper_branch,
3482
+ anchor="write",
3483
+ promote_to_head=False,
3484
+ create_worktree_if_missing=True,
2190
3485
  )
2191
3486
  return {
2192
- "ok": True,
2193
- "branch": branch_name,
2194
- "branch_result": branch_result,
2195
- "worktree": worktree_result,
2196
- "worktree_root": str(worktree_root) if worktree_root else None,
2197
- "parent_branch": parent_branch,
2198
- "start_point": start_ref,
2199
- "guidance": "Use this branch/worktree for the isolated idea or run. Keep durable outputs under quest_root.",
2200
- "artifact": artifact_result,
3487
+ **activated,
3488
+ "source_branch": resolved_source_branch,
3489
+ "source_run_id": resolved_run_id,
3490
+ "source_idea_id": resolved_idea_id,
2201
3491
  }
2202
3492
 
2203
3493
  def submit_idea(
@@ -2235,8 +3525,8 @@ class ArtifactService:
2235
3525
  if normalized_mode == "create":
2236
3526
  resolved_idea_id = str(idea_id or generate_id("idea")).strip()
2237
3527
  active_branch = (
2238
- str(state.get("research_head_branch") or "").strip()
2239
- or str(state.get("current_workspace_branch") or "").strip()
3528
+ str(state.get("current_workspace_branch") or "").strip()
3529
+ or str(state.get("research_head_branch") or "").strip()
2240
3530
  or current_branch(self._workspace_root_for(quest_root))
2241
3531
  )
2242
3532
  active_parent_branch = self._idea_parent_branch(self._latest_idea_for_branch(quest_root, active_branch))
@@ -2376,19 +3666,26 @@ class ArtifactService:
2376
3666
  worktree_root,
2377
3667
  message=f"idea: create {resolved_idea_id}",
2378
3668
  )
3669
+ idea_md_rel_path = self._workspace_relative(quest_root, idea_md_path)
3670
+ idea_draft_rel_path = self._workspace_relative(quest_root, idea_draft_path)
2379
3671
  interaction = self.interact(
2380
3672
  quest_root,
2381
3673
  kind="milestone",
2382
- message=(
2383
- f"Idea `{resolved_idea_id}` is now active.\n"
2384
- f"- Branch no: `{branch_no}`\n"
2385
- f"- Branch: `{branch_name}`\n"
2386
- f"- Lineage: `{normalized_lineage_intent or 'manual'}`\n"
2387
- f"- Foundation: `{foundation.get('label') or foundation.get('branch') or 'current head'}`\n"
2388
- f"- Worktree: `{worktree_root}`\n"
2389
- f"- Idea file: `{idea_md_path}`\n"
2390
- f"- Draft file: `{idea_draft_path}`\n"
2391
- f"- Next target: `{next_target}`"
3674
+ message=self._build_idea_interaction_message(
3675
+ action="create",
3676
+ idea_id=resolved_idea_id,
3677
+ title=title,
3678
+ problem=problem,
3679
+ hypothesis=hypothesis,
3680
+ mechanism=mechanism,
3681
+ foundation_label=self._format_foundation_label(
3682
+ foundation,
3683
+ fallback=foundation.get("branch") or "current head",
3684
+ ),
3685
+ branch_name=branch_name,
3686
+ next_target=next_target,
3687
+ idea_md_rel_path=idea_md_rel_path,
3688
+ draft_md_rel_path=idea_draft_rel_path,
2392
3689
  ),
2393
3690
  deliver_to_bound_conversations=True,
2394
3691
  include_recent_inbound_messages=False,
@@ -2441,9 +3738,17 @@ class ArtifactService:
2441
3738
  raise ValueError("submit_idea(mode='revise') requires an existing active `idea_id`.")
2442
3739
  if normalized_lineage_intent:
2443
3740
  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()
3741
+ branch_name = str(
3742
+ state.get("current_workspace_branch")
3743
+ or state.get("research_head_branch")
3744
+ or f"idea/{quest_id}-{resolved_idea_id}"
3745
+ ).strip()
2445
3746
  worktree_root = Path(
2446
- str(state.get("research_head_worktree_root") or canonical_worktree_root(quest_root, f"idea-{resolved_idea_id}"))
3747
+ str(
3748
+ state.get("current_workspace_root")
3749
+ or state.get("research_head_worktree_root")
3750
+ or canonical_worktree_root(quest_root, f"idea-{resolved_idea_id}")
3751
+ )
2447
3752
  )
2448
3753
  ensure_dir(worktree_root / "memory" / "ideas" / resolved_idea_id)
2449
3754
  idea_md_path = worktree_root / "memory" / "ideas" / resolved_idea_id / "idea.md"
@@ -2545,34 +3850,48 @@ class ArtifactService:
2545
3850
  checkpoint=False,
2546
3851
  workspace_root=worktree_root,
2547
3852
  )
3853
+ research_state_updates: dict[str, Any] = {
3854
+ "active_idea_id": resolved_idea_id,
3855
+ "current_workspace_branch": branch_name,
3856
+ "current_workspace_root": str(worktree_root),
3857
+ "active_idea_md_path": str(idea_md_path),
3858
+ "active_idea_draft_path": str(idea_draft_path),
3859
+ "workspace_mode": "idea",
3860
+ "last_flow_type": "idea_revision",
3861
+ }
3862
+ current_head_branch = str(state.get("research_head_branch") or "").strip()
3863
+ if not current_head_branch or current_head_branch == branch_name:
3864
+ research_state_updates["research_head_branch"] = branch_name
3865
+ research_state_updates["research_head_worktree_root"] = str(worktree_root)
2548
3866
  research_state = self.quest_service.update_research_state(
2549
3867
  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",
3868
+ **research_state_updates,
2559
3869
  )
2560
3870
  self.quest_service.update_settings(quest_id, active_anchor="experiment")
2561
3871
  checkpoint_result = self._checkpoint_with_optional_push(
2562
3872
  worktree_root,
2563
3873
  message=f"idea: revise {resolved_idea_id}",
2564
3874
  )
3875
+ idea_md_rel_path = self._workspace_relative(quest_root, idea_md_path)
3876
+ idea_draft_rel_path = self._workspace_relative(quest_root, idea_draft_path)
2565
3877
  interaction = self.interact(
2566
3878
  quest_root,
2567
3879
  kind="progress",
2568
- message=(
2569
- f"Idea `{resolved_idea_id}` was revised.\n"
2570
- f"- Branch: `{branch_name}`\n"
2571
- f"- Foundation: `{(existing_foundation_ref or {}).get('branch') or 'current head'}`\n"
2572
- f"- Worktree: `{worktree_root}`\n"
2573
- f"- Idea file: `{idea_md_path}`\n"
2574
- f"- Draft file: `{idea_draft_path}`\n"
2575
- f"- Next target: `{next_target}`"
3880
+ message=self._build_idea_interaction_message(
3881
+ action="revise",
3882
+ idea_id=resolved_idea_id,
3883
+ title=title,
3884
+ problem=problem,
3885
+ hypothesis=hypothesis,
3886
+ mechanism=mechanism,
3887
+ foundation_label=self._format_foundation_label(
3888
+ existing_foundation_ref,
3889
+ fallback=(existing_foundation_ref or {}).get("branch") or "current head",
3890
+ ),
3891
+ branch_name=branch_name,
3892
+ next_target=next_target,
3893
+ idea_md_rel_path=idea_md_rel_path,
3894
+ draft_md_rel_path=idea_draft_rel_path,
2576
3895
  ),
2577
3896
  deliver_to_bound_conversations=True,
2578
3897
  include_recent_inbound_messages=False,
@@ -2712,14 +4031,21 @@ class ArtifactService:
2712
4031
  baseline_id: str | None = None,
2713
4032
  baseline_variant_id: str | None = None,
2714
4033
  evaluation_summary: dict[str, Any] | None = None,
4034
+ strict_metric_contract: bool = False,
2715
4035
  ) -> dict[str, Any]:
2716
4036
  self._require_baseline_gate_open(quest_root, action="record_main_experiment")
2717
4037
  state = self.quest_service.read_research_state(quest_root)
2718
- if str(state.get("workspace_mode") or "").strip() == "analysis":
4038
+ workspace_mode = str(state.get("workspace_mode") or "").strip()
4039
+ if workspace_mode == "analysis":
2719
4040
  raise ValueError(
2720
4041
  "record_main_experiment cannot run while the active workspace is an analysis slice. "
2721
4042
  "Finish or close the analysis campaign first."
2722
4043
  )
4044
+ if workspace_mode == "paper":
4045
+ raise ValueError(
4046
+ "record_main_experiment cannot run while the active workspace is a paper branch. "
4047
+ "Return to the source evidence branch or create a new run branch first."
4048
+ )
2723
4049
 
2724
4050
  run_identifier = str(run_id or "").strip()
2725
4051
  if not run_identifier:
@@ -2727,7 +4053,18 @@ class ArtifactService:
2727
4053
 
2728
4054
  active_idea_id = str(state.get("active_idea_id") or "").strip() or None
2729
4055
  workspace_root = self._workspace_root_for(quest_root)
2730
- branch_name = str(state.get("research_head_branch") or current_branch(workspace_root)).strip()
4056
+ current_branch_name = str(
4057
+ state.get("current_workspace_branch")
4058
+ or state.get("research_head_branch")
4059
+ or current_branch(workspace_root)
4060
+ ).strip()
4061
+ branch_name, parent_branch, auto_promoted_run_branch = self._promote_workspace_to_run_branch(
4062
+ quest_root,
4063
+ run_id=run_identifier,
4064
+ idea_id=active_idea_id,
4065
+ workspace_root=workspace_root,
4066
+ current_branch_name=current_branch_name,
4067
+ )
2731
4068
  attachment = self._active_baseline_attachment(quest_root, workspace_root=workspace_root)
2732
4069
  baseline_entry = dict(attachment.get("entry") or {}) if isinstance(attachment, dict) else {}
2733
4070
  selected_variant = dict(attachment.get("selected_variant") or {}) if isinstance(attachment, dict) else {}
@@ -2757,16 +4094,49 @@ class ArtifactService:
2757
4094
  for item in normalized_metric_rows
2758
4095
  if str(item.get("metric_id") or "").strip()
2759
4096
  }
2760
- effective_metric_contract = normalize_metric_contract(
2761
- metric_contract or baseline_entry.get("metric_contract"),
2762
- baseline_id=resolved_baseline_id,
2763
- metrics_summary=normalized_metrics_summary,
2764
- primary_metric=baseline_entry.get("primary_metric"),
2765
- baseline_variants=baseline_entry.get("baseline_variants"),
4097
+ baseline_contract_payload = self._load_metric_contract_payload(quest_root, metric_contract_json_rel_path)
4098
+ baseline_metric_contract = baseline_entry.get("metric_contract")
4099
+ baseline_primary_metric = baseline_entry.get("primary_metric")
4100
+ if isinstance(baseline_contract_payload, dict) and baseline_contract_payload:
4101
+ payload_metric_contract = baseline_contract_payload.get("metric_contract")
4102
+ if isinstance(payload_metric_contract, dict) and payload_metric_contract:
4103
+ baseline_metric_contract = payload_metric_contract
4104
+ payload_primary_metric = baseline_contract_payload.get("primary_metric")
4105
+ if isinstance(payload_primary_metric, dict) and payload_primary_metric:
4106
+ baseline_primary_metric = payload_primary_metric
4107
+ effective_metric_contract = (
4108
+ self._merge_run_metric_contract(
4109
+ baseline_metric_contract=baseline_metric_contract,
4110
+ baseline_primary_metric=baseline_primary_metric,
4111
+ baseline_variants=baseline_entry.get("baseline_variants"),
4112
+ run_metric_contract=metric_contract,
4113
+ metrics_summary=normalized_metrics_summary,
4114
+ metric_rows=normalized_metric_rows,
4115
+ baseline_id=resolved_baseline_id,
4116
+ )
4117
+ if isinstance(baseline_metric_contract, dict) and baseline_metric_contract
4118
+ else normalize_metric_contract(
4119
+ metric_contract or baseline_entry.get("metric_contract"),
4120
+ baseline_id=resolved_baseline_id,
4121
+ metrics_summary=normalized_metrics_summary,
4122
+ metric_rows=normalized_metric_rows,
4123
+ primary_metric=baseline_primary_metric,
4124
+ baseline_variants=baseline_entry.get("baseline_variants"),
4125
+ )
2766
4126
  )
4127
+ metric_validation: dict[str, Any] | None = None
4128
+ if strict_metric_contract:
4129
+ metric_validation = validate_main_experiment_against_baseline_contract(
4130
+ baseline_contract_payload=baseline_contract_payload,
4131
+ run_metric_contract=effective_metric_contract,
4132
+ metric_rows=normalized_metric_rows,
4133
+ metrics_summary=normalized_metrics_summary,
4134
+ dataset_scope=dataset_scope,
4135
+ )
2767
4136
  baseline_metrics = selected_baseline_metrics(baseline_entry, resolved_variant_id)
2768
4137
  comparisons = compare_with_baseline(
2769
4138
  metrics_summary=normalized_metrics_summary,
4139
+ metric_rows=normalized_metric_rows,
2770
4140
  metric_contract=effective_metric_contract,
2771
4141
  baseline_metrics=baseline_metrics,
2772
4142
  )
@@ -2827,6 +4197,7 @@ class ArtifactService:
2827
4197
  "",
2828
4198
  f"- Run id: `{run_identifier}`",
2829
4199
  f"- Branch: `{branch_name}`",
4200
+ f"- Parent branch: `{parent_branch or 'none'}`",
2830
4201
  f"- Worktree: `{workspace_root}`",
2831
4202
  f"- Idea: `{active_idea_id or 'none'}`",
2832
4203
  f"- Baseline: `{resolved_baseline_id or 'none'}`",
@@ -2924,6 +4295,7 @@ class ArtifactService:
2924
4295
  "verdict": verdict,
2925
4296
  "idea_id": active_idea_id,
2926
4297
  "branch": branch_name,
4298
+ "parent_branch": parent_branch,
2927
4299
  "worktree_root": str(workspace_root),
2928
4300
  "head_commit": head_commit(workspace_root),
2929
4301
  "baseline_ref": {
@@ -2956,6 +4328,7 @@ class ArtifactService:
2956
4328
  "evidence_paths": resolved_evidence_paths,
2957
4329
  "files_changed": resolved_changed_files,
2958
4330
  "run_md_path": str(run_md_path),
4331
+ "metric_validation": metric_validation,
2959
4332
  }
2960
4333
  write_json(result_json_path, result_payload)
2961
4334
 
@@ -2970,6 +4343,7 @@ class ArtifactService:
2970
4343
  "reason": conclusion.strip() or progress_eval.get("reason") or "Main experiment result recorded.",
2971
4344
  "idea_id": active_idea_id,
2972
4345
  "branch": branch_name,
4346
+ "parent_branch": parent_branch,
2973
4347
  "worktree_root": str(workspace_root),
2974
4348
  "worktree_rel_path": self._workspace_relative(quest_root, workspace_root),
2975
4349
  "flow_type": "main_experiment",
@@ -2989,6 +4363,7 @@ class ArtifactService:
2989
4363
  "breakthrough_level": progress_eval.get("breakthrough_level"),
2990
4364
  "need_research_paper": delivery_policy.get("need_research_paper"),
2991
4365
  "recommended_next_route": delivery_policy.get("recommended_next_route"),
4366
+ "auto_promoted_run_branch": auto_promoted_run_branch,
2992
4367
  "changed_file_count": len(resolved_changed_files),
2993
4368
  "evidence_count": len(resolved_evidence_paths),
2994
4369
  "evaluation_summary": normalized_evaluation_summary,
@@ -3008,6 +4383,7 @@ class ArtifactService:
3008
4383
  },
3009
4384
  "progress_eval": progress_eval,
3010
4385
  "evaluation_summary": normalized_evaluation_summary,
4386
+ "metric_validation": metric_validation,
3011
4387
  "files_changed": resolved_changed_files,
3012
4388
  "evidence_paths": resolved_evidence_paths,
3013
4389
  "verdict": verdict,
@@ -3018,14 +4394,21 @@ class ArtifactService:
3018
4394
  interaction = self.interact(
3019
4395
  quest_root,
3020
4396
  kind="milestone",
3021
- message=(
3022
- f"Main experiment `{run_identifier}` has been recorded.\n"
3023
- f"- Branch: `{branch_name}`\n"
3024
- f"- Run log: `{run_md_path}`\n"
3025
- f"- Result: `{result_json_path}`\n"
3026
- f"- Verdict: `{verdict}`\n"
3027
- f"- Breakthrough: `{progress_eval.get('breakthrough_level')}`\n"
3028
- f"- Recommended next route: `{delivery_policy.get('recommended_next_route')}`"
4397
+ message=self._build_main_experiment_interaction_message(
4398
+ run_id=run_identifier,
4399
+ branch_name=branch_name,
4400
+ verdict=verdict,
4401
+ primary_metric_id=primary_metric_id,
4402
+ primary_value=primary_value,
4403
+ primary_baseline=primary_baseline,
4404
+ primary_delta=primary_delta,
4405
+ decimals=decimals if isinstance(decimals, int) else None,
4406
+ conclusion=conclusion.strip() or progress_eval.get("reason"),
4407
+ evaluation_summary=normalized_evaluation_summary,
4408
+ breakthrough_level=str(progress_eval.get("breakthrough_level") or "").strip() or None,
4409
+ recommended_next_route=str(delivery_policy.get("recommended_next_route") or "").strip() or None,
4410
+ run_md_rel_path=self._workspace_relative(quest_root, run_md_path),
4411
+ result_json_rel_path=self._workspace_relative(quest_root, result_json_path),
3029
4412
  ),
3030
4413
  deliver_to_bound_conversations=True,
3031
4414
  include_recent_inbound_messages=False,
@@ -3049,6 +4432,22 @@ class ArtifactService:
3049
4432
  ],
3050
4433
  )
3051
4434
  self.quest_service.update_settings(self._quest_id(quest_root), active_anchor="decision")
4435
+ research_state = self.quest_service.update_research_state(
4436
+ quest_root,
4437
+ active_idea_id=active_idea_id,
4438
+ current_workspace_branch=branch_name,
4439
+ current_workspace_root=str(workspace_root),
4440
+ research_head_branch=branch_name,
4441
+ research_head_worktree_root=str(workspace_root),
4442
+ active_analysis_campaign_id=None,
4443
+ analysis_parent_branch=None,
4444
+ analysis_parent_worktree_root=None,
4445
+ paper_parent_branch=None,
4446
+ paper_parent_worktree_root=None,
4447
+ paper_parent_run_id=None,
4448
+ workspace_mode="run",
4449
+ last_flow_type="main_experiment_recorded",
4450
+ )
3052
4451
  return {
3053
4452
  "ok": True,
3054
4453
  "guidance": artifact.get("guidance"),
@@ -3058,10 +4457,14 @@ class ArtifactService:
3058
4457
  "suggested_artifact_calls": artifact.get("suggested_artifact_calls"),
3059
4458
  "next_instruction": artifact.get("next_instruction"),
3060
4459
  "run_id": run_identifier,
4460
+ "branch": branch_name,
4461
+ "parent_branch": parent_branch,
4462
+ "auto_promoted_run_branch": auto_promoted_run_branch,
3061
4463
  "run_md_path": str(run_md_path),
3062
4464
  "result_json_path": str(result_json_path),
3063
4465
  "artifact": artifact,
3064
4466
  "interaction": interaction,
4467
+ "research_state": research_state,
3065
4468
  "metrics_summary": normalized_metrics_summary,
3066
4469
  "baseline_comparisons": {
3067
4470
  key: value for key, value in comparisons.items() if key != "primary"
@@ -3069,6 +4472,7 @@ class ArtifactService:
3069
4472
  "progress_eval": progress_eval,
3070
4473
  "evaluation_summary": normalized_evaluation_summary,
3071
4474
  "delivery_policy": delivery_policy,
4475
+ "metric_validation": metric_validation,
3072
4476
  }
3073
4477
 
3074
4478
  def create_analysis_campaign(
@@ -3091,6 +4495,14 @@ class ArtifactService:
3091
4495
  quest_root,
3092
4496
  state=state,
3093
4497
  )
4498
+ runtime_refs = self.resolve_runtime_refs(quest_root)
4499
+ resolved_parent_run_id = (
4500
+ str(parent_run_id or "").strip()
4501
+ or str(state.get("paper_parent_run_id") or "").strip()
4502
+ or str((self._latest_main_run_for_branch(quest_root, parent_branch) or {}).get("run_id") or "").strip()
4503
+ or str(runtime_refs.get("latest_main_run_id") or "").strip()
4504
+ or None
4505
+ )
3094
4506
  active_idea_id = str(resolved_idea_id or "").strip()
3095
4507
  if not active_idea_id:
3096
4508
  raise ValueError("An active idea is required before starting an analysis campaign.")
@@ -3104,6 +4516,54 @@ class ArtifactService:
3104
4516
  normalized_research_questions = self._normalize_string_list(research_questions)
3105
4517
  normalized_experimental_designs = self._normalize_string_list(experimental_designs)
3106
4518
  normalized_todo_items = self._normalize_campaign_todo_items(todo_items)
4519
+ quest_data = self.quest_service.read_quest_yaml(quest_root)
4520
+ active_anchor = str(quest_data.get("active_anchor") or "").strip().lower()
4521
+ campaign_origin_kind = (
4522
+ str(normalized_campaign_origin.get("kind") or "").strip().lower()
4523
+ if isinstance(normalized_campaign_origin, dict)
4524
+ else ""
4525
+ )
4526
+ writing_facing = bool(
4527
+ resolved_outline_ref
4528
+ or normalized_research_questions
4529
+ or normalized_experimental_designs
4530
+ or normalized_todo_items
4531
+ or str(state.get("workspace_mode") or "").strip().lower() == "paper"
4532
+ or active_anchor == "write"
4533
+ or campaign_origin_kind in {"write", "paper", "rebuttal", "revision"}
4534
+ )
4535
+ if writing_facing:
4536
+ if not resolved_outline_ref:
4537
+ raise ValueError(
4538
+ "Writing-facing analysis campaigns require `selected_outline_ref` before slices can be launched."
4539
+ )
4540
+ if not normalized_research_questions:
4541
+ raise ValueError(
4542
+ "Writing-facing analysis campaigns require non-empty `research_questions`."
4543
+ )
4544
+ if not normalized_experimental_designs:
4545
+ raise ValueError(
4546
+ "Writing-facing analysis campaigns require non-empty `experimental_designs`."
4547
+ )
4548
+ if not normalized_todo_items:
4549
+ raise ValueError(
4550
+ "Writing-facing analysis campaigns require non-empty `todo_items`."
4551
+ )
4552
+ todo_slice_ids = {
4553
+ str(item.get("slice_id") or "").strip()
4554
+ for item in normalized_todo_items
4555
+ if str(item.get("slice_id") or "").strip()
4556
+ }
4557
+ missing_slice_ids = [
4558
+ str(raw.get("slice_id") or "").strip()
4559
+ for raw in slices
4560
+ if str(raw.get("slice_id") or "").strip() and str(raw.get("slice_id") or "").strip() not in todo_slice_ids
4561
+ ]
4562
+ if missing_slice_ids:
4563
+ raise ValueError(
4564
+ "Writing-facing analysis campaigns require one todo item per slice. "
4565
+ f"Missing todo items for: {', '.join(missing_slice_ids)}."
4566
+ )
3107
4567
  slice_contexts: list[dict[str, Any]] = []
3108
4568
  inventory_entries: list[dict[str, Any]] = []
3109
4569
  for index, raw in enumerate(slices, start=1):
@@ -3366,7 +4826,7 @@ class ArtifactService:
3366
4826
  {
3367
4827
  "title": campaign_title,
3368
4828
  "goal": campaign_goal,
3369
- "parent_run_id": parent_run_id,
4829
+ "parent_run_id": resolved_parent_run_id,
3370
4830
  "active_idea_id": active_idea_id,
3371
4831
  "parent_branch": parent_branch,
3372
4832
  "parent_worktree_root": str(parent_worktree_root),
@@ -3441,7 +4901,7 @@ class ArtifactService:
3441
4901
  "details": {
3442
4902
  "campaign_title": campaign_title,
3443
4903
  "campaign_goal": campaign_goal,
3444
- "parent_run_id": parent_run_id,
4904
+ "parent_run_id": resolved_parent_run_id,
3445
4905
  "campaign_origin": normalized_campaign_origin,
3446
4906
  "selected_outline_ref": resolved_outline_ref,
3447
4907
  "todo_manifest_path": str(todo_manifest_path),
@@ -3493,14 +4953,13 @@ class ArtifactService:
3493
4953
  interaction = self.interact(
3494
4954
  quest_root,
3495
4955
  kind="milestone",
3496
- message=(
3497
- f"Analysis campaign `{campaign_id}` is ready.\n"
3498
- f"- Parent branch: `{parent_branch}`\n"
3499
- f"- Parent worktree: `{parent_worktree_root}`\n"
3500
- f"- Next slice: `{first_slice['slice_id']}`\n"
3501
- f"- Slice branch: `{first_slice['branch']}`\n"
3502
- f"- Slice worktree: `{first_slice['worktree_root']}`\n"
3503
- f"- Core requirement: {first_slice['must_not_simplify'] or 'Follow the full evaluation protocol.'}"
4956
+ message=self._build_analysis_campaign_interaction_message(
4957
+ campaign_id=campaign_id,
4958
+ goal=campaign_goal,
4959
+ parent_branch=parent_branch,
4960
+ selected_outline_ref=resolved_outline_ref,
4961
+ first_slice=first_slice,
4962
+ todo_manifest_rel_path=self._workspace_relative(quest_root, todo_manifest_path),
3504
4963
  ),
3505
4964
  deliver_to_bound_conversations=True,
3506
4965
  include_recent_inbound_messages=False,
@@ -3560,11 +5019,32 @@ class ArtifactService:
3560
5019
  if normalized_mode not in {"candidate", "select", "revise"}:
3561
5020
  raise ValueError("submit_paper_outline mode must be `candidate`, `select`, or `revise`.")
3562
5021
 
3563
- existing_selected = read_json(self._paper_selected_outline_path(quest_root), {})
5022
+ paper_context = (
5023
+ self._ensure_active_paper_workspace(quest_root)
5024
+ if normalized_mode in {"select", "revise"}
5025
+ else {
5026
+ "worktree_root": str(self._workspace_root_for(quest_root)),
5027
+ "branch": str(self.quest_service.read_research_state(quest_root).get("current_workspace_branch") or "").strip() or None,
5028
+ }
5029
+ )
5030
+ workspace_root = Path(str(paper_context.get("worktree_root") or self._workspace_root_for(quest_root)))
5031
+ paper_root = (
5032
+ ensure_dir(workspace_root / "paper")
5033
+ if normalized_mode in {"select", "revise"}
5034
+ else self._paper_root(quest_root, workspace_root=workspace_root, create=True)
5035
+ )
5036
+ if normalized_mode in {"select", "revise"}:
5037
+ selected_outline_path = paper_root / "selected_outline.json"
5038
+ else:
5039
+ selected_outline_path = self._paper_selected_outline_path(quest_root, workspace_root=workspace_root)
5040
+ existing_selected = read_json(selected_outline_path, {})
5041
+ if not isinstance(existing_selected, dict) or not existing_selected:
5042
+ existing_selected = read_json(quest_root / "paper" / "selected_outline.json", {})
3564
5043
  existing_selected = existing_selected if isinstance(existing_selected, dict) else {}
3565
5044
  if normalized_mode == "candidate":
3566
5045
  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"
5046
+ candidate_path = self._paper_outline_candidates_root(quest_root, workspace_root=workspace_root) / f"{resolved_outline_id}.json"
5047
+ canonical_candidate_path = quest_root / "paper" / "outlines" / "candidates" / f"{resolved_outline_id}.json"
3568
5048
  existing = read_json(candidate_path, {})
3569
5049
  existing = existing if isinstance(existing, dict) else {}
3570
5050
  record = self._normalize_paper_outline_record(
@@ -3579,6 +5059,8 @@ class ArtifactService:
3579
5059
  created_at=str(existing.get("created_at") or "") or None,
3580
5060
  )
3581
5061
  write_json(candidate_path, record)
5062
+ if canonical_candidate_path.resolve() != candidate_path.resolve():
5063
+ write_json(canonical_candidate_path, record)
3582
5064
  artifact = self.record(
3583
5065
  quest_root,
3584
5066
  {
@@ -3599,7 +5081,7 @@ class ArtifactService:
3599
5081
  },
3600
5082
  },
3601
5083
  checkpoint=False,
3602
- workspace_root=self._workspace_root_for(quest_root),
5084
+ workspace_root=workspace_root,
3603
5085
  )
3604
5086
  return {
3605
5087
  "ok": True,
@@ -3613,8 +5095,13 @@ class ArtifactService:
3613
5095
  source_outline_id = str(outline_id or existing_selected.get("outline_id") or "").strip()
3614
5096
  if not source_outline_id:
3615
5097
  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"
5098
+ source_candidate_path = paper_root / "outlines" / "candidates" / f"{source_outline_id}.json"
3617
5099
  source_record = read_json(source_candidate_path, {})
5100
+ if not isinstance(source_record, dict) or not source_record:
5101
+ fallback_candidate_path = quest_root / "paper" / "outlines" / "candidates" / f"{source_outline_id}.json"
5102
+ source_record = read_json(fallback_candidate_path, {})
5103
+ if isinstance(source_record, dict) and source_record:
5104
+ source_candidate_path = fallback_candidate_path
3618
5105
  if not isinstance(source_record, dict) or not source_record:
3619
5106
  source_record = existing_selected if str(existing_selected.get("outline_id") or "").strip() == source_outline_id else {}
3620
5107
  if not source_record:
@@ -3632,18 +5119,23 @@ class ArtifactService:
3632
5119
  created_at=str(source_record.get("created_at") or "") or None,
3633
5120
  )
3634
5121
 
3635
- selected_outline_path = self._paper_selected_outline_path(quest_root)
3636
5122
  write_json(selected_outline_path, resolved_record)
5123
+ canonical_selected_outline_path = quest_root / "paper" / "selected_outline.json"
5124
+ if canonical_selected_outline_path.resolve() != selected_outline_path.resolve():
5125
+ write_json(canonical_selected_outline_path, resolved_record)
3637
5126
  if source_candidate_path.exists():
3638
5127
  source_record["status"] = "selected" if normalized_mode == "select" else "revised"
3639
5128
  source_record["updated_at"] = utc_now()
3640
5129
  write_json(source_candidate_path, source_record)
3641
5130
  revised_outline_path = None
3642
5131
  if normalized_mode == "revise":
3643
- revised_outline_path = self._paper_outline_revisions_root(quest_root) / f"{source_outline_id}.json"
5132
+ revised_outline_path = ensure_dir(paper_root / "outlines" / "revisions") / f"{source_outline_id}.json"
3644
5133
  write_json(revised_outline_path, resolved_record)
5134
+ canonical_revised_outline_path = quest_root / "paper" / "outlines" / "revisions" / f"{source_outline_id}.json"
5135
+ if canonical_revised_outline_path.resolve() != revised_outline_path.resolve():
5136
+ write_json(canonical_revised_outline_path, resolved_record)
3645
5137
 
3646
- outline_selection_path = self._paper_outline_selection_path(quest_root)
5138
+ outline_selection_path = paper_root / "outline_selection.md"
3647
5139
  action_label = "selected" if normalized_mode == "select" else "revised"
3648
5140
  selection_lines = [
3649
5141
  f"# Outline {normalized_mode.capitalize()}",
@@ -3682,7 +5174,47 @@ class ArtifactService:
3682
5174
  },
3683
5175
  },
3684
5176
  checkpoint=False,
3685
- workspace_root=self._workspace_root_for(quest_root),
5177
+ workspace_root=workspace_root,
5178
+ )
5179
+ selected_outline_rel_path = self._workspace_relative(quest_root, selected_outline_path)
5180
+ outline_selection_rel_path = self._workspace_relative(quest_root, outline_selection_path)
5181
+ revised_outline_rel_path = self._workspace_relative(quest_root, revised_outline_path) if revised_outline_path else None
5182
+ interaction = self.interact(
5183
+ quest_root,
5184
+ kind="milestone" if normalized_mode == "select" else "progress",
5185
+ message=self._build_outline_interaction_message(
5186
+ action=normalized_mode,
5187
+ outline_id=source_outline_id,
5188
+ title=str(resolved_record.get("title") or "").strip() or source_outline_id,
5189
+ selected_reason=selected_reason or note,
5190
+ story=str(resolved_record.get("story") or "").strip() or None,
5191
+ research_questions=(
5192
+ (resolved_record.get("detailed_outline") or {})
5193
+ if isinstance(resolved_record.get("detailed_outline"), dict)
5194
+ else {}
5195
+ ).get("research_questions"),
5196
+ experimental_designs=(
5197
+ (resolved_record.get("detailed_outline") or {})
5198
+ if isinstance(resolved_record.get("detailed_outline"), dict)
5199
+ else {}
5200
+ ).get("experimental_designs"),
5201
+ selected_outline_rel_path=selected_outline_rel_path,
5202
+ outline_selection_rel_path=outline_selection_rel_path,
5203
+ revised_outline_rel_path=revised_outline_rel_path,
5204
+ ),
5205
+ deliver_to_bound_conversations=True,
5206
+ include_recent_inbound_messages=False,
5207
+ attachments=[
5208
+ {
5209
+ "kind": "paper_outline_selected" if normalized_mode == "select" else "paper_outline_revised",
5210
+ "outline_id": source_outline_id,
5211
+ "title": resolved_record.get("title"),
5212
+ "selected_reason": selected_reason,
5213
+ "selected_outline_path": str(selected_outline_path),
5214
+ "outline_selection_path": str(outline_selection_path),
5215
+ "revised_outline_path": str(revised_outline_path) if revised_outline_path else None,
5216
+ }
5217
+ ],
3686
5218
  )
3687
5219
  return {
3688
5220
  "ok": True,
@@ -3693,6 +5225,7 @@ class ArtifactService:
3693
5225
  "revised_outline_path": str(revised_outline_path) if revised_outline_path else None,
3694
5226
  "record": resolved_record,
3695
5227
  "artifact": artifact,
5228
+ "interaction": interaction,
3696
5229
  }
3697
5230
 
3698
5231
  def submit_paper_bundle(
@@ -3710,24 +5243,44 @@ class ArtifactService:
3710
5243
  pdf_path: str | None = None,
3711
5244
  latex_root_path: str | None = None,
3712
5245
  ) -> dict[str, Any]:
3713
- selected_outline_path = self._paper_selected_outline_path(quest_root)
5246
+ paper_context = self._ensure_active_paper_workspace(quest_root)
5247
+ workspace_root = Path(str(paper_context.get("worktree_root") or self._workspace_root_for(quest_root)))
5248
+ paper_root = self._paper_root(quest_root, workspace_root=workspace_root, create=True)
5249
+ selected_outline_path = self._paper_selected_outline_path(quest_root, workspace_root=workspace_root)
3714
5250
  selected_outline = read_json(selected_outline_path, {})
5251
+ if not isinstance(selected_outline, dict) or not selected_outline:
5252
+ fallback_selected_outline_path = quest_root / "paper" / "selected_outline.json"
5253
+ selected_outline = read_json(fallback_selected_outline_path, {})
5254
+ if isinstance(selected_outline, dict) and selected_outline:
5255
+ selected_outline_path = fallback_selected_outline_path
3715
5256
  selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
3716
5257
  if not selected_outline and not str(outline_path or "").strip():
3717
5258
  raise ValueError("submit_paper_bundle requires a selected outline or explicit `outline_path`.")
3718
5259
 
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
- )
5260
+ manifest_path = self._paper_bundle_manifest_path(quest_root, workspace_root=workspace_root)
5261
+ baseline_inventory = self._write_paper_baseline_inventory(quest_root, workspace_root=workspace_root)
5262
+ baseline_inventory_path = self._paper_baseline_inventory_path(quest_root, workspace_root=workspace_root)
5263
+ source_branch = str(paper_context.get("source_branch") or "").strip() or None
5264
+ paper_branch = str(paper_context.get("branch") or "").strip() or current_branch(workspace_root)
5265
+ source_run_id = str(paper_context.get("source_run_id") or "").strip() or None
5266
+ source_idea_id = str(paper_context.get("source_idea_id") or "").strip() or None
5267
+ paper_manifest_rel = self._workspace_relative(quest_root, manifest_path) or "paper/paper_bundle_manifest.json"
5268
+ paper_inventory_rel = self._workspace_relative(quest_root, baseline_inventory_path) or "paper/baseline_inventory.json"
3726
5269
  open_source_manifest = self._ensure_open_source_prep(
3727
5270
  quest_root,
5271
+ workspace_root=workspace_root,
3728
5272
  source_branch=source_branch,
3729
- source_bundle_manifest_path="paper/paper_bundle_manifest.json",
3730
- baseline_inventory_path="paper/baseline_inventory.json",
5273
+ source_bundle_manifest_path=paper_manifest_rel,
5274
+ baseline_inventory_path=paper_inventory_rel,
5275
+ )
5276
+ default_draft_path = self._workspace_relative(quest_root, paper_root / "draft.md") or "paper/draft.md"
5277
+ default_writing_plan_path = self._workspace_relative(quest_root, paper_root / "writing_plan.md") or "paper/writing_plan.md"
5278
+ default_references_path = self._workspace_relative(quest_root, paper_root / "references.bib") or "paper/references.bib"
5279
+ default_claim_map_path = (
5280
+ self._workspace_relative(quest_root, paper_root / "claim_evidence_map.json") or "paper/claim_evidence_map.json"
5281
+ )
5282
+ default_compile_report_path = (
5283
+ self._workspace_relative(quest_root, paper_root / "build" / "compile_report.json") or "paper/build/compile_report.json"
3731
5284
  )
3732
5285
  manifest = {
3733
5286
  "schema_version": 1,
@@ -3740,15 +5293,23 @@ class ArtifactService:
3740
5293
  or "paper",
3741
5294
  "summary": str(summary or "").strip() or None,
3742
5295
  "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,
5296
+ "paper_branch": paper_branch,
5297
+ "source_branch": source_branch,
5298
+ "source_run_id": source_run_id,
5299
+ "source_idea_id": source_idea_id,
5300
+ "draft_path": str(draft_path or default_draft_path).strip() or None,
5301
+ "writing_plan_path": str(writing_plan_path or default_writing_plan_path).strip() or None,
5302
+ "references_path": str(references_path or default_references_path).strip() or None,
5303
+ "claim_evidence_map_path": str(claim_evidence_map_path or default_claim_map_path).strip() or None,
5304
+ "compile_report_path": str(compile_report_path or default_compile_report_path).strip() or None,
3748
5305
  "pdf_path": str(pdf_path or "").strip() or None,
3749
5306
  "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",
5307
+ "baseline_inventory_path": paper_inventory_rel,
5308
+ "open_source_manifest_path": self._workspace_relative(
5309
+ quest_root,
5310
+ self._open_source_manifest_path(quest_root, workspace_root=workspace_root),
5311
+ )
5312
+ or "release/open_source/manifest.json",
3752
5313
  "open_source_cleanup_plan_path": str(open_source_manifest.get("cleanup_plan_path") or "").strip()
3753
5314
  or "release/open_source/cleanup_plan.md",
3754
5315
  "selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
@@ -3773,24 +5334,27 @@ class ArtifactService:
3773
5334
  "draft_path": manifest.get("draft_path"),
3774
5335
  "pdf_path": manifest.get("pdf_path"),
3775
5336
  "baseline_inventory_path": str(baseline_inventory_path),
3776
- "open_source_manifest_path": str(self._open_source_manifest_path(quest_root)),
5337
+ "open_source_manifest_path": str(self._open_source_manifest_path(quest_root, workspace_root=workspace_root)),
3777
5338
  },
3778
5339
  "details": {
3779
5340
  "title": manifest.get("title"),
3780
5341
  "selected_outline_ref": manifest.get("selected_outline_ref"),
3781
5342
  "baseline_inventory_count": len(baseline_inventory.get("supplementary_baselines") or []),
3782
5343
  "open_source_status": open_source_manifest.get("status"),
5344
+ "paper_branch": paper_branch,
5345
+ "source_branch": source_branch,
5346
+ "source_run_id": source_run_id,
3783
5347
  },
3784
5348
  },
3785
5349
  checkpoint=False,
3786
- workspace_root=self._workspace_root_for(quest_root),
5350
+ workspace_root=workspace_root,
3787
5351
  )
3788
5352
  return {
3789
5353
  "ok": True,
3790
5354
  "manifest_path": str(manifest_path),
3791
5355
  "manifest": manifest,
3792
5356
  "baseline_inventory_path": str(baseline_inventory_path),
3793
- "open_source_manifest_path": str(self._open_source_manifest_path(quest_root)),
5357
+ "open_source_manifest_path": str(self._open_source_manifest_path(quest_root, workspace_root=workspace_root)),
3794
5358
  "artifact": artifact,
3795
5359
  }
3796
5360
 
@@ -3828,7 +5392,17 @@ class ArtifactService:
3828
5392
 
3829
5393
  evidence_paths = [str(item).strip() for item in (evidence_paths or []) if str(item).strip()]
3830
5394
  deviations = [str(item).strip() for item in (deviations or []) if str(item).strip()]
3831
- metric_rows = [item for item in (metric_rows or []) if isinstance(item, dict)]
5395
+ normalized_metric_rows = normalize_metric_rows(metric_rows or [])
5396
+ normalized_metrics_summary = {
5397
+ str(item.get("metric_id") or "").strip(): item.get("value")
5398
+ for item in normalized_metric_rows
5399
+ if str(item.get("metric_id") or "").strip()
5400
+ }
5401
+ normalized_metric_contract = normalize_metric_contract(
5402
+ {},
5403
+ metrics_summary=normalized_metrics_summary,
5404
+ metric_rows=normalized_metric_rows,
5405
+ )
3832
5406
  normalized_comparison_baselines = self._normalize_comparison_baselines(quest_root, comparison_baselines)
3833
5407
  normalized_claim_impact = str(claim_impact or "").strip() or None
3834
5408
  normalized_reviewer_resolution = str(reviewer_resolution or "").strip() or None
@@ -3896,9 +5470,9 @@ class ArtifactService:
3896
5470
  result_lines.extend([f"- `{item}`" for item in evidence_paths])
3897
5471
  else:
3898
5472
  result_lines.append("- None recorded.")
3899
- if metric_rows:
5473
+ if normalized_metric_rows:
3900
5474
  result_lines.extend(["", "## Metric Rows", ""])
3901
- for row in metric_rows:
5475
+ for row in normalized_metric_rows:
3902
5476
  result_lines.append(f"- `{row}`")
3903
5477
  result_lines.extend(["", "## Comparison Baselines", ""])
3904
5478
  if normalized_comparison_baselines:
@@ -3918,16 +5492,6 @@ class ArtifactService:
3918
5492
  result_lines.extend(["", "## Subset Approval", "", f"`{subset_approval_ref}`"])
3919
5493
  write_text(result_path, "\n".join(result_lines).rstrip() + "\n")
3920
5494
 
3921
- metrics_summary: dict[str, Any] = {}
3922
- for row in metric_rows:
3923
- name = str(row.get("name") or row.get("metric") or "").strip()
3924
- if name:
3925
- metrics_summary[name] = row.get("value")
3926
- continue
3927
- keys = [key for key in row.keys() if key not in {"split", "seed", "note", "notes"}]
3928
- if len(keys) == 1:
3929
- metrics_summary[keys[0]] = row.get(keys[0])
3930
-
3931
5495
  result_payload = {
3932
5496
  "schema_version": 1,
3933
5497
  "result_kind": "analysis_slice",
@@ -3939,8 +5503,9 @@ class ArtifactService:
3939
5503
  "run_kind": target.get("run_kind"),
3940
5504
  "required_baselines": target.get("required_baselines") or [],
3941
5505
  "comparison_baselines": normalized_comparison_baselines,
3942
- "metrics_summary": metrics_summary,
3943
- "metric_rows": metric_rows,
5506
+ "metrics_summary": normalized_metrics_summary,
5507
+ "metric_rows": normalized_metric_rows,
5508
+ "metric_contract": normalized_metric_contract,
3944
5509
  "dataset_scope": normalized_scope,
3945
5510
  "subset_approval_ref": subset_approval_ref,
3946
5511
  "setup": setup.strip() or None,
@@ -4026,7 +5591,11 @@ class ArtifactService:
4026
5591
  "parent_branch": parent_branch,
4027
5592
  "worktree_root": str(slice_worktree_root),
4028
5593
  "worktree_rel_path": self._workspace_relative(quest_root, slice_worktree_root),
4029
- "metrics_summary": metrics_summary,
5594
+ "metrics_summary": normalized_metrics_summary,
5595
+ "metric_rows": normalized_metric_rows,
5596
+ "metric_contract": normalized_metric_contract,
5597
+ "comparison_baselines": normalized_comparison_baselines,
5598
+ "evidence_paths": evidence_paths,
4030
5599
  "flow_type": "analysis_slice",
4031
5600
  "protocol_step": "record",
4032
5601
  "paths": {
@@ -4040,7 +5609,7 @@ class ArtifactService:
4040
5609
  "must_not_simplify": target.get("must_not_simplify"),
4041
5610
  "dataset_scope": normalized_scope,
4042
5611
  "subset_approval_ref": subset_approval_ref,
4043
- "metric_rows": metric_rows,
5612
+ "metric_rows": normalized_metric_rows,
4044
5613
  "claim_impact": normalized_claim_impact,
4045
5614
  "reviewer_resolution": normalized_reviewer_resolution,
4046
5615
  "manuscript_update_hint": normalized_manuscript_update_hint,
@@ -4080,6 +5649,8 @@ class ArtifactService:
4080
5649
  updated["reviewer_resolution"] = normalized_reviewer_resolution
4081
5650
  updated["manuscript_update_hint"] = normalized_manuscript_update_hint
4082
5651
  updated["next_recommendation"] = normalized_next_recommendation
5652
+ updated["metrics_summary"] = normalized_metrics_summary
5653
+ updated["metric_rows"] = normalized_metric_rows
4083
5654
  updated["comparison_baselines"] = normalized_comparison_baselines
4084
5655
  updated["evaluation_summary"] = normalized_evaluation_summary
4085
5656
  updated_slices.append(updated)
@@ -4137,13 +5708,13 @@ class ArtifactService:
4137
5708
  interaction = self.interact(
4138
5709
  quest_root,
4139
5710
  kind="progress",
4140
- message=(
4141
- f"Analysis slice `{slice_id}` is complete.\n"
4142
- f"- Parent branch mirror updated: `{mirror_path}`\n"
4143
- f"- Next slice: `{next_slice['slice_id']}`\n"
4144
- f"- Next branch: `{next_slice['branch']}`\n"
4145
- f"- Next worktree: `{next_slice['worktree_root']}`\n"
4146
- f"- Core requirement: {next_slice.get('must_not_simplify') or 'Use the full intended evaluation protocol.'}"
5711
+ message=self._build_analysis_slice_interaction_message(
5712
+ campaign_id=campaign_id,
5713
+ slice_id=slice_id,
5714
+ evaluation_summary=normalized_evaluation_summary,
5715
+ claim_impact=normalized_claim_impact,
5716
+ next_slice=next_slice,
5717
+ mirror_rel_path=self._workspace_relative(quest_root, mirror_path),
4147
5718
  ),
4148
5719
  deliver_to_bound_conversations=True,
4149
5720
  include_recent_inbound_messages=False,
@@ -4229,26 +5800,55 @@ class ArtifactService:
4229
5800
  message=f"analysis: summarize {campaign_id}",
4230
5801
  )
4231
5802
  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(
5803
+ startup_contract = self._startup_contract(quest_root)
5804
+ raw_need_research_paper = startup_contract.get("need_research_paper")
5805
+ need_research_paper = raw_need_research_paper if isinstance(raw_need_research_paper, bool) else True
5806
+ base_research_state = self.quest_service.update_research_state(
4233
5807
  quest_root,
4234
5808
  active_idea_id=restored_idea_id,
4235
5809
  active_analysis_campaign_id=None,
5810
+ analysis_parent_branch=None,
5811
+ analysis_parent_worktree_root=None,
5812
+ paper_parent_branch=None,
5813
+ paper_parent_worktree_root=None,
5814
+ paper_parent_run_id=None,
4236
5815
  next_pending_slice_id=None,
4237
5816
  current_workspace_branch=parent_branch,
4238
5817
  current_workspace_root=str(parent_worktree_root),
4239
- workspace_mode="idea",
5818
+ workspace_mode="run" if self._branch_kind_from_name(parent_branch) == "run" else "idea",
4240
5819
  last_flow_type="analysis_campaign_complete",
4241
5820
  )
4242
- self.quest_service.update_settings(self._quest_id(quest_root), active_anchor="decision")
5821
+ writing_workspace: dict[str, Any] | None = None
5822
+ if need_research_paper:
5823
+ try:
5824
+ writing_workspace = self._ensure_active_paper_workspace(
5825
+ quest_root,
5826
+ source_branch=parent_branch,
5827
+ source_run_id=str(manifest.get("parent_run_id") or "").strip() or None,
5828
+ source_idea_id=restored_idea_id,
5829
+ )
5830
+ except Exception:
5831
+ writing_workspace = None
5832
+
5833
+ if writing_workspace:
5834
+ research_state = self.quest_service.read_research_state(quest_root)
5835
+ self.quest_service.update_settings(self._quest_id(quest_root), active_anchor="write")
5836
+ else:
5837
+ research_state = base_research_state
5838
+ self.quest_service.update_settings(self._quest_id(quest_root), active_anchor="decision")
4243
5839
  interaction = self.interact(
4244
5840
  quest_root,
4245
5841
  kind="milestone",
4246
- message=(
4247
- f"All analysis slices in `{campaign_id}` are complete.\n"
4248
- f"- Returned to parent branch: `{parent_branch}`\n"
4249
- f"- Parent worktree: `{parent_worktree_root}`\n"
4250
- f"- Analysis summary: `{summary_path}`\n"
4251
- "Use the completed analysis evidence to make the next durable route decision."
5842
+ message=self._build_analysis_complete_interaction_message(
5843
+ campaign_id=campaign_id,
5844
+ completed_slices=updated_slices,
5845
+ summary_rel_path=self._workspace_relative(quest_root, summary_path),
5846
+ writing_branch=writing_workspace.get("branch") if writing_workspace else None,
5847
+ writing_worktree_rel_path=(
5848
+ self._workspace_relative(quest_root, Path(str(writing_workspace.get("worktree_root"))))
5849
+ if writing_workspace and str(writing_workspace.get("worktree_root") or "").strip()
5850
+ else None
5851
+ ),
4252
5852
  ),
4253
5853
  deliver_to_bound_conversations=True,
4254
5854
  include_recent_inbound_messages=False,
@@ -4259,6 +5859,8 @@ class ArtifactService:
4259
5859
  "parent_branch": parent_branch,
4260
5860
  "parent_worktree_root": str(parent_worktree_root),
4261
5861
  "summary_path": str(summary_path),
5862
+ "writing_branch": writing_workspace.get("branch") if writing_workspace else None,
5863
+ "writing_worktree_root": writing_workspace.get("worktree_root") if writing_workspace else None,
4262
5864
  }
4263
5865
  ],
4264
5866
  )
@@ -4284,6 +5886,8 @@ class ArtifactService:
4284
5886
  "completed": True,
4285
5887
  "returned_to_branch": parent_branch,
4286
5888
  "returned_to_worktree_root": str(parent_worktree_root),
5889
+ "writing_branch": writing_workspace.get("branch") if writing_workspace else None,
5890
+ "writing_worktree_root": writing_workspace.get("worktree_root") if writing_workspace else None,
4287
5891
  }
4288
5892
 
4289
5893
  def publish_baseline(self, quest_root: Path, payload: dict) -> dict:
@@ -4334,6 +5938,81 @@ class ArtifactService:
4334
5938
  "guidance": "The selected baseline is now attached under baselines/imported. Reuse it before considering a fresh reproduction.",
4335
5939
  }
4336
5940
 
5941
+ def delete_baseline(self, baseline_id: str) -> dict[str, Any]:
5942
+ existing = self.baselines.get(baseline_id, include_deleted=True)
5943
+ if existing is None:
5944
+ raise FileNotFoundError(f"Unknown baseline: {baseline_id}")
5945
+
5946
+ normalized_baseline_id = str(existing.get("baseline_id") or existing.get("entry_id") or baseline_id).strip()
5947
+ already_deleted = self.baselines.is_deleted(normalized_baseline_id)
5948
+ deleted_entry = self.baselines.delete(normalized_baseline_id) if not already_deleted else dict(existing)
5949
+
5950
+ affected_quest_ids: list[str] = []
5951
+ cleared_requested_refs = 0
5952
+ cleared_confirmed_refs = 0
5953
+ deleted_paths: list[str] = []
5954
+ warnings: list[str] = []
5955
+ quests_root = self.home / "quests"
5956
+
5957
+ for quest_yaml in sorted(quests_root.glob("*/quest.yaml")):
5958
+ quest_root = quest_yaml.parent
5959
+ quest_id = quest_root.name
5960
+ quest_touched = False
5961
+ quest_payload = self.quest_service.read_quest_yaml(quest_root)
5962
+
5963
+ requested_ref = (
5964
+ dict(quest_payload.get("requested_baseline_ref") or {})
5965
+ if isinstance(quest_payload.get("requested_baseline_ref"), dict)
5966
+ else {}
5967
+ )
5968
+ if str(requested_ref.get("baseline_id") or "").strip() == normalized_baseline_id:
5969
+ self.quest_service.update_startup_context(quest_root, requested_baseline_ref=None)
5970
+ cleared_requested_refs += 1
5971
+ quest_touched = True
5972
+
5973
+ confirmed_ref = (
5974
+ dict(quest_payload.get("confirmed_baseline_ref") or {})
5975
+ if isinstance(quest_payload.get("confirmed_baseline_ref"), dict)
5976
+ else {}
5977
+ )
5978
+ if str(confirmed_ref.get("baseline_id") or "").strip() == normalized_baseline_id:
5979
+ self.quest_service.update_baseline_state(
5980
+ quest_root,
5981
+ baseline_gate="pending",
5982
+ confirmed_baseline_ref=None,
5983
+ active_anchor="baseline",
5984
+ )
5985
+ cleared_confirmed_refs += 1
5986
+ quest_touched = True
5987
+
5988
+ for root in self._baseline_workspace_roots(quest_root):
5989
+ try:
5990
+ removed = self._remove_baseline_materialization(root, normalized_baseline_id)
5991
+ except OSError as exc:
5992
+ warnings.append(
5993
+ f"Unable to remove baseline materialization under `{root}` for quest `{quest_id}`: {exc}"
5994
+ )
5995
+ continue
5996
+ if removed:
5997
+ deleted_paths.extend(removed)
5998
+ quest_touched = True
5999
+
6000
+ if quest_touched:
6001
+ affected_quest_ids.append(quest_id)
6002
+
6003
+ return {
6004
+ "ok": True,
6005
+ "baseline_id": normalized_baseline_id,
6006
+ "deleted": not already_deleted,
6007
+ "already_deleted": already_deleted,
6008
+ "baseline_registry_entry": deleted_entry,
6009
+ "affected_quest_ids": affected_quest_ids,
6010
+ "cleared_requested_refs": cleared_requested_refs,
6011
+ "cleared_confirmed_refs": cleared_confirmed_refs,
6012
+ "deleted_paths": deleted_paths,
6013
+ "warnings": warnings,
6014
+ }
6015
+
4337
6016
  def confirm_baseline(
4338
6017
  self,
4339
6018
  quest_root: Path,
@@ -4345,9 +6024,11 @@ class ArtifactService:
4345
6024
  summary: str | None = None,
4346
6025
  baseline_kind: str | None = None,
4347
6026
  metric_contract: dict[str, Any] | None = None,
6027
+ metric_directions: dict[str, str] | None = None,
4348
6028
  metrics_summary: dict[str, Any] | None = None,
4349
6029
  primary_metric: dict[str, Any] | None = None,
4350
6030
  auto_advance: bool = True,
6031
+ strict_metric_contract: bool = False,
4351
6032
  ) -> dict[str, Any]:
4352
6033
  resolved = self._resolve_baseline_path(quest_root, baseline_path, baseline_id=baseline_id)
4353
6034
  resolved_baseline_id = str(resolved["baseline_id"] or "").strip()
@@ -4439,6 +6120,85 @@ class ArtifactService:
4439
6120
  or ""
4440
6121
  ).strip() or None
4441
6122
 
6123
+ source_metrics_summary = (
6124
+ selected_variant.get("metrics_summary")
6125
+ if isinstance(selected_variant, dict) and selected_variant.get("metrics_summary") is not None
6126
+ else entry.get("metrics_summary")
6127
+ )
6128
+ entry_metric_contract, entry_primary_metric = self._apply_metric_directions_to_contract(
6129
+ metric_contract=entry.get("metric_contract"),
6130
+ metric_directions=metric_directions,
6131
+ baseline_id=resolved_baseline_id,
6132
+ metrics_summary=source_metrics_summary,
6133
+ primary_metric=entry.get("primary_metric"),
6134
+ baseline_variants=entry.get("baseline_variants"),
6135
+ )
6136
+ entry = {
6137
+ **entry,
6138
+ "metric_contract": entry_metric_contract,
6139
+ "primary_metric": entry_primary_metric or entry.get("primary_metric"),
6140
+ }
6141
+ canonical_baseline = (
6142
+ validate_baseline_metric_contract_submission(
6143
+ metric_contract=entry.get("metric_contract"),
6144
+ metrics_summary=source_metrics_summary,
6145
+ primary_metric=entry.get("primary_metric"),
6146
+ )
6147
+ if strict_metric_contract
6148
+ else canonicalize_baseline_submission(
6149
+ metric_contract=entry.get("metric_contract"),
6150
+ metrics_summary=source_metrics_summary,
6151
+ primary_metric=entry.get("primary_metric"),
6152
+ )
6153
+ )
6154
+ entry = {
6155
+ **entry,
6156
+ "metrics_summary": canonical_baseline["metrics_summary"],
6157
+ "metric_contract": canonical_baseline["metric_contract"],
6158
+ "metric_details": canonical_baseline["metric_details"],
6159
+ }
6160
+ if isinstance(selected_variant, dict):
6161
+ selected_variant = {
6162
+ **selected_variant,
6163
+ "metrics_summary": canonical_baseline["metrics_summary"],
6164
+ }
6165
+ if isinstance(entry.get("baseline_variants"), list):
6166
+ entry["baseline_variants"] = [
6167
+ (
6168
+ {
6169
+ **variant,
6170
+ "metrics_summary": canonical_baseline["metrics_summary"],
6171
+ }
6172
+ if isinstance(variant, dict)
6173
+ and str(variant.get("variant_id") or "").strip() == str(resolved_variant_id or "").strip()
6174
+ else variant
6175
+ )
6176
+ for variant in entry.get("baseline_variants", [])
6177
+ ]
6178
+ primary_metric_id = str(
6179
+ (entry.get("primary_metric") or {}).get("metric_id")
6180
+ or (entry.get("primary_metric") or {}).get("name")
6181
+ or (entry.get("primary_metric") or {}).get("id")
6182
+ or (canonical_baseline["metric_contract"] or {}).get("primary_metric_id")
6183
+ or ""
6184
+ ).strip()
6185
+ if primary_metric_id and primary_metric_id in canonical_baseline["metrics_summary"]:
6186
+ primary_metric_meta = next(
6187
+ (
6188
+ item
6189
+ for item in (canonical_baseline["metric_contract"] or {}).get("metrics", [])
6190
+ if isinstance(item, dict) and str(item.get("metric_id") or "").strip() == primary_metric_id
6191
+ ),
6192
+ {},
6193
+ )
6194
+ entry["primary_metric"] = {
6195
+ **(dict(entry.get("primary_metric") or {}) if isinstance(entry.get("primary_metric"), dict) else {}),
6196
+ "metric_id": primary_metric_id,
6197
+ "value": canonical_baseline["metrics_summary"][primary_metric_id],
6198
+ "direction": primary_metric_meta.get("direction")
6199
+ or (entry.get("primary_metric") or {}).get("direction"),
6200
+ }
6201
+
4442
6202
  metric_contract_json = self._write_baseline_metric_contract_json(
4443
6203
  quest_root,
4444
6204
  baseline_root=resolved_root,
@@ -4540,7 +6300,10 @@ class ArtifactService:
4540
6300
  "artifact": artifact,
4541
6301
  "baseline_registry_entry": registry_entry,
4542
6302
  "snapshot": self.quest_service.snapshot(self._quest_id(quest_root)),
6303
+ "metric_details": canonical_baseline["metric_details"],
4543
6304
  "legacy_guidance": "Baseline gate confirmed. Idea selection is now the default next anchor.",
6305
+ "metric_contract_json_path": str(metric_contract_json.get("path") or ""),
6306
+ "metric_contract_json_rel_path": str(metric_contract_json.get("rel_path") or ""),
4544
6307
  }
4545
6308
 
4546
6309
  def waive_baseline(
@@ -5159,6 +6922,8 @@ class ArtifactService:
5159
6922
  return f"idea/{quest_id}-{idea_id}"
5160
6923
  if branch_kind == "quest":
5161
6924
  return f"quest/{quest_id}"
6925
+ if branch_kind == "paper":
6926
+ return f"paper/{run_id or generate_id('paper')}"
5162
6927
  return f"run/{run_id or generate_id('run')}"
5163
6928
 
5164
6929
  def _bound_conversations(self, quest_root: Path) -> list[str]:
@@ -5187,7 +6952,14 @@ class ArtifactService:
5187
6952
  return targets
5188
6953
 
5189
6954
  def _connectors_config(self) -> dict[str, Any]:
5190
- return ConfigManager(self.home).load_named_normalized("connectors")
6955
+ manager = ConfigManager(self.home)
6956
+ connectors = manager.load_named_normalized("connectors")
6957
+ for name, config in list(connectors.items()):
6958
+ if str(name).startswith("_") or not isinstance(config, dict):
6959
+ continue
6960
+ if not manager.is_connector_system_enabled(str(name)):
6961
+ config["enabled"] = False
6962
+ return connectors
5191
6963
 
5192
6964
  @staticmethod
5193
6965
  def _delivery_policy(connectors: dict[str, Any]) -> str: