@researai/deepscientist 1.5.8 → 1.5.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/LICENSE +186 -21
  2. package/README.md +108 -95
  3. package/assets/branding/connector-qq.png +0 -0
  4. package/assets/branding/connector-rokid.png +0 -0
  5. package/assets/branding/connector-weixin.png +0 -0
  6. package/assets/branding/projects.png +0 -0
  7. package/bin/ds.js +172 -13
  8. package/docs/assets/branding/projects.png +0 -0
  9. package/docs/en/00_QUICK_START.md +308 -70
  10. package/docs/en/01_SETTINGS_REFERENCE.md +3 -0
  11. package/docs/en/02_START_RESEARCH_GUIDE.md +112 -0
  12. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +62 -179
  13. package/docs/en/09_DOCTOR.md +41 -5
  14. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +137 -0
  15. package/docs/en/11_LICENSE_AND_RISK.md +256 -0
  16. package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +427 -0
  17. package/docs/en/13_CORE_ARCHITECTURE_GUIDE.md +297 -0
  18. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  19. package/docs/en/99_ACKNOWLEDGEMENTS.md +4 -1
  20. package/docs/en/README.md +79 -0
  21. package/docs/images/lingzhu/rokid-agent-platform-create.png +0 -0
  22. package/docs/images/weixin/weixin-plugin-entry.png +0 -0
  23. package/docs/images/weixin/weixin-plugin-entry.svg +33 -0
  24. package/docs/images/weixin/weixin-qr-confirm.svg +30 -0
  25. package/docs/images/weixin/weixin-quest-media-flow.svg +44 -0
  26. package/docs/images/weixin/weixin-settings-bind.svg +57 -0
  27. package/docs/zh/00_QUICK_START.md +315 -74
  28. package/docs/zh/01_SETTINGS_REFERENCE.md +3 -0
  29. package/docs/zh/02_START_RESEARCH_GUIDE.md +112 -0
  30. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +62 -193
  31. package/docs/zh/09_DOCTOR.md +41 -5
  32. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +144 -0
  33. package/docs/zh/11_LICENSE_AND_RISK.md +256 -0
  34. package/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +423 -0
  35. package/docs/zh/13_CORE_ARCHITECTURE_GUIDE.md +296 -0
  36. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  37. package/docs/zh/99_ACKNOWLEDGEMENTS.md +4 -1
  38. package/docs/zh/README.md +126 -0
  39. package/install.sh +0 -34
  40. package/package.json +3 -3
  41. package/pyproject.toml +2 -2
  42. package/src/deepscientist/__init__.py +1 -1
  43. package/src/deepscientist/annotations.py +343 -0
  44. package/src/deepscientist/artifact/arxiv.py +484 -37
  45. package/src/deepscientist/artifact/metrics.py +1 -3
  46. package/src/deepscientist/artifact/service.py +1347 -111
  47. package/src/deepscientist/arxiv_library.py +275 -0
  48. package/src/deepscientist/bash_exec/service.py +9 -0
  49. package/src/deepscientist/bridges/builtins.py +2 -0
  50. package/src/deepscientist/bridges/connectors.py +447 -0
  51. package/src/deepscientist/channels/__init__.py +2 -0
  52. package/src/deepscientist/channels/builtins.py +3 -1
  53. package/src/deepscientist/channels/qq.py +1 -1
  54. package/src/deepscientist/channels/qq_gateway.py +1 -1
  55. package/src/deepscientist/channels/relay.py +7 -1
  56. package/src/deepscientist/channels/weixin.py +59 -0
  57. package/src/deepscientist/channels/weixin_ilink.py +317 -0
  58. package/src/deepscientist/config/models.py +22 -2
  59. package/src/deepscientist/config/service.py +431 -60
  60. package/src/deepscientist/connector/__init__.py +4 -0
  61. package/src/deepscientist/connector/connector_profiles.py +481 -0
  62. package/src/deepscientist/connector/lingzhu_support.py +668 -0
  63. package/src/deepscientist/connector/qq_profiles.py +206 -0
  64. package/src/deepscientist/connector/weixin_support.py +663 -0
  65. package/src/deepscientist/connector_profiles.py +1 -374
  66. package/src/deepscientist/connector_runtime.py +2 -0
  67. package/src/deepscientist/daemon/api/handlers.py +295 -5
  68. package/src/deepscientist/daemon/api/router.py +16 -1
  69. package/src/deepscientist/daemon/app.py +1130 -61
  70. package/src/deepscientist/doctor.py +5 -2
  71. package/src/deepscientist/gitops/diff.py +120 -29
  72. package/src/deepscientist/lingzhu_support.py +1 -182
  73. package/src/deepscientist/mcp/server.py +14 -5
  74. package/src/deepscientist/prompts/builder.py +29 -1
  75. package/src/deepscientist/qq_profiles.py +1 -196
  76. package/src/deepscientist/quest/node_traces.py +152 -2
  77. package/src/deepscientist/quest/service.py +169 -43
  78. package/src/deepscientist/quest/stage_views.py +172 -9
  79. package/src/deepscientist/registries/baseline.py +56 -4
  80. package/src/deepscientist/runners/codex.py +55 -3
  81. package/src/deepscientist/weixin_support.py +1 -0
  82. package/src/prompts/connectors/lingzhu.md +3 -1
  83. package/src/prompts/connectors/weixin.md +230 -0
  84. package/src/prompts/system.md +9 -0
  85. package/src/skills/idea/SKILL.md +16 -0
  86. package/src/skills/idea/references/literature-survey-template.md +24 -0
  87. package/src/skills/idea/references/related-work-playbook.md +4 -0
  88. package/src/skills/idea/references/selection-gate.md +9 -0
  89. package/src/skills/write/SKILL.md +1 -1
  90. package/src/tui/package.json +1 -1
  91. package/src/ui/dist/assets/{AiManusChatView-m2FNtwbn.js → AiManusChatView-D0mTXG4-.js} +156 -48
  92. package/src/ui/dist/assets/{AnalysisPlugin-BMTF8EGL.js → AnalysisPlugin-Db0cTXxm.js} +1 -1
  93. package/src/ui/dist/assets/{CliPlugin-BEOWgxCI.js → CliPlugin-DrV8je02.js} +164 -9
  94. package/src/ui/dist/assets/{CodeEditorPlugin-BCXvjqmb.js → CodeEditorPlugin-QXMSCH71.js} +8 -8
  95. package/src/ui/dist/assets/{CodeViewerPlugin-DaJcy3nD.js → CodeViewerPlugin-7hhtWj_E.js} +5 -5
  96. package/src/ui/dist/assets/{DocViewerPlugin-ByfeIq4K.js → DocViewerPlugin-BWMSnRJe.js} +3 -3
  97. package/src/ui/dist/assets/{GitDiffViewerPlugin-Cksf3VZ-.js → GitDiffViewerPlugin-7J9h9Vy_.js} +20 -21
  98. package/src/ui/dist/assets/{ImageViewerPlugin-CFz-OsTS.js → ImageViewerPlugin-CHJl_0lr.js} +5 -5
  99. package/src/ui/dist/assets/{LabCopilotPanel-CJ1cJzoX.js → LabCopilotPanel-1qSow1es.js} +11 -11
  100. package/src/ui/dist/assets/{LabPlugin-BF3dVJwa.js → LabPlugin-eQpPPCEp.js} +2 -1
  101. package/src/ui/dist/assets/{LatexPlugin-DDkwZ6Sj.js → LatexPlugin-BwRfi89Z.js} +7 -7
  102. package/src/ui/dist/assets/{MarkdownViewerPlugin-HAuvurcT.js → MarkdownViewerPlugin-836PVQWV.js} +4 -4
  103. package/src/ui/dist/assets/{MarketplacePlugin-BtoTYy2C.js → MarketplacePlugin-C2y_556i.js} +3 -3
  104. package/src/ui/dist/assets/{NotebookEditor-CSJYx7b-.js → NotebookEditor-BRzJbGsn.js} +12 -12
  105. package/src/ui/dist/assets/{NotebookEditor-DQgRezm_.js → NotebookEditor-DIX7Mlzu.js} +1 -1
  106. package/src/ui/dist/assets/{PdfLoader-DPa_-fv6.js → PdfLoader-DzRaTAlq.js} +14 -7
  107. package/src/ui/dist/assets/{PdfMarkdownPlugin-BZpXOEjm.js → PdfMarkdownPlugin-DZUfIUnp.js} +73 -6
  108. package/src/ui/dist/assets/{PdfViewerPlugin-BT8a6wGR.js → PdfViewerPlugin-BwtICzue.js} +103 -34
  109. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +3627 -0
  110. package/src/ui/dist/assets/{SearchPlugin-D_blveZi.js → SearchPlugin-DHeIAMsx.js} +1 -1
  111. package/src/ui/dist/assets/{TextViewerPlugin-Btx0M3hX.js → TextViewerPlugin-C3tCmFox.js} +5 -4
  112. package/src/ui/dist/assets/{VNCViewer-DImJO4rO.js → VNCViewer-CQsKVm3t.js} +10 -10
  113. package/src/ui/dist/assets/bot-BEA2vWuK.js +21 -0
  114. package/src/ui/dist/assets/branding/logo-rokid.png +0 -0
  115. package/src/ui/dist/assets/browser-BAcuE0Xj.js +2895 -0
  116. package/src/ui/dist/assets/{code-BUfXGJSl.js → code-XfbSR8K2.js} +1 -1
  117. package/src/ui/dist/assets/{file-content-VqamwI3X.js → file-content-BjxNaIfy.js} +1 -1
  118. package/src/ui/dist/assets/{file-diff-panel-C_wOoS7a.js → file-diff-panel-D_lLVQk0.js} +1 -1
  119. package/src/ui/dist/assets/{file-socket-D2bTuMVP.js → file-socket-D9x_5vlY.js} +1 -1
  120. package/src/ui/dist/assets/{image-BZkGJ4mM.js → image-BhWT33W1.js} +1 -1
  121. package/src/ui/dist/assets/{index-DdRW6RMJ.js → index--c4iXtuy.js} +12 -12
  122. package/src/ui/dist/assets/{index-CxkvSeKw.js → index-BDxipwrC.js} +2 -2
  123. package/src/ui/dist/assets/{index-DjggJovS.js → index-DZTZ8mWP.js} +14934 -9613
  124. package/src/ui/dist/assets/{index-DXZ1daiJ.css → index-Dqj-Mjb4.css} +2 -13
  125. package/src/ui/dist/assets/index-PJbSbPTy.js +25 -0
  126. package/src/ui/dist/assets/{monaco-DHMc7kKM.js → monaco-K8izTGgo.js} +1 -1
  127. package/src/ui/dist/assets/{pdf-effect-queue-DSw_D3RV.js → pdf-effect-queue-DfBors6y.js} +16 -1
  128. package/src/ui/dist/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
  129. package/src/ui/dist/assets/{popover-B85oCgCS.js → popover-yFK1J4fL.js} +1 -1
  130. package/src/ui/dist/assets/{project-sync-DOMCcPac.js → project-sync-PENr2zcz.js} +1 -74
  131. package/src/ui/dist/assets/select-CAbJDfYv.js +1690 -0
  132. package/src/ui/dist/assets/{sigma-BO2rQrl3.js → sigma-DEuYJqTl.js} +1 -1
  133. package/src/ui/dist/assets/{index-D9QIGcmc.js → square-check-big-omoSUmcd.js} +2 -13
  134. package/src/ui/dist/assets/{trash-BsVEH_dV.js → trash--F119N47.js} +1 -1
  135. package/src/ui/dist/assets/{useCliAccess-b8L6JuZm.js → useCliAccess-D31UR23I.js} +1 -1
  136. package/src/ui/dist/assets/{useFileDiffOverlay-BY7uA9hV.js → useFileDiffOverlay-BH6KcMzq.js} +1 -1
  137. package/src/ui/dist/assets/{wrap-text-BwyVuUIK.js → wrap-text-CZ613PM5.js} +1 -1
  138. package/src/ui/dist/assets/{zoom-out-RDpLugQP.js → zoom-out-BgDLAv3z.js} +1 -1
  139. package/src/ui/dist/index.html +2 -2
  140. package/src/ui/dist/assets/AutoFigurePlugin-BGxN8Umr.css +0 -3056
  141. package/src/ui/dist/assets/AutoFigurePlugin-DxPdMUNb.js +0 -8149
  142. package/src/ui/dist/assets/PdfViewerPlugin-BJXtIwj_.css +0 -260
  143. package/src/ui/dist/assets/Stepper-DH2k75Vo.js +0 -158
  144. package/src/ui/dist/assets/bibtex-B-Hqu0Sg.js +0 -189
  145. package/src/ui/dist/assets/file-utils--zJCPN1i.js +0 -109
  146. package/src/ui/dist/assets/message-square-FUIPIhU2.js +0 -16
  147. package/src/ui/dist/assets/pdfjs-DU1YE8WO.js +0 -3
  148. package/src/ui/dist/assets/tooltip-B1OspAkx.js +0 -108
@@ -5,6 +5,7 @@ import shutil
5
5
  from pathlib import Path
6
6
  from typing import Any
7
7
 
8
+ from ..arxiv_library import ArxivLibraryService
8
9
  from ..bridges import register_builtin_connector_bridges
9
10
  from ..channels import get_channel_factory, register_builtin_channels
10
11
  from ..config import ConfigManager
@@ -38,7 +39,7 @@ from ..shared import (
38
39
  )
39
40
  from ..quest import QuestService
40
41
  from ..memory.frontmatter import dump_markdown_document, load_markdown_document
41
- from .arxiv import read_arxiv_content
42
+ from .arxiv import fetch_arxiv_metadata, read_arxiv_content
42
43
  from .guidance import build_guidance_for_record, guidance_summary
43
44
  from .metrics import (
44
45
  baseline_metric_lines,
@@ -48,6 +49,7 @@ from .metrics import (
48
49
  compute_progress_eval,
49
50
  MetricContractValidationError,
50
51
  normalize_metric_contract,
52
+ normalize_metric_direction,
51
53
  normalize_metric_rows,
52
54
  normalize_metrics_summary,
53
55
  selected_baseline_metrics,
@@ -95,6 +97,77 @@ class ArtifactService:
95
97
  self.home = home
96
98
  self.baselines = BaselineRegistry(home)
97
99
  self.quest_service = QuestService(home)
100
+ self.arxiv_library = ArxivLibraryService()
101
+
102
+ @staticmethod
103
+ def _notification_text(value: object) -> str | None:
104
+ text = str(value or "")
105
+ if not text.strip():
106
+ return None
107
+ normalized_lines: list[str] = []
108
+ for raw_line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
109
+ cleaned = re.sub(r"[ \t]+", " ", raw_line).strip()
110
+ if not cleaned:
111
+ if normalized_lines and normalized_lines[-1] != "":
112
+ normalized_lines.append("")
113
+ continue
114
+ normalized_lines.append(cleaned)
115
+ while normalized_lines and normalized_lines[-1] == "":
116
+ normalized_lines.pop()
117
+ rendered = "\n".join(normalized_lines).strip()
118
+ return rendered or None
119
+
120
+ @classmethod
121
+ def _notification_block(cls, value: object) -> str | None:
122
+ if value is None:
123
+ return None
124
+ if isinstance(value, dict):
125
+ lines: list[str] = []
126
+ for key, item in value.items():
127
+ label = cls._format_route_label(key) or str(key).strip()
128
+ block = cls._notification_block(item)
129
+ if not label or not block:
130
+ continue
131
+ block_lines = block.splitlines()
132
+ if len(block_lines) == 1:
133
+ lines.append(f"- {label}: {block_lines[0]}")
134
+ continue
135
+ lines.append(f"- {label}:")
136
+ lines.extend(f" {line}" if line else "" for line in block_lines)
137
+ return "\n".join(lines).strip() or None
138
+ if isinstance(value, (list, tuple, set)):
139
+ lines = []
140
+ for item in value:
141
+ block = cls._notification_block(item)
142
+ if not block:
143
+ continue
144
+ block_lines = block.splitlines()
145
+ if not block_lines:
146
+ continue
147
+ lines.append(f"- {block_lines[0]}")
148
+ lines.extend(f" {line}" if line else "" for line in block_lines[1:])
149
+ return "\n".join(lines).strip() or None
150
+ return cls._notification_text(value)
151
+
152
+ @classmethod
153
+ def _append_notification_section(cls, lines: list[str], label: str, value: object) -> None:
154
+ block = cls._notification_block(value)
155
+ if not block:
156
+ return
157
+ lines.extend(["", f"{label}:", block])
158
+
159
+ @staticmethod
160
+ def _append_notification_file_section(lines: list[str], entries: list[tuple[str, str | None]]) -> None:
161
+ normalized = [
162
+ (label, str(path).strip())
163
+ for label, path in entries
164
+ if str(path or "").strip()
165
+ ]
166
+ if not normalized:
167
+ return
168
+ lines.extend(["", "Files:"])
169
+ for label, path in normalized:
170
+ lines.append(f"- {label}: `{path}`")
98
171
 
99
172
  def _normalize_evaluation_summary(self, payload: dict[str, Any] | None) -> dict[str, str] | None:
100
173
  if not isinstance(payload, dict):
@@ -131,6 +204,302 @@ class ArtifactService:
131
204
  lines = [f"- {label}: {normalized[key]}" for key, label in labels if normalized.get(key)]
132
205
  return lines or ["- Not recorded."]
133
206
 
207
+ @staticmethod
208
+ def _format_route_label(value: object) -> str | None:
209
+ normalized = str(value or "").strip().replace("_", " ").replace("-", " ")
210
+ if not normalized:
211
+ return None
212
+ return " ".join(part.capitalize() for part in normalized.split())
213
+
214
+ def _format_foundation_label(self, foundation_ref: dict[str, Any] | None, *, fallback: str | None = None) -> str:
215
+ payload = dict(foundation_ref or {})
216
+ label = self._notification_text(payload.get("label"))
217
+ if label:
218
+ return label
219
+ kind = self._notification_text(payload.get("kind"))
220
+ ref = self._notification_text(payload.get("ref"))
221
+ branch = self._notification_text(payload.get("branch"))
222
+ if kind and ref:
223
+ return f"{kind} {ref}"
224
+ if branch:
225
+ return branch
226
+ return fallback or "current head"
227
+
228
+ def _build_idea_interaction_message(
229
+ self,
230
+ *,
231
+ action: str,
232
+ idea_id: str,
233
+ title: str | None,
234
+ problem: str | None,
235
+ hypothesis: str | None,
236
+ mechanism: str | None,
237
+ foundation_label: str | None,
238
+ branch_name: str,
239
+ next_target: str | None,
240
+ idea_md_rel_path: str | None,
241
+ draft_md_rel_path: str | None,
242
+ ) -> str:
243
+ lead = "is now active" if action == "create" else "was revised"
244
+ lines = [f"Idea `{idea_id}` {lead} on branch `{branch_name}`."]
245
+ self._append_notification_section(lines, "Title", title)
246
+ self._append_notification_section(lines, "Problem", problem)
247
+ self._append_notification_section(lines, "Hypothesis", hypothesis)
248
+ self._append_notification_section(lines, "Mechanism", mechanism)
249
+ if foundation_label:
250
+ self._append_notification_section(lines, "Foundation", foundation_label)
251
+ if next_target:
252
+ self._append_notification_section(lines, "Next route", self._format_route_label(next_target) or next_target)
253
+ self._append_notification_file_section(
254
+ lines,
255
+ [
256
+ ("Idea doc", idea_md_rel_path),
257
+ ("Draft", draft_md_rel_path),
258
+ ],
259
+ )
260
+ return "\n".join(lines)
261
+
262
+ def _build_main_experiment_interaction_message(
263
+ self,
264
+ *,
265
+ run_id: str,
266
+ branch_name: str,
267
+ verdict: str,
268
+ primary_metric_id: str | None,
269
+ primary_value: object,
270
+ primary_baseline: object,
271
+ primary_delta: object,
272
+ decimals: int | None,
273
+ conclusion: str | None,
274
+ evaluation_summary: dict[str, str] | None,
275
+ breakthrough_level: str | None,
276
+ recommended_next_route: str | None,
277
+ run_md_rel_path: str | None,
278
+ result_json_rel_path: str | None,
279
+ ) -> str:
280
+ lines = [f"Main experiment `{run_id}` finished on branch `{branch_name}`."]
281
+ outcome_lines: list[str] = []
282
+ if primary_metric_id and primary_value is not None:
283
+ metric_text = f"{primary_metric_id}={self._format_metric_value(primary_value, decimals)}"
284
+ if primary_baseline is not None and primary_delta is not None:
285
+ metric_text += (
286
+ f", baseline={self._format_metric_value(primary_baseline, decimals)}, "
287
+ f"delta={self._format_metric_value(primary_delta, decimals)}"
288
+ )
289
+ outcome_lines.append(f"- Metric: {metric_text}")
290
+ outcome_lines.append(f"- Verdict: {self._format_route_label(verdict) or verdict}")
291
+ if self._notification_text(breakthrough_level):
292
+ outcome_lines.append(f"- Breakthrough level: {self._notification_text(breakthrough_level)}")
293
+ if recommended_next_route:
294
+ outcome_lines.append(
295
+ f"- Recommended next route: {self._format_route_label(recommended_next_route) or recommended_next_route}"
296
+ )
297
+ if outcome_lines:
298
+ lines.extend(["", "Outcome:", *outcome_lines])
299
+ self._append_notification_section(
300
+ lines,
301
+ "Conclusion",
302
+ self._notification_text(conclusion) or (evaluation_summary or {}).get("takeaway"),
303
+ )
304
+ normalized_evaluation_summary = self._normalize_evaluation_summary(evaluation_summary)
305
+ if normalized_evaluation_summary:
306
+ lines.extend(["", "Evaluation summary:", *self._evaluation_summary_markdown_lines(normalized_evaluation_summary)])
307
+ self._append_notification_file_section(
308
+ lines,
309
+ [
310
+ ("Run log", run_md_rel_path),
311
+ ("Result", result_json_rel_path),
312
+ ],
313
+ )
314
+ return "\n".join(lines)
315
+
316
+ def _build_outline_interaction_message(
317
+ self,
318
+ *,
319
+ action: str,
320
+ outline_id: str,
321
+ title: str | None,
322
+ selected_reason: str | None,
323
+ story: str | None,
324
+ research_questions: object,
325
+ experimental_designs: object,
326
+ selected_outline_rel_path: str | None,
327
+ outline_selection_rel_path: str | None,
328
+ revised_outline_rel_path: str | None = None,
329
+ ) -> str:
330
+ verb = "selected" if action == "select" else "revised"
331
+ lines = [f"Paper outline `{outline_id}` was {verb} and promoted into the writing stage."]
332
+ self._append_notification_section(lines, "Title", title)
333
+ self._append_notification_section(lines, "Reason", selected_reason)
334
+ self._append_notification_section(lines, "Story", story)
335
+ self._append_notification_section(lines, "Research questions", research_questions)
336
+ self._append_notification_section(lines, "Experimental designs", experimental_designs)
337
+ self._append_notification_section(
338
+ lines,
339
+ "Next route",
340
+ "Continue writing on the paper branch, or launch outline-bound analysis if evidence is still missing.",
341
+ )
342
+ self._append_notification_file_section(
343
+ lines,
344
+ [
345
+ ("Selected outline", selected_outline_rel_path),
346
+ ("Selection note", outline_selection_rel_path),
347
+ ("Revision record", revised_outline_rel_path),
348
+ ],
349
+ )
350
+ return "\n".join(lines)
351
+
352
+ def _build_analysis_campaign_interaction_message(
353
+ self,
354
+ *,
355
+ campaign_id: str,
356
+ goal: str | None,
357
+ parent_branch: str,
358
+ selected_outline_ref: str | None,
359
+ first_slice: dict[str, Any],
360
+ todo_manifest_rel_path: str | None,
361
+ ) -> str:
362
+ lines = [f"Analysis campaign `{campaign_id}` is ready from parent branch `{parent_branch}`."]
363
+ self._append_notification_section(lines, "Goal", goal)
364
+ if selected_outline_ref:
365
+ self._append_notification_section(lines, "Selected outline", f"`{selected_outline_ref}`")
366
+ next_slice_lines = [
367
+ f"- Slice: `{first_slice.get('slice_id')}`",
368
+ f"- Branch: `{first_slice.get('branch')}`",
369
+ ]
370
+ if self._notification_text(first_slice.get("title")):
371
+ next_slice_lines.append(f"- Focus: {self._notification_text(first_slice.get('title'))}")
372
+ lines.extend(["", "Next slice:", *next_slice_lines])
373
+ requirement = self._notification_text(first_slice.get("must_not_simplify") or first_slice.get("goal"))
374
+ if requirement:
375
+ self._append_notification_section(lines, "Core requirement", requirement)
376
+ self._append_notification_file_section(lines, [("Todo manifest", todo_manifest_rel_path)])
377
+ return "\n".join(lines)
378
+
379
+ def _build_analysis_slice_interaction_message(
380
+ self,
381
+ *,
382
+ campaign_id: str,
383
+ slice_id: str,
384
+ evaluation_summary: dict[str, str] | None,
385
+ claim_impact: str | None,
386
+ next_slice: dict[str, Any],
387
+ mirror_rel_path: str | None,
388
+ ) -> str:
389
+ lines = [f"Analysis slice `{slice_id}` from campaign `{campaign_id}` is complete."]
390
+ normalized_evaluation_summary = self._normalize_evaluation_summary(evaluation_summary)
391
+ if normalized_evaluation_summary:
392
+ lines.extend(["", "Evaluation summary:", *self._evaluation_summary_markdown_lines(normalized_evaluation_summary)])
393
+ self._append_notification_section(lines, "Claim impact", claim_impact)
394
+ lines.extend(
395
+ [
396
+ "",
397
+ "Next slice:",
398
+ f"- Slice: `{next_slice.get('slice_id')}`",
399
+ f"- Branch: `{next_slice.get('branch')}`",
400
+ ]
401
+ )
402
+ requirement = self._notification_text(next_slice.get("must_not_simplify") or next_slice.get("goal"))
403
+ if requirement:
404
+ self._append_notification_section(lines, "Core requirement", requirement)
405
+ self._append_notification_file_section(lines, [("Parent mirror", mirror_rel_path)])
406
+ return "\n".join(lines)
407
+
408
+ def _build_analysis_complete_interaction_message(
409
+ self,
410
+ *,
411
+ campaign_id: str,
412
+ completed_slices: list[dict[str, Any]],
413
+ summary_rel_path: str | None,
414
+ writing_branch: str | None,
415
+ writing_worktree_rel_path: str | None,
416
+ ) -> str:
417
+ lines = [f"Analysis campaign `{campaign_id}` is complete."]
418
+ overview_lines = [f"- Completed slices: {len(completed_slices)}"]
419
+ if writing_branch:
420
+ overview_lines.append(f"- Next route: writing is active on branch `{writing_branch}`")
421
+ if writing_worktree_rel_path:
422
+ overview_lines.append(f"- Writing workspace: `{writing_worktree_rel_path}`")
423
+ else:
424
+ overview_lines.append("- Next route: make the next durable decision from the merged analysis evidence.")
425
+ lines.extend(["", "Overview:", *overview_lines])
426
+ completed_slice_lines: list[str] = []
427
+ for item in completed_slices:
428
+ slice_id = str(item.get("slice_id") or "").strip() or "unknown"
429
+ title = self._notification_text(item.get("title"))
430
+ lead = f"- `{slice_id}`"
431
+ if title:
432
+ lead += f": {title}"
433
+ completed_slice_lines.append(lead)
434
+ takeaway = self._notification_text(
435
+ ((item.get("evaluation_summary") or {}) if isinstance(item.get("evaluation_summary"), dict) else {}).get(
436
+ "takeaway"
437
+ )
438
+ )
439
+ if takeaway:
440
+ completed_slice_lines.append(f" Takeaway: {takeaway}")
441
+ claim_impact = self._notification_text(item.get("claim_impact"))
442
+ if claim_impact:
443
+ completed_slice_lines.append(f" Claim impact: {claim_impact}")
444
+ if completed_slice_lines:
445
+ lines.extend(["", "Completed slices:", *completed_slice_lines])
446
+ self._append_notification_file_section(lines, [("Summary", summary_rel_path)])
447
+ return "\n".join(lines)
448
+
449
+ def _build_paper_bundle_interaction_message(
450
+ self,
451
+ *,
452
+ title: str | None,
453
+ summary: str | None,
454
+ paper_branch: str | None,
455
+ source_branch: str | None,
456
+ source_run_id: str | None,
457
+ selected_outline_ref: str | None,
458
+ manifest_rel_path: str | None,
459
+ draft_rel_path: str | None,
460
+ writing_plan_rel_path: str | None,
461
+ references_rel_path: str | None,
462
+ claim_evidence_map_rel_path: str | None,
463
+ compile_report_rel_path: str | None,
464
+ pdf_rel_path: str | None,
465
+ latex_root_rel_path: str | None,
466
+ baseline_inventory_rel_path: str | None,
467
+ open_source_manifest_rel_path: str | None,
468
+ ) -> str:
469
+ bundle_label = self._notification_text(title) or "paper"
470
+ lines = [f"Paper bundle `{bundle_label}` is ready on branch `{paper_branch or 'paper'}`."]
471
+ overview_lines: list[str] = []
472
+ if source_branch:
473
+ overview_lines.append(f"- Source branch: `{source_branch}`")
474
+ if source_run_id:
475
+ overview_lines.append(f"- Source run: `{source_run_id}`")
476
+ if selected_outline_ref:
477
+ overview_lines.append(f"- Selected outline: `{selected_outline_ref}`")
478
+ if overview_lines:
479
+ lines.extend(["", "Overview:", *overview_lines])
480
+ self._append_notification_section(lines, "Summary", summary)
481
+ self._append_notification_file_section(
482
+ lines,
483
+ [
484
+ ("Bundle manifest", manifest_rel_path),
485
+ ("Draft", draft_rel_path),
486
+ ("Writing plan", writing_plan_rel_path),
487
+ ("References", references_rel_path),
488
+ ("Claim-evidence map", claim_evidence_map_rel_path),
489
+ ("Compile report", compile_report_rel_path),
490
+ ("PDF", pdf_rel_path),
491
+ ("LaTeX root", latex_root_rel_path),
492
+ ("Baseline inventory", baseline_inventory_rel_path),
493
+ ("Open-source manifest", open_source_manifest_rel_path),
494
+ ],
495
+ )
496
+ self._append_notification_section(
497
+ lines,
498
+ "Next route",
499
+ "Finalize the paper package, review the bundle artifacts, and publish or close the quest when ready.",
500
+ )
501
+ return "\n".join(lines)
502
+
134
503
  def _load_metric_contract_payload(self, quest_root: Path, metric_contract_json_rel_path: str | None) -> dict[str, Any] | None:
135
504
  rel_path = str(metric_contract_json_rel_path or "").strip()
136
505
  if not rel_path:
@@ -144,6 +513,173 @@ class ArtifactService:
144
513
  payload = read_json(resolved_path, {})
145
514
  return payload if isinstance(payload, dict) and payload else None
146
515
 
516
+ def _normalize_metric_directions(self, metric_directions: object) -> dict[str, str]:
517
+ if not isinstance(metric_directions, dict):
518
+ return {}
519
+ normalized: dict[str, str] = {}
520
+ for raw_metric_id, raw_direction in metric_directions.items():
521
+ metric_id = str(raw_metric_id or "").strip()
522
+ if not metric_id:
523
+ continue
524
+ normalized[metric_id] = normalize_metric_direction(raw_direction, metric_id=metric_id)
525
+ return normalized
526
+
527
+ def _apply_metric_directions_to_contract(
528
+ self,
529
+ *,
530
+ metric_contract: object,
531
+ metric_directions: object,
532
+ baseline_id: str | None = None,
533
+ metrics_summary: object = None,
534
+ metric_rows: object = None,
535
+ primary_metric: object = None,
536
+ baseline_variants: object = None,
537
+ ) -> tuple[dict[str, Any], dict[str, Any] | None]:
538
+ normalized_contract = normalize_metric_contract(
539
+ metric_contract,
540
+ baseline_id=baseline_id,
541
+ metrics_summary=metrics_summary,
542
+ metric_rows=metric_rows,
543
+ primary_metric=primary_metric,
544
+ baseline_variants=baseline_variants,
545
+ )
546
+ normalized_primary_metric = dict(primary_metric or {}) if isinstance(primary_metric, dict) else None
547
+ overrides = self._normalize_metric_directions(metric_directions)
548
+ if not overrides:
549
+ return normalized_contract, normalized_primary_metric
550
+
551
+ metrics_by_id: dict[str, dict[str, Any]] = {}
552
+ ordered_metric_ids: list[str] = []
553
+ for raw_metric in normalized_contract.get("metrics", []):
554
+ if not isinstance(raw_metric, dict):
555
+ continue
556
+ metric_id = str(raw_metric.get("metric_id") or "").strip()
557
+ if not metric_id:
558
+ continue
559
+ metrics_by_id[metric_id] = dict(raw_metric)
560
+ ordered_metric_ids.append(metric_id)
561
+ for metric_id, direction in overrides.items():
562
+ current = metrics_by_id.get(metric_id)
563
+ if current is None:
564
+ current = {
565
+ "metric_id": metric_id,
566
+ "label": metric_id,
567
+ "direction": direction,
568
+ "unit": None,
569
+ "decimals": None,
570
+ "chart_group": "default",
571
+ }
572
+ ordered_metric_ids.append(metric_id)
573
+ else:
574
+ current = {
575
+ **current,
576
+ "direction": direction,
577
+ }
578
+ metrics_by_id[metric_id] = current
579
+
580
+ primary_metric_id = str(
581
+ (normalized_primary_metric or {}).get("metric_id")
582
+ or (normalized_primary_metric or {}).get("name")
583
+ or (normalized_primary_metric or {}).get("id")
584
+ or normalized_contract.get("primary_metric_id")
585
+ or ""
586
+ ).strip()
587
+ if normalized_primary_metric and primary_metric_id in overrides:
588
+ normalized_primary_metric = {
589
+ **normalized_primary_metric,
590
+ "direction": overrides[primary_metric_id],
591
+ }
592
+
593
+ return {
594
+ **normalized_contract,
595
+ "metrics": [metrics_by_id[metric_id] for metric_id in ordered_metric_ids if metric_id in metrics_by_id],
596
+ }, normalized_primary_metric
597
+
598
+ def _merge_run_metric_contract(
599
+ self,
600
+ *,
601
+ baseline_metric_contract: object,
602
+ baseline_primary_metric: object,
603
+ baseline_variants: object,
604
+ run_metric_contract: object,
605
+ metrics_summary: object,
606
+ metric_rows: object,
607
+ baseline_id: str | None = None,
608
+ ) -> dict[str, Any]:
609
+ baseline_contract = normalize_metric_contract(
610
+ baseline_metric_contract,
611
+ baseline_id=baseline_id,
612
+ metrics_summary=metrics_summary,
613
+ metric_rows=metric_rows,
614
+ primary_metric=baseline_primary_metric,
615
+ baseline_variants=baseline_variants,
616
+ )
617
+ if not isinstance(run_metric_contract, dict) or not run_metric_contract:
618
+ return baseline_contract
619
+
620
+ overlay_contract = normalize_metric_contract(
621
+ run_metric_contract,
622
+ baseline_id=baseline_id,
623
+ metrics_summary=metrics_summary,
624
+ metric_rows=metric_rows,
625
+ primary_metric=baseline_contract.get("primary_metric_id"),
626
+ )
627
+ overlay_metrics: dict[str, dict[str, Any]] = {}
628
+ for raw_metric in overlay_contract.get("metrics", []):
629
+ if not isinstance(raw_metric, dict):
630
+ continue
631
+ metric_id = str(raw_metric.get("metric_id") or "").strip()
632
+ if metric_id:
633
+ overlay_metrics[metric_id] = raw_metric
634
+
635
+ merged_metrics: list[dict[str, Any]] = []
636
+ seen_metric_ids: set[str] = set()
637
+ for raw_metric in baseline_contract.get("metrics", []):
638
+ if not isinstance(raw_metric, dict):
639
+ continue
640
+ metric_id = str(raw_metric.get("metric_id") or "").strip()
641
+ if not metric_id:
642
+ continue
643
+ patch = overlay_metrics.get(metric_id) or {}
644
+ merged = dict(raw_metric)
645
+ for field in (
646
+ "label",
647
+ "unit",
648
+ "decimals",
649
+ "chart_group",
650
+ "description",
651
+ "derivation",
652
+ "source_ref",
653
+ "required",
654
+ "origin_path",
655
+ ):
656
+ value = patch.get(field)
657
+ if value is None:
658
+ continue
659
+ if isinstance(value, str) and not value.strip():
660
+ continue
661
+ merged[field] = value
662
+ merged_metrics.append(merged)
663
+ seen_metric_ids.add(metric_id)
664
+
665
+ for metric_id, raw_metric in overlay_metrics.items():
666
+ if metric_id in seen_metric_ids:
667
+ continue
668
+ merged_metrics.append(dict(raw_metric))
669
+
670
+ merged_contract = {
671
+ **baseline_contract,
672
+ "metrics": merged_metrics,
673
+ }
674
+ if not merged_contract.get("evaluation_protocol") and overlay_contract.get("evaluation_protocol") is not None:
675
+ merged_contract["evaluation_protocol"] = overlay_contract.get("evaluation_protocol")
676
+ for key, value in overlay_contract.items():
677
+ if key in {"contract_id", "primary_metric_id", "metrics", "evaluation_protocol"}:
678
+ continue
679
+ if key not in merged_contract and value is not None:
680
+ merged_contract[key] = value
681
+ return merged_contract
682
+
147
683
  def _workspace_root_for(self, quest_root: Path, workspace_root: Path | None = None) -> Path:
148
684
  if workspace_root is not None:
149
685
  return workspace_root
@@ -999,6 +1535,9 @@ class ArtifactService:
999
1535
  continue
1000
1536
  seen_paths.add(key)
1001
1537
  payload = read_yaml(path, {})
1538
+ baseline_id = str(payload.get("source_baseline_id") or "").strip() if isinstance(payload, dict) else ""
1539
+ if baseline_id and self.baselines.is_deleted(baseline_id):
1540
+ continue
1002
1541
  if isinstance(payload, dict) and payload:
1003
1542
  attachments.append(payload)
1004
1543
  if not attachments:
@@ -1011,6 +1550,48 @@ class ArtifactService:
1011
1550
  ),
1012
1551
  )
1013
1552
 
1553
+ def _baseline_workspace_roots(self, quest_root: Path) -> list[Path]:
1554
+ roots: list[Path] = [quest_root]
1555
+ research_state = read_json(quest_root / ".ds" / "research_state.json", {})
1556
+ if isinstance(research_state, dict):
1557
+ for key in (
1558
+ "research_head_worktree_root",
1559
+ "current_workspace_root",
1560
+ "analysis_parent_worktree_root",
1561
+ "paper_parent_worktree_root",
1562
+ ):
1563
+ raw = str(research_state.get(key) or "").strip()
1564
+ if raw:
1565
+ roots.append(Path(raw))
1566
+ worktrees_root = quest_root / ".ds" / "worktrees"
1567
+ if worktrees_root.exists():
1568
+ roots.extend(path for path in sorted(worktrees_root.iterdir()) if path.is_dir())
1569
+ deduped: list[Path] = []
1570
+ seen: set[str] = set()
1571
+ for root in roots:
1572
+ key = str(root.resolve(strict=False))
1573
+ if key in seen:
1574
+ continue
1575
+ seen.add(key)
1576
+ deduped.append(root)
1577
+ return deduped
1578
+
1579
+ @staticmethod
1580
+ def _remove_baseline_materialization(root: Path, baseline_id: str) -> list[str]:
1581
+ deleted_paths: list[str] = []
1582
+ for candidate in (
1583
+ root / "baselines" / "imported" / baseline_id,
1584
+ root / "baselines" / "local" / baseline_id,
1585
+ ):
1586
+ if not candidate.exists():
1587
+ continue
1588
+ if candidate.is_dir():
1589
+ shutil.rmtree(candidate)
1590
+ else:
1591
+ candidate.unlink()
1592
+ deleted_paths.append(str(candidate))
1593
+ return deleted_paths
1594
+
1014
1595
  def _resolve_baseline_path(
1015
1596
  self,
1016
1597
  quest_root: Path,
@@ -1867,8 +2448,19 @@ class ArtifactService:
1867
2448
  ) -> tuple[str, Path, str | None]:
1868
2449
  current_root_raw = str(state.get("current_workspace_root") or "").strip()
1869
2450
  head_root_raw = str(state.get("research_head_worktree_root") or "").strip()
2451
+ paper_parent_root_raw = str(state.get("paper_parent_worktree_root") or "").strip()
2452
+ current_branch_raw = str(state.get("current_workspace_branch") or "").strip()
2453
+ research_head_branch_raw = str(state.get("research_head_branch") or "").strip()
2454
+ paper_parent_branch_raw = str(state.get("paper_parent_branch") or "").strip()
2455
+ workspace_mode = str(state.get("workspace_mode") or "").strip().lower()
2456
+ prefer_paper_parent = workspace_mode == "paper" or self._branch_kind_from_name(current_branch_raw) == "paper"
1870
2457
  parent_worktree_root: Path | None = None
1871
- for raw in (current_root_raw, head_root_raw):
2458
+ root_candidates = (
2459
+ (paper_parent_root_raw, head_root_raw, current_root_raw)
2460
+ if prefer_paper_parent
2461
+ else (current_root_raw, head_root_raw, paper_parent_root_raw)
2462
+ )
2463
+ for raw in root_candidates:
1872
2464
  if not raw:
1873
2465
  continue
1874
2466
  candidate = Path(raw)
@@ -1879,10 +2471,21 @@ class ArtifactService:
1879
2471
  parent_worktree_root = self._workspace_root_for(quest_root)
1880
2472
 
1881
2473
  parent_branch = (
1882
- str(state.get("current_workspace_branch") or "").strip()
1883
- or str(state.get("research_head_branch") or "").strip()
1884
- or current_branch(parent_worktree_root)
1885
- or current_branch(self._workspace_root_for(quest_root))
2474
+ (
2475
+ paper_parent_branch_raw
2476
+ or research_head_branch_raw
2477
+ or current_branch_raw
2478
+ or current_branch(parent_worktree_root)
2479
+ or current_branch(self._workspace_root_for(quest_root))
2480
+ )
2481
+ if prefer_paper_parent
2482
+ else (
2483
+ current_branch_raw
2484
+ or research_head_branch_raw
2485
+ or paper_parent_branch_raw
2486
+ or current_branch(parent_worktree_root)
2487
+ or current_branch(self._workspace_root_for(quest_root))
2488
+ )
1886
2489
  )
1887
2490
  parent_branch = str(parent_branch or "").strip()
1888
2491
  if not parent_branch:
@@ -2278,32 +2881,82 @@ class ArtifactService:
2278
2881
  }
2279
2882
 
2280
2883
  def list_paper_outlines(self, quest_root: Path) -> dict[str, Any]:
2281
- selected_outline = read_json(self._paper_selected_outline_path(quest_root), {})
2884
+ selected_outline_path = self._paper_selected_outline_path(quest_root)
2885
+ selected_outline = read_json(selected_outline_path, {})
2282
2886
  selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
2283
- outlines: list[dict[str, Any]] = []
2284
- for status, root in (
2285
- ("candidate", self._paper_outline_candidates_root(quest_root)),
2286
- ("revised", self._paper_outline_revisions_root(quest_root)),
2287
- ):
2288
- for path in sorted(root.glob("outline-*.json")):
2289
- record = read_json(path, {})
2290
- if not isinstance(record, dict) or not record:
2887
+ if not selected_outline:
2888
+ fallback_selected_outline_path = quest_root / "paper" / "selected_outline.json"
2889
+ fallback_selected_outline = read_json(fallback_selected_outline_path, {})
2890
+ if isinstance(fallback_selected_outline, dict) and fallback_selected_outline:
2891
+ selected_outline = fallback_selected_outline
2892
+ selected_outline_path = fallback_selected_outline_path
2893
+
2894
+ selected_outline_id = str(selected_outline.get("outline_id") or "").strip()
2895
+ status_rank = {"candidate": 1, "revised": 2, "selected": 3}
2896
+ outlines_by_id: dict[str, dict[str, Any]] = {}
2897
+ seen_paper_roots: set[str] = set()
2898
+ paper_roots: list[Path] = []
2899
+ for root in (self._paper_root(quest_root), quest_root / "paper"):
2900
+ try:
2901
+ key = str(root.resolve())
2902
+ except FileNotFoundError:
2903
+ key = str(root)
2904
+ if key in seen_paper_roots:
2905
+ continue
2906
+ seen_paper_roots.add(key)
2907
+ paper_roots.append(root)
2908
+
2909
+ for paper_root in paper_roots:
2910
+ for default_status, relative_parts in (
2911
+ ("candidate", ("outlines", "candidates")),
2912
+ ("revised", ("outlines", "revisions")),
2913
+ ):
2914
+ root = paper_root.joinpath(*relative_parts)
2915
+ if not root.exists():
2291
2916
  continue
2292
- outline_id = str(record.get("outline_id") or path.stem).strip() or path.stem
2293
- outlines.append(
2294
- {
2917
+ for path in sorted(root.glob("outline-*.json")):
2918
+ record = read_json(path, {})
2919
+ if not isinstance(record, dict) or not record:
2920
+ continue
2921
+ outline_id = str(record.get("outline_id") or path.stem).strip() or path.stem
2922
+ item = {
2295
2923
  "outline_id": outline_id,
2296
2924
  "title": str(record.get("title") or outline_id).strip() or outline_id,
2297
- "status": str(record.get("status") or status).strip() or status,
2925
+ "status": str(record.get("status") or default_status).strip() or default_status,
2298
2926
  "review_result": str(record.get("review_result") or "").strip() or None,
2299
2927
  "path": str(path),
2300
- "is_selected": outline_id == str(selected_outline.get("outline_id") or "").strip(),
2928
+ "is_selected": outline_id == selected_outline_id,
2301
2929
  }
2302
- )
2930
+ current = outlines_by_id.get(outline_id)
2931
+ if current is None or status_rank.get(str(item.get("status") or ""), 0) >= status_rank.get(
2932
+ str(current.get("status") or ""),
2933
+ 0,
2934
+ ):
2935
+ outlines_by_id[outline_id] = item
2936
+
2937
+ if selected_outline_id:
2938
+ selected_item = {
2939
+ "outline_id": selected_outline_id,
2940
+ "title": str(selected_outline.get("title") or selected_outline_id).strip() or selected_outline_id,
2941
+ "status": str(selected_outline.get("status") or "selected").strip() or "selected",
2942
+ "review_result": str(selected_outline.get("review_result") or "").strip() or None,
2943
+ "path": str(selected_outline_path),
2944
+ "is_selected": True,
2945
+ }
2946
+ current = outlines_by_id.get(selected_outline_id)
2947
+ if current is None or status_rank.get(str(selected_item.get("status") or ""), 0) >= status_rank.get(
2948
+ str(current.get("status") or ""),
2949
+ 0,
2950
+ ):
2951
+ outlines_by_id[selected_outline_id] = selected_item
2952
+ else:
2953
+ current["is_selected"] = True
2954
+
2955
+ outlines = list(outlines_by_id.values())
2303
2956
  outlines.sort(key=lambda item: (str(item.get("outline_id") or ""), str(item.get("status") or "")))
2304
2957
  return {
2305
2958
  "ok": True,
2306
- "selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
2959
+ "selected_outline_ref": selected_outline_id or None,
2307
2960
  "selected_outline": selected_outline or None,
2308
2961
  "count": len(outlines),
2309
2962
  "outlines": outlines,
@@ -2366,8 +3019,296 @@ class ArtifactService:
2366
3019
  deduped.append(path)
2367
3020
  return deduped
2368
3021
 
2369
- def arxiv(self, paper_id: str, *, full_text: bool = False) -> dict[str, Any]:
2370
- return read_arxiv_content(paper_id, full_text=full_text)
3022
+ @staticmethod
3023
+ def _arxiv_content_from_item(item: dict[str, Any]) -> str:
3024
+ title = str(item.get("title") or item.get("display_name") or item.get("arxiv_id") or "arXiv paper").strip()
3025
+ authors = [str(author).strip() for author in (item.get("authors") or []) if str(author).strip()]
3026
+ categories = [str(category).strip() for category in (item.get("categories") or []) if str(category).strip()]
3027
+ abstract = str(item.get("abstract") or "").strip() or "Abstract unavailable."
3028
+ overview = str(item.get("overview") or "").strip()
3029
+ lines = [f"# {title}", "", f"- paper_id: {str(item.get('arxiv_id') or '').strip()}"]
3030
+ if item.get("metadata_source"):
3031
+ lines.append(f"- metadata_source: {item['metadata_source']}")
3032
+ if item.get("summary_source"):
3033
+ lines.append(f"- summary_source: {item['summary_source']}")
3034
+ if authors:
3035
+ lines.append(f"- authors: {', '.join(authors)}")
3036
+ if categories:
3037
+ lines.append(f"- categories: {', '.join(categories)}")
3038
+ if item.get("published_at"):
3039
+ lines.append(f"- published_at: {item['published_at']}")
3040
+ if item.get("version") is not None:
3041
+ lines.append(f"- version: v{item['version']}")
3042
+ if overview:
3043
+ lines.extend(["", "## Summary", "", overview])
3044
+ if abstract and abstract != overview:
3045
+ lines.extend(["", "## Abstract", "", abstract])
3046
+ else:
3047
+ lines.extend(["", "## Abstract", "", abstract])
3048
+ return "\n".join(lines).strip()
3049
+
3050
+ @staticmethod
3051
+ def _arxiv_item_needs_refresh(item: dict[str, Any] | None) -> bool:
3052
+ if not isinstance(item, dict):
3053
+ return False
3054
+ title = str(item.get("title") or "").strip()
3055
+ lowered_title = title.lower()
3056
+ authors = item.get("authors") or []
3057
+ categories = item.get("categories") or []
3058
+ published_at = str(item.get("published_at") or "").strip()
3059
+ metadata_source = str(item.get("metadata_source") or "").strip()
3060
+ bibtex = str(item.get("bibtex") or "").strip()
3061
+ overview = str(item.get("overview") or "").strip()
3062
+ overview_markdown = str(item.get("overview_markdown") or "").strip()
3063
+ overview_source = str(item.get("overview_source") or "").strip()
3064
+ return (
3065
+ not title
3066
+ or title.startswith("#")
3067
+ or lowered_title.startswith("research paper analysis")
3068
+ or lowered_title.startswith("## research paper analysis")
3069
+ or not authors
3070
+ or not categories
3071
+ or not published_at
3072
+ or not metadata_source
3073
+ or not bibtex
3074
+ or (not overview and not overview_markdown and not overview_source)
3075
+ )
3076
+
3077
+ def _refresh_arxiv_item_metadata(self, quest_root: Path, item: dict[str, Any]) -> dict[str, Any]:
3078
+ arxiv_id = str(item.get("arxiv_id") or "").strip()
3079
+ if not arxiv_id:
3080
+ return item
3081
+ metadata = fetch_arxiv_metadata(arxiv_id)
3082
+ if not metadata.get("ok"):
3083
+ return item
3084
+ summary = read_arxiv_content(arxiv_id, full_text=False)
3085
+ summary_source = summary.get("summary_source") if summary.get("ok") else None
3086
+ overview_source = summary.get("overview_source") if summary.get("ok") else None
3087
+ return self.arxiv_library.upsert_item(
3088
+ quest_root,
3089
+ {
3090
+ **item,
3091
+ "arxiv_id": metadata.get("paper_id") or arxiv_id,
3092
+ "title": metadata.get("title") or item.get("title") or arxiv_id,
3093
+ "display_name": metadata.get("title") or item.get("display_name") or arxiv_id,
3094
+ "authors": metadata.get("authors") or item.get("authors") or [],
3095
+ "categories": metadata.get("categories") or item.get("categories") or [],
3096
+ "abstract": metadata.get("abstract") or item.get("abstract") or "",
3097
+ "published_at": metadata.get("published_at") or item.get("published_at") or "",
3098
+ "version": metadata.get("version") if metadata.get("version") is not None else item.get("version"),
3099
+ "primary_class": metadata.get("primary_class") or item.get("primary_class") or "",
3100
+ "metadata_source": metadata.get("metadata_source") or item.get("metadata_source"),
3101
+ "metadata_status": "ready",
3102
+ "overview": summary.get("overview") or item.get("overview") or "",
3103
+ "overview_markdown": summary.get("overview_markdown") or item.get("overview_markdown") or "",
3104
+ "summary_source": summary_source or item.get("summary_source"),
3105
+ "overview_source": overview_source or summary_source or item.get("overview_source"),
3106
+ "bibtex": metadata.get("bibtex") or item.get("bibtex"),
3107
+ "abs_url": metadata.get("abs_url") or item.get("abs_url"),
3108
+ "pdf_url": metadata.get("pdf_url") or item.get("pdf_url"),
3109
+ },
3110
+ )
3111
+
3112
+ @staticmethod
3113
+ def _arxiv_file_payload(quest_root: Path, item: dict[str, Any]) -> dict[str, Any]:
3114
+ relative = str(item.get("path") or "").strip()
3115
+ if not relative:
3116
+ return {}
3117
+ document_id = str(item.get("document_id") or f"questpath::{relative}").strip()
3118
+ return {
3119
+ "path": relative,
3120
+ "document_id": document_id,
3121
+ "pdf_rel_path": relative,
3122
+ "pdf_url": f"/api/quests/{quest_root.name}/documents/asset?document_id={document_id}",
3123
+ }
3124
+
3125
+ def arxiv(
3126
+ self,
3127
+ paper_id: str | None = None,
3128
+ *,
3129
+ full_text: bool = False,
3130
+ mode: str = "read",
3131
+ quest_root: Path | None = None,
3132
+ ) -> dict[str, Any]:
3133
+ normalized_mode = str(mode or "read").strip().lower() or "read"
3134
+ if normalized_mode == "list":
3135
+ if quest_root is None:
3136
+ return {
3137
+ "ok": False,
3138
+ "mode": "list",
3139
+ "error": "`quest_root` is required for `artifact.arxiv(mode='list')`.",
3140
+ }
3141
+ items = self.arxiv_library.list_items(quest_root)
3142
+ refreshed_any = False
3143
+ for item in items[:]:
3144
+ if not self._arxiv_item_needs_refresh(item):
3145
+ continue
3146
+ refreshed = self._refresh_arxiv_item_metadata(quest_root, item)
3147
+ if refreshed != item:
3148
+ refreshed_any = True
3149
+ if refreshed_any:
3150
+ items = self.arxiv_library.list_items(quest_root)
3151
+ return {
3152
+ "ok": True,
3153
+ "mode": "list",
3154
+ "items": items,
3155
+ "count": len(items),
3156
+ }
3157
+
3158
+ if paper_id is None:
3159
+ return {
3160
+ "ok": False,
3161
+ "mode": normalized_mode,
3162
+ "error": "`paper_id` is required for `artifact.arxiv(mode='read')`.",
3163
+ }
3164
+
3165
+ if quest_root is None:
3166
+ return {
3167
+ **read_arxiv_content(paper_id, full_text=full_text),
3168
+ "mode": normalized_mode,
3169
+ }
3170
+
3171
+ entry = self.arxiv_library.mark_processing(quest_root, paper_id)
3172
+ cached_entry = self.arxiv_library.get_item(quest_root, paper_id)
3173
+ if cached_entry and self._arxiv_item_needs_refresh(cached_entry):
3174
+ refreshed = self._refresh_arxiv_item_metadata(quest_root, cached_entry)
3175
+ if refreshed:
3176
+ cached_entry = refreshed
3177
+ if (
3178
+ cached_entry
3179
+ and not full_text
3180
+ and cached_entry.get("abstract")
3181
+ and (cached_entry.get("summary_source") or cached_entry.get("metadata_status") == "pending")
3182
+ ):
3183
+ paper_ref = str(cached_entry.get("arxiv_id") or paper_id).strip()
3184
+ self.arxiv_library.queue_pdf_download(quest_root, str(cached_entry.get("arxiv_id") or paper_id))
3185
+ return {
3186
+ "ok": True,
3187
+ "mode": normalized_mode,
3188
+ "paper_id": paper_ref,
3189
+ "requested_full_text": full_text,
3190
+ "content_mode": "abstract",
3191
+ "source": "quest_arxiv_library",
3192
+ "source_url": f"https://arxiv.org/abs/{paper_ref}",
3193
+ "title": cached_entry.get("title"),
3194
+ "authors": cached_entry.get("authors") or [],
3195
+ "categories": cached_entry.get("categories") or [],
3196
+ "abstract": cached_entry.get("abstract") or "",
3197
+ "overview": cached_entry.get("overview") or "",
3198
+ "overview_markdown": cached_entry.get("overview_markdown") or "",
3199
+ "summary_source": cached_entry.get("summary_source"),
3200
+ "overview_source": cached_entry.get("overview_source"),
3201
+ "metadata_source": cached_entry.get("metadata_source"),
3202
+ "published_at": cached_entry.get("published_at") or "",
3203
+ "version": cached_entry.get("version"),
3204
+ "primary_class": cached_entry.get("primary_class") or "",
3205
+ "bibtex": cached_entry.get("bibtex") or "",
3206
+ "status": cached_entry.get("status"),
3207
+ "metadata_status": cached_entry.get("metadata_status"),
3208
+ "abs_url": f"https://arxiv.org/abs/{paper_ref}",
3209
+ "pdf_url": f"https://arxiv.org/pdf/{paper_ref}.pdf",
3210
+ "content": self._arxiv_content_from_item(cached_entry),
3211
+ "attempts": [],
3212
+ **self._arxiv_file_payload(quest_root, cached_entry),
3213
+ }
3214
+
3215
+ fetched = read_arxiv_content(str(entry.get("arxiv_id") or paper_id), full_text=full_text)
3216
+ if not fetched.get("ok"):
3217
+ normalized_id = str(entry.get("arxiv_id") or paper_id).strip()
3218
+ placeholder = self.arxiv_library.upsert_item(
3219
+ quest_root,
3220
+ {
3221
+ **(cached_entry or {}),
3222
+ "arxiv_id": normalized_id,
3223
+ "title": str((cached_entry or {}).get("title") or normalized_id).strip(),
3224
+ "display_name": str((cached_entry or {}).get("display_name") or normalized_id).strip(),
3225
+ "status": str((cached_entry or {}).get("status") or "processing").strip() or "processing",
3226
+ "metadata_status": "pending",
3227
+ "error": None,
3228
+ "pdf_rel_path": self.arxiv_library.pdf_relative_path(normalized_id),
3229
+ "abs_url": str((cached_entry or {}).get("abs_url") or f"https://arxiv.org/abs/{normalized_id}"),
3230
+ "pdf_url": str((cached_entry or {}).get("pdf_url") or f"https://arxiv.org/pdf/{normalized_id}.pdf"),
3231
+ },
3232
+ )
3233
+ self.arxiv_library.queue_pdf_download(
3234
+ quest_root,
3235
+ normalized_id,
3236
+ pdf_url=str(placeholder.get("pdf_url") or "").strip() or None,
3237
+ )
3238
+ latest = self.arxiv_library.get_item(quest_root, normalized_id) or placeholder
3239
+ return {
3240
+ "ok": True,
3241
+ "mode": normalized_mode,
3242
+ "paper_id": normalized_id,
3243
+ "requested_full_text": full_text,
3244
+ "content_mode": "pending",
3245
+ "source": "quest_arxiv_library_partial",
3246
+ "source_url": latest.get("abs_url") or f"https://arxiv.org/abs/{normalized_id}",
3247
+ "title": latest.get("title"),
3248
+ "authors": latest.get("authors") or [],
3249
+ "categories": latest.get("categories") or [],
3250
+ "abstract": latest.get("abstract") or "",
3251
+ "overview": latest.get("overview") or "",
3252
+ "overview_markdown": latest.get("overview_markdown") or "",
3253
+ "summary_source": latest.get("summary_source"),
3254
+ "overview_source": latest.get("overview_source"),
3255
+ "metadata_source": latest.get("metadata_source"),
3256
+ "published_at": latest.get("published_at") or "",
3257
+ "version": latest.get("version"),
3258
+ "primary_class": latest.get("primary_class") or "",
3259
+ "bibtex": latest.get("bibtex") or "",
3260
+ "status": latest.get("status"),
3261
+ "metadata_status": "pending",
3262
+ "metadata_pending": True,
3263
+ "message": "Metadata is temporarily unavailable. Open the arXiv link directly while DeepScientist retries later.",
3264
+ "abs_url": latest.get("abs_url") or f"https://arxiv.org/abs/{normalized_id}",
3265
+ "pdf_url": latest.get("pdf_url") or f"https://arxiv.org/pdf/{normalized_id}.pdf",
3266
+ "content": self._arxiv_content_from_item(latest),
3267
+ "attempts": fetched.get("attempts") or [],
3268
+ "guidance": fetched.get("guidance"),
3269
+ **self._arxiv_file_payload(quest_root, latest),
3270
+ }
3271
+
3272
+ saved = self.arxiv_library.upsert_item(
3273
+ quest_root,
3274
+ {
3275
+ **(cached_entry or {}),
3276
+ "arxiv_id": fetched.get("paper_id") or str(entry.get("arxiv_id") or paper_id),
3277
+ "title": fetched.get("title") or cached_entry.get("title") if cached_entry else fetched.get("title"),
3278
+ "authors": fetched.get("authors") or (cached_entry.get("authors") if cached_entry else []),
3279
+ "categories": fetched.get("categories") or (cached_entry.get("categories") if cached_entry else []),
3280
+ "abstract": fetched.get("abstract") or (cached_entry.get("abstract") if cached_entry else ""),
3281
+ "overview": fetched.get("overview") or (cached_entry.get("overview") if cached_entry else ""),
3282
+ "overview_markdown": fetched.get("overview_markdown") or (cached_entry.get("overview_markdown") if cached_entry else ""),
3283
+ "summary_source": fetched.get("summary_source") or (cached_entry.get("summary_source") if cached_entry else None),
3284
+ "overview_source": fetched.get("overview_source") or (cached_entry.get("overview_source") if cached_entry else None),
3285
+ "metadata_source": fetched.get("metadata_source") or (cached_entry.get("metadata_source") if cached_entry else None),
3286
+ "published_at": fetched.get("published_at") or (cached_entry.get("published_at") if cached_entry else ""),
3287
+ "version": fetched.get("version") if fetched.get("version") is not None else (cached_entry.get("version") if cached_entry else None),
3288
+ "primary_class": fetched.get("primary_class") or (cached_entry.get("primary_class") if cached_entry else ""),
3289
+ "bibtex": fetched.get("bibtex") or (cached_entry.get("bibtex") if cached_entry else None),
3290
+ "abs_url": fetched.get("abs_url") or (cached_entry.get("abs_url") if cached_entry else None),
3291
+ "pdf_url": fetched.get("pdf_url") or (cached_entry.get("pdf_url") if cached_entry else None),
3292
+ "display_name": fetched.get("title") or fetched.get("paper_id") or str(entry.get("arxiv_id") or paper_id),
3293
+ "pdf_rel_path": self.arxiv_library.pdf_relative_path(str(fetched.get("paper_id") or entry.get("arxiv_id") or paper_id)),
3294
+ "status": "processing",
3295
+ "metadata_status": "ready",
3296
+ "error": None,
3297
+ },
3298
+ )
3299
+ self.arxiv_library.queue_pdf_download(
3300
+ quest_root,
3301
+ str(saved.get("arxiv_id") or paper_id),
3302
+ pdf_url=str(fetched.get("pdf_url") or "").strip() or None,
3303
+ )
3304
+ latest = self.arxiv_library.get_item(quest_root, str(saved.get("arxiv_id") or paper_id)) or saved
3305
+ return {
3306
+ **fetched,
3307
+ "mode": normalized_mode,
3308
+ "status": latest.get("status"),
3309
+ "metadata_status": latest.get("metadata_status"),
3310
+ **self._arxiv_file_payload(quest_root, latest),
3311
+ }
2371
3312
 
2372
3313
  def record(
2373
3314
  self,
@@ -3139,19 +4080,26 @@ class ArtifactService:
3139
4080
  worktree_root,
3140
4081
  message=f"idea: create {resolved_idea_id}",
3141
4082
  )
4083
+ idea_md_rel_path = self._workspace_relative(quest_root, idea_md_path)
4084
+ idea_draft_rel_path = self._workspace_relative(quest_root, idea_draft_path)
3142
4085
  interaction = self.interact(
3143
4086
  quest_root,
3144
4087
  kind="milestone",
3145
- message=(
3146
- f"Idea `{resolved_idea_id}` is now active.\n"
3147
- f"- Branch no: `{branch_no}`\n"
3148
- f"- Branch: `{branch_name}`\n"
3149
- f"- Lineage: `{normalized_lineage_intent or 'manual'}`\n"
3150
- f"- Foundation: `{foundation.get('label') or foundation.get('branch') or 'current head'}`\n"
3151
- f"- Worktree: `{worktree_root}`\n"
3152
- f"- Idea file: `{idea_md_path}`\n"
3153
- f"- Draft file: `{idea_draft_path}`\n"
3154
- f"- Next target: `{next_target}`"
4088
+ message=self._build_idea_interaction_message(
4089
+ action="create",
4090
+ idea_id=resolved_idea_id,
4091
+ title=title,
4092
+ problem=problem,
4093
+ hypothesis=hypothesis,
4094
+ mechanism=mechanism,
4095
+ foundation_label=self._format_foundation_label(
4096
+ foundation,
4097
+ fallback=foundation.get("branch") or "current head",
4098
+ ),
4099
+ branch_name=branch_name,
4100
+ next_target=next_target,
4101
+ idea_md_rel_path=idea_md_rel_path,
4102
+ draft_md_rel_path=idea_draft_rel_path,
3155
4103
  ),
3156
4104
  deliver_to_bound_conversations=True,
3157
4105
  include_recent_inbound_messages=False,
@@ -3338,17 +4286,26 @@ class ArtifactService:
3338
4286
  worktree_root,
3339
4287
  message=f"idea: revise {resolved_idea_id}",
3340
4288
  )
4289
+ idea_md_rel_path = self._workspace_relative(quest_root, idea_md_path)
4290
+ idea_draft_rel_path = self._workspace_relative(quest_root, idea_draft_path)
3341
4291
  interaction = self.interact(
3342
4292
  quest_root,
3343
4293
  kind="progress",
3344
- message=(
3345
- f"Idea `{resolved_idea_id}` was revised.\n"
3346
- f"- Branch: `{branch_name}`\n"
3347
- f"- Foundation: `{(existing_foundation_ref or {}).get('branch') or 'current head'}`\n"
3348
- f"- Worktree: `{worktree_root}`\n"
3349
- f"- Idea file: `{idea_md_path}`\n"
3350
- f"- Draft file: `{idea_draft_path}`\n"
3351
- f"- Next target: `{next_target}`"
4294
+ message=self._build_idea_interaction_message(
4295
+ action="revise",
4296
+ idea_id=resolved_idea_id,
4297
+ title=title,
4298
+ problem=problem,
4299
+ hypothesis=hypothesis,
4300
+ mechanism=mechanism,
4301
+ foundation_label=self._format_foundation_label(
4302
+ existing_foundation_ref,
4303
+ fallback=(existing_foundation_ref or {}).get("branch") or "current head",
4304
+ ),
4305
+ branch_name=branch_name,
4306
+ next_target=next_target,
4307
+ idea_md_rel_path=idea_md_rel_path,
4308
+ draft_md_rel_path=idea_draft_rel_path,
3352
4309
  ),
3353
4310
  deliver_to_bound_conversations=True,
3354
4311
  include_recent_inbound_messages=False,
@@ -3551,15 +4508,36 @@ class ArtifactService:
3551
4508
  for item in normalized_metric_rows
3552
4509
  if str(item.get("metric_id") or "").strip()
3553
4510
  }
3554
- effective_metric_contract = normalize_metric_contract(
3555
- metric_contract or baseline_entry.get("metric_contract"),
3556
- baseline_id=resolved_baseline_id,
3557
- metrics_summary=normalized_metrics_summary,
3558
- metric_rows=normalized_metric_rows,
3559
- primary_metric=baseline_entry.get("primary_metric"),
3560
- baseline_variants=baseline_entry.get("baseline_variants"),
3561
- )
3562
4511
  baseline_contract_payload = self._load_metric_contract_payload(quest_root, metric_contract_json_rel_path)
4512
+ baseline_metric_contract = baseline_entry.get("metric_contract")
4513
+ baseline_primary_metric = baseline_entry.get("primary_metric")
4514
+ if isinstance(baseline_contract_payload, dict) and baseline_contract_payload:
4515
+ payload_metric_contract = baseline_contract_payload.get("metric_contract")
4516
+ if isinstance(payload_metric_contract, dict) and payload_metric_contract:
4517
+ baseline_metric_contract = payload_metric_contract
4518
+ payload_primary_metric = baseline_contract_payload.get("primary_metric")
4519
+ if isinstance(payload_primary_metric, dict) and payload_primary_metric:
4520
+ baseline_primary_metric = payload_primary_metric
4521
+ effective_metric_contract = (
4522
+ self._merge_run_metric_contract(
4523
+ baseline_metric_contract=baseline_metric_contract,
4524
+ baseline_primary_metric=baseline_primary_metric,
4525
+ baseline_variants=baseline_entry.get("baseline_variants"),
4526
+ run_metric_contract=metric_contract,
4527
+ metrics_summary=normalized_metrics_summary,
4528
+ metric_rows=normalized_metric_rows,
4529
+ baseline_id=resolved_baseline_id,
4530
+ )
4531
+ if isinstance(baseline_metric_contract, dict) and baseline_metric_contract
4532
+ else normalize_metric_contract(
4533
+ metric_contract or baseline_entry.get("metric_contract"),
4534
+ baseline_id=resolved_baseline_id,
4535
+ metrics_summary=normalized_metrics_summary,
4536
+ metric_rows=normalized_metric_rows,
4537
+ primary_metric=baseline_primary_metric,
4538
+ baseline_variants=baseline_entry.get("baseline_variants"),
4539
+ )
4540
+ )
3563
4541
  metric_validation: dict[str, Any] | None = None
3564
4542
  if strict_metric_contract:
3565
4543
  metric_validation = validate_main_experiment_against_baseline_contract(
@@ -3830,14 +4808,21 @@ class ArtifactService:
3830
4808
  interaction = self.interact(
3831
4809
  quest_root,
3832
4810
  kind="milestone",
3833
- message=(
3834
- f"Main experiment `{run_identifier}` has been recorded.\n"
3835
- f"- Branch: `{branch_name}`\n"
3836
- f"- Run log: `{run_md_path}`\n"
3837
- f"- Result: `{result_json_path}`\n"
3838
- f"- Verdict: `{verdict}`\n"
3839
- f"- Breakthrough: `{progress_eval.get('breakthrough_level')}`\n"
3840
- f"- Recommended next route: `{delivery_policy.get('recommended_next_route')}`"
4811
+ message=self._build_main_experiment_interaction_message(
4812
+ run_id=run_identifier,
4813
+ branch_name=branch_name,
4814
+ verdict=verdict,
4815
+ primary_metric_id=primary_metric_id,
4816
+ primary_value=primary_value,
4817
+ primary_baseline=primary_baseline,
4818
+ primary_delta=primary_delta,
4819
+ decimals=decimals if isinstance(decimals, int) else None,
4820
+ conclusion=conclusion.strip() or progress_eval.get("reason"),
4821
+ evaluation_summary=normalized_evaluation_summary,
4822
+ breakthrough_level=str(progress_eval.get("breakthrough_level") or "").strip() or None,
4823
+ recommended_next_route=str(delivery_policy.get("recommended_next_route") or "").strip() or None,
4824
+ run_md_rel_path=self._workspace_relative(quest_root, run_md_path),
4825
+ result_json_rel_path=self._workspace_relative(quest_root, result_json_path),
3841
4826
  ),
3842
4827
  deliver_to_bound_conversations=True,
3843
4828
  include_recent_inbound_messages=False,
@@ -3924,6 +4909,14 @@ class ArtifactService:
3924
4909
  quest_root,
3925
4910
  state=state,
3926
4911
  )
4912
+ runtime_refs = self.resolve_runtime_refs(quest_root)
4913
+ resolved_parent_run_id = (
4914
+ str(parent_run_id or "").strip()
4915
+ or str(state.get("paper_parent_run_id") or "").strip()
4916
+ or str((self._latest_main_run_for_branch(quest_root, parent_branch) or {}).get("run_id") or "").strip()
4917
+ or str(runtime_refs.get("latest_main_run_id") or "").strip()
4918
+ or None
4919
+ )
3927
4920
  active_idea_id = str(resolved_idea_id or "").strip()
3928
4921
  if not active_idea_id:
3929
4922
  raise ValueError("An active idea is required before starting an analysis campaign.")
@@ -3937,6 +4930,54 @@ class ArtifactService:
3937
4930
  normalized_research_questions = self._normalize_string_list(research_questions)
3938
4931
  normalized_experimental_designs = self._normalize_string_list(experimental_designs)
3939
4932
  normalized_todo_items = self._normalize_campaign_todo_items(todo_items)
4933
+ quest_data = self.quest_service.read_quest_yaml(quest_root)
4934
+ active_anchor = str(quest_data.get("active_anchor") or "").strip().lower()
4935
+ campaign_origin_kind = (
4936
+ str(normalized_campaign_origin.get("kind") or "").strip().lower()
4937
+ if isinstance(normalized_campaign_origin, dict)
4938
+ else ""
4939
+ )
4940
+ writing_facing = bool(
4941
+ resolved_outline_ref
4942
+ or normalized_research_questions
4943
+ or normalized_experimental_designs
4944
+ or normalized_todo_items
4945
+ or str(state.get("workspace_mode") or "").strip().lower() == "paper"
4946
+ or active_anchor == "write"
4947
+ or campaign_origin_kind in {"write", "paper", "rebuttal", "revision"}
4948
+ )
4949
+ if writing_facing:
4950
+ if not resolved_outline_ref:
4951
+ raise ValueError(
4952
+ "Writing-facing analysis campaigns require `selected_outline_ref` before slices can be launched."
4953
+ )
4954
+ if not normalized_research_questions:
4955
+ raise ValueError(
4956
+ "Writing-facing analysis campaigns require non-empty `research_questions`."
4957
+ )
4958
+ if not normalized_experimental_designs:
4959
+ raise ValueError(
4960
+ "Writing-facing analysis campaigns require non-empty `experimental_designs`."
4961
+ )
4962
+ if not normalized_todo_items:
4963
+ raise ValueError(
4964
+ "Writing-facing analysis campaigns require non-empty `todo_items`."
4965
+ )
4966
+ todo_slice_ids = {
4967
+ str(item.get("slice_id") or "").strip()
4968
+ for item in normalized_todo_items
4969
+ if str(item.get("slice_id") or "").strip()
4970
+ }
4971
+ missing_slice_ids = [
4972
+ str(raw.get("slice_id") or "").strip()
4973
+ for raw in slices
4974
+ if str(raw.get("slice_id") or "").strip() and str(raw.get("slice_id") or "").strip() not in todo_slice_ids
4975
+ ]
4976
+ if missing_slice_ids:
4977
+ raise ValueError(
4978
+ "Writing-facing analysis campaigns require one todo item per slice. "
4979
+ f"Missing todo items for: {', '.join(missing_slice_ids)}."
4980
+ )
3940
4981
  slice_contexts: list[dict[str, Any]] = []
3941
4982
  inventory_entries: list[dict[str, Any]] = []
3942
4983
  for index, raw in enumerate(slices, start=1):
@@ -4199,7 +5240,7 @@ class ArtifactService:
4199
5240
  {
4200
5241
  "title": campaign_title,
4201
5242
  "goal": campaign_goal,
4202
- "parent_run_id": parent_run_id,
5243
+ "parent_run_id": resolved_parent_run_id,
4203
5244
  "active_idea_id": active_idea_id,
4204
5245
  "parent_branch": parent_branch,
4205
5246
  "parent_worktree_root": str(parent_worktree_root),
@@ -4274,7 +5315,7 @@ class ArtifactService:
4274
5315
  "details": {
4275
5316
  "campaign_title": campaign_title,
4276
5317
  "campaign_goal": campaign_goal,
4277
- "parent_run_id": parent_run_id,
5318
+ "parent_run_id": resolved_parent_run_id,
4278
5319
  "campaign_origin": normalized_campaign_origin,
4279
5320
  "selected_outline_ref": resolved_outline_ref,
4280
5321
  "todo_manifest_path": str(todo_manifest_path),
@@ -4326,14 +5367,13 @@ class ArtifactService:
4326
5367
  interaction = self.interact(
4327
5368
  quest_root,
4328
5369
  kind="milestone",
4329
- message=(
4330
- f"Analysis campaign `{campaign_id}` is ready.\n"
4331
- f"- Parent branch: `{parent_branch}`\n"
4332
- f"- Parent worktree: `{parent_worktree_root}`\n"
4333
- f"- Next slice: `{first_slice['slice_id']}`\n"
4334
- f"- Slice branch: `{first_slice['branch']}`\n"
4335
- f"- Slice worktree: `{first_slice['worktree_root']}`\n"
4336
- f"- Core requirement: {first_slice['must_not_simplify'] or 'Follow the full evaluation protocol.'}"
5370
+ message=self._build_analysis_campaign_interaction_message(
5371
+ campaign_id=campaign_id,
5372
+ goal=campaign_goal,
5373
+ parent_branch=parent_branch,
5374
+ selected_outline_ref=resolved_outline_ref,
5375
+ first_slice=first_slice,
5376
+ todo_manifest_rel_path=self._workspace_relative(quest_root, todo_manifest_path),
4337
5377
  ),
4338
5378
  deliver_to_bound_conversations=True,
4339
5379
  include_recent_inbound_messages=False,
@@ -4418,6 +5458,7 @@ class ArtifactService:
4418
5458
  if normalized_mode == "candidate":
4419
5459
  resolved_outline_id = str(outline_id or self._next_paper_outline_id(quest_root)).strip()
4420
5460
  candidate_path = self._paper_outline_candidates_root(quest_root, workspace_root=workspace_root) / f"{resolved_outline_id}.json"
5461
+ canonical_candidate_path = quest_root / "paper" / "outlines" / "candidates" / f"{resolved_outline_id}.json"
4421
5462
  existing = read_json(candidate_path, {})
4422
5463
  existing = existing if isinstance(existing, dict) else {}
4423
5464
  record = self._normalize_paper_outline_record(
@@ -4432,6 +5473,8 @@ class ArtifactService:
4432
5473
  created_at=str(existing.get("created_at") or "") or None,
4433
5474
  )
4434
5475
  write_json(candidate_path, record)
5476
+ if canonical_candidate_path.resolve() != candidate_path.resolve():
5477
+ write_json(canonical_candidate_path, record)
4435
5478
  artifact = self.record(
4436
5479
  quest_root,
4437
5480
  {
@@ -4491,6 +5534,9 @@ class ArtifactService:
4491
5534
  )
4492
5535
 
4493
5536
  write_json(selected_outline_path, resolved_record)
5537
+ canonical_selected_outline_path = quest_root / "paper" / "selected_outline.json"
5538
+ if canonical_selected_outline_path.resolve() != selected_outline_path.resolve():
5539
+ write_json(canonical_selected_outline_path, resolved_record)
4494
5540
  if source_candidate_path.exists():
4495
5541
  source_record["status"] = "selected" if normalized_mode == "select" else "revised"
4496
5542
  source_record["updated_at"] = utc_now()
@@ -4499,6 +5545,9 @@ class ArtifactService:
4499
5545
  if normalized_mode == "revise":
4500
5546
  revised_outline_path = ensure_dir(paper_root / "outlines" / "revisions") / f"{source_outline_id}.json"
4501
5547
  write_json(revised_outline_path, resolved_record)
5548
+ canonical_revised_outline_path = quest_root / "paper" / "outlines" / "revisions" / f"{source_outline_id}.json"
5549
+ if canonical_revised_outline_path.resolve() != revised_outline_path.resolve():
5550
+ write_json(canonical_revised_outline_path, resolved_record)
4502
5551
 
4503
5552
  outline_selection_path = paper_root / "outline_selection.md"
4504
5553
  action_label = "selected" if normalized_mode == "select" else "revised"
@@ -4541,6 +5590,46 @@ class ArtifactService:
4541
5590
  checkpoint=False,
4542
5591
  workspace_root=workspace_root,
4543
5592
  )
5593
+ selected_outline_rel_path = self._workspace_relative(quest_root, selected_outline_path)
5594
+ outline_selection_rel_path = self._workspace_relative(quest_root, outline_selection_path)
5595
+ revised_outline_rel_path = self._workspace_relative(quest_root, revised_outline_path) if revised_outline_path else None
5596
+ interaction = self.interact(
5597
+ quest_root,
5598
+ kind="milestone" if normalized_mode == "select" else "progress",
5599
+ message=self._build_outline_interaction_message(
5600
+ action=normalized_mode,
5601
+ outline_id=source_outline_id,
5602
+ title=str(resolved_record.get("title") or "").strip() or source_outline_id,
5603
+ selected_reason=selected_reason or note,
5604
+ story=str(resolved_record.get("story") or "").strip() or None,
5605
+ research_questions=(
5606
+ (resolved_record.get("detailed_outline") or {})
5607
+ if isinstance(resolved_record.get("detailed_outline"), dict)
5608
+ else {}
5609
+ ).get("research_questions"),
5610
+ experimental_designs=(
5611
+ (resolved_record.get("detailed_outline") or {})
5612
+ if isinstance(resolved_record.get("detailed_outline"), dict)
5613
+ else {}
5614
+ ).get("experimental_designs"),
5615
+ selected_outline_rel_path=selected_outline_rel_path,
5616
+ outline_selection_rel_path=outline_selection_rel_path,
5617
+ revised_outline_rel_path=revised_outline_rel_path,
5618
+ ),
5619
+ deliver_to_bound_conversations=True,
5620
+ include_recent_inbound_messages=False,
5621
+ attachments=[
5622
+ {
5623
+ "kind": "paper_outline_selected" if normalized_mode == "select" else "paper_outline_revised",
5624
+ "outline_id": source_outline_id,
5625
+ "title": resolved_record.get("title"),
5626
+ "selected_reason": selected_reason,
5627
+ "selected_outline_path": str(selected_outline_path),
5628
+ "outline_selection_path": str(outline_selection_path),
5629
+ "revised_outline_path": str(revised_outline_path) if revised_outline_path else None,
5630
+ }
5631
+ ],
5632
+ )
4544
5633
  return {
4545
5634
  "ok": True,
4546
5635
  "mode": normalized_mode,
@@ -4550,6 +5639,7 @@ class ArtifactService:
4550
5639
  "revised_outline_path": str(revised_outline_path) if revised_outline_path else None,
4551
5640
  "record": resolved_record,
4552
5641
  "artifact": artifact,
5642
+ "interaction": interaction,
4553
5643
  }
4554
5644
 
4555
5645
  def submit_paper_bundle(
@@ -4673,6 +5763,50 @@ class ArtifactService:
4673
5763
  checkpoint=False,
4674
5764
  workspace_root=workspace_root,
4675
5765
  )
5766
+ interaction = self.interact(
5767
+ quest_root,
5768
+ kind="milestone",
5769
+ message=self._build_paper_bundle_interaction_message(
5770
+ title=str(manifest.get("title") or "").strip() or None,
5771
+ summary=str(manifest.get("summary") or "").strip() or None,
5772
+ paper_branch=paper_branch,
5773
+ source_branch=source_branch,
5774
+ source_run_id=source_run_id,
5775
+ selected_outline_ref=str(manifest.get("selected_outline_ref") or "").strip() or None,
5776
+ manifest_rel_path=self._workspace_relative(quest_root, manifest_path),
5777
+ draft_rel_path=str(manifest.get("draft_path") or "").strip() or None,
5778
+ writing_plan_rel_path=str(manifest.get("writing_plan_path") or "").strip() or None,
5779
+ references_rel_path=str(manifest.get("references_path") or "").strip() or None,
5780
+ claim_evidence_map_rel_path=str(manifest.get("claim_evidence_map_path") or "").strip() or None,
5781
+ compile_report_rel_path=str(manifest.get("compile_report_path") or "").strip() or None,
5782
+ pdf_rel_path=str(manifest.get("pdf_path") or "").strip() or None,
5783
+ latex_root_rel_path=str(manifest.get("latex_root_path") or "").strip() or None,
5784
+ baseline_inventory_rel_path=paper_inventory_rel,
5785
+ open_source_manifest_rel_path=str(manifest.get("open_source_manifest_path") or "").strip() or None,
5786
+ ),
5787
+ deliver_to_bound_conversations=True,
5788
+ include_recent_inbound_messages=False,
5789
+ attachments=[
5790
+ {
5791
+ "kind": "paper_bundle",
5792
+ "title": manifest.get("title"),
5793
+ "paper_branch": paper_branch,
5794
+ "source_branch": source_branch,
5795
+ "source_run_id": source_run_id,
5796
+ "selected_outline_ref": manifest.get("selected_outline_ref"),
5797
+ "manifest_path": str(manifest_path),
5798
+ "draft_path": manifest.get("draft_path"),
5799
+ "writing_plan_path": manifest.get("writing_plan_path"),
5800
+ "references_path": manifest.get("references_path"),
5801
+ "claim_evidence_map_path": manifest.get("claim_evidence_map_path"),
5802
+ "compile_report_path": manifest.get("compile_report_path"),
5803
+ "pdf_path": manifest.get("pdf_path"),
5804
+ "latex_root_path": manifest.get("latex_root_path"),
5805
+ "baseline_inventory_path": str(baseline_inventory_path),
5806
+ "open_source_manifest_path": str(self._open_source_manifest_path(quest_root, workspace_root=workspace_root)),
5807
+ }
5808
+ ],
5809
+ )
4676
5810
  return {
4677
5811
  "ok": True,
4678
5812
  "manifest_path": str(manifest_path),
@@ -4680,6 +5814,7 @@ class ArtifactService:
4680
5814
  "baseline_inventory_path": str(baseline_inventory_path),
4681
5815
  "open_source_manifest_path": str(self._open_source_manifest_path(quest_root, workspace_root=workspace_root)),
4682
5816
  "artifact": artifact,
5817
+ "interaction": interaction,
4683
5818
  }
4684
5819
 
4685
5820
  def record_analysis_slice(
@@ -4716,7 +5851,17 @@ class ArtifactService:
4716
5851
 
4717
5852
  evidence_paths = [str(item).strip() for item in (evidence_paths or []) if str(item).strip()]
4718
5853
  deviations = [str(item).strip() for item in (deviations or []) if str(item).strip()]
4719
- metric_rows = [item for item in (metric_rows or []) if isinstance(item, dict)]
5854
+ normalized_metric_rows = normalize_metric_rows(metric_rows or [])
5855
+ normalized_metrics_summary = {
5856
+ str(item.get("metric_id") or "").strip(): item.get("value")
5857
+ for item in normalized_metric_rows
5858
+ if str(item.get("metric_id") or "").strip()
5859
+ }
5860
+ normalized_metric_contract = normalize_metric_contract(
5861
+ {},
5862
+ metrics_summary=normalized_metrics_summary,
5863
+ metric_rows=normalized_metric_rows,
5864
+ )
4720
5865
  normalized_comparison_baselines = self._normalize_comparison_baselines(quest_root, comparison_baselines)
4721
5866
  normalized_claim_impact = str(claim_impact or "").strip() or None
4722
5867
  normalized_reviewer_resolution = str(reviewer_resolution or "").strip() or None
@@ -4784,9 +5929,9 @@ class ArtifactService:
4784
5929
  result_lines.extend([f"- `{item}`" for item in evidence_paths])
4785
5930
  else:
4786
5931
  result_lines.append("- None recorded.")
4787
- if metric_rows:
5932
+ if normalized_metric_rows:
4788
5933
  result_lines.extend(["", "## Metric Rows", ""])
4789
- for row in metric_rows:
5934
+ for row in normalized_metric_rows:
4790
5935
  result_lines.append(f"- `{row}`")
4791
5936
  result_lines.extend(["", "## Comparison Baselines", ""])
4792
5937
  if normalized_comparison_baselines:
@@ -4806,16 +5951,6 @@ class ArtifactService:
4806
5951
  result_lines.extend(["", "## Subset Approval", "", f"`{subset_approval_ref}`"])
4807
5952
  write_text(result_path, "\n".join(result_lines).rstrip() + "\n")
4808
5953
 
4809
- metrics_summary: dict[str, Any] = {}
4810
- for row in metric_rows:
4811
- name = str(row.get("name") or row.get("metric") or "").strip()
4812
- if name:
4813
- metrics_summary[name] = row.get("value")
4814
- continue
4815
- keys = [key for key in row.keys() if key not in {"split", "seed", "note", "notes"}]
4816
- if len(keys) == 1:
4817
- metrics_summary[keys[0]] = row.get(keys[0])
4818
-
4819
5954
  result_payload = {
4820
5955
  "schema_version": 1,
4821
5956
  "result_kind": "analysis_slice",
@@ -4827,8 +5962,9 @@ class ArtifactService:
4827
5962
  "run_kind": target.get("run_kind"),
4828
5963
  "required_baselines": target.get("required_baselines") or [],
4829
5964
  "comparison_baselines": normalized_comparison_baselines,
4830
- "metrics_summary": metrics_summary,
4831
- "metric_rows": metric_rows,
5965
+ "metrics_summary": normalized_metrics_summary,
5966
+ "metric_rows": normalized_metric_rows,
5967
+ "metric_contract": normalized_metric_contract,
4832
5968
  "dataset_scope": normalized_scope,
4833
5969
  "subset_approval_ref": subset_approval_ref,
4834
5970
  "setup": setup.strip() or None,
@@ -4914,7 +6050,11 @@ class ArtifactService:
4914
6050
  "parent_branch": parent_branch,
4915
6051
  "worktree_root": str(slice_worktree_root),
4916
6052
  "worktree_rel_path": self._workspace_relative(quest_root, slice_worktree_root),
4917
- "metrics_summary": metrics_summary,
6053
+ "metrics_summary": normalized_metrics_summary,
6054
+ "metric_rows": normalized_metric_rows,
6055
+ "metric_contract": normalized_metric_contract,
6056
+ "comparison_baselines": normalized_comparison_baselines,
6057
+ "evidence_paths": evidence_paths,
4918
6058
  "flow_type": "analysis_slice",
4919
6059
  "protocol_step": "record",
4920
6060
  "paths": {
@@ -4928,7 +6068,7 @@ class ArtifactService:
4928
6068
  "must_not_simplify": target.get("must_not_simplify"),
4929
6069
  "dataset_scope": normalized_scope,
4930
6070
  "subset_approval_ref": subset_approval_ref,
4931
- "metric_rows": metric_rows,
6071
+ "metric_rows": normalized_metric_rows,
4932
6072
  "claim_impact": normalized_claim_impact,
4933
6073
  "reviewer_resolution": normalized_reviewer_resolution,
4934
6074
  "manuscript_update_hint": normalized_manuscript_update_hint,
@@ -4968,6 +6108,8 @@ class ArtifactService:
4968
6108
  updated["reviewer_resolution"] = normalized_reviewer_resolution
4969
6109
  updated["manuscript_update_hint"] = normalized_manuscript_update_hint
4970
6110
  updated["next_recommendation"] = normalized_next_recommendation
6111
+ updated["metrics_summary"] = normalized_metrics_summary
6112
+ updated["metric_rows"] = normalized_metric_rows
4971
6113
  updated["comparison_baselines"] = normalized_comparison_baselines
4972
6114
  updated["evaluation_summary"] = normalized_evaluation_summary
4973
6115
  updated_slices.append(updated)
@@ -5025,13 +6167,13 @@ class ArtifactService:
5025
6167
  interaction = self.interact(
5026
6168
  quest_root,
5027
6169
  kind="progress",
5028
- message=(
5029
- f"Analysis slice `{slice_id}` is complete.\n"
5030
- f"- Parent branch mirror updated: `{mirror_path}`\n"
5031
- f"- Next slice: `{next_slice['slice_id']}`\n"
5032
- f"- Next branch: `{next_slice['branch']}`\n"
5033
- f"- Next worktree: `{next_slice['worktree_root']}`\n"
5034
- f"- Core requirement: {next_slice.get('must_not_simplify') or 'Use the full intended evaluation protocol.'}"
6170
+ message=self._build_analysis_slice_interaction_message(
6171
+ campaign_id=campaign_id,
6172
+ slice_id=slice_id,
6173
+ evaluation_summary=normalized_evaluation_summary,
6174
+ claim_impact=normalized_claim_impact,
6175
+ next_slice=next_slice,
6176
+ mirror_rel_path=self._workspace_relative(quest_root, mirror_path),
5035
6177
  ),
5036
6178
  deliver_to_bound_conversations=True,
5037
6179
  include_recent_inbound_messages=False,
@@ -5156,20 +6298,16 @@ class ArtifactService:
5156
6298
  interaction = self.interact(
5157
6299
  quest_root,
5158
6300
  kind="milestone",
5159
- message=(
5160
- f"All analysis slices in `{campaign_id}` are complete.\n"
5161
- f"- Returned to parent branch: `{parent_branch}`\n"
5162
- f"- Parent worktree: `{parent_worktree_root}`\n"
5163
- f"- Analysis summary: `{summary_path}`\n"
5164
- + (
5165
- (
5166
- f"- Writing branch: `{writing_workspace.get('branch')}`\n"
5167
- f"- Writing worktree: `{writing_workspace.get('worktree_root')}`\n"
5168
- "Writing is now active on the dedicated paper branch."
5169
- )
5170
- if writing_workspace
5171
- else "Use the completed analysis evidence to make the next durable route decision."
5172
- )
6301
+ message=self._build_analysis_complete_interaction_message(
6302
+ campaign_id=campaign_id,
6303
+ completed_slices=updated_slices,
6304
+ summary_rel_path=self._workspace_relative(quest_root, summary_path),
6305
+ writing_branch=writing_workspace.get("branch") if writing_workspace else None,
6306
+ writing_worktree_rel_path=(
6307
+ self._workspace_relative(quest_root, Path(str(writing_workspace.get("worktree_root"))))
6308
+ if writing_workspace and str(writing_workspace.get("worktree_root") or "").strip()
6309
+ else None
6310
+ ),
5173
6311
  ),
5174
6312
  deliver_to_bound_conversations=True,
5175
6313
  include_recent_inbound_messages=False,
@@ -5259,6 +6397,81 @@ class ArtifactService:
5259
6397
  "guidance": "The selected baseline is now attached under baselines/imported. Reuse it before considering a fresh reproduction.",
5260
6398
  }
5261
6399
 
6400
+ def delete_baseline(self, baseline_id: str) -> dict[str, Any]:
6401
+ existing = self.baselines.get(baseline_id, include_deleted=True)
6402
+ if existing is None:
6403
+ raise FileNotFoundError(f"Unknown baseline: {baseline_id}")
6404
+
6405
+ normalized_baseline_id = str(existing.get("baseline_id") or existing.get("entry_id") or baseline_id).strip()
6406
+ already_deleted = self.baselines.is_deleted(normalized_baseline_id)
6407
+ deleted_entry = self.baselines.delete(normalized_baseline_id) if not already_deleted else dict(existing)
6408
+
6409
+ affected_quest_ids: list[str] = []
6410
+ cleared_requested_refs = 0
6411
+ cleared_confirmed_refs = 0
6412
+ deleted_paths: list[str] = []
6413
+ warnings: list[str] = []
6414
+ quests_root = self.home / "quests"
6415
+
6416
+ for quest_yaml in sorted(quests_root.glob("*/quest.yaml")):
6417
+ quest_root = quest_yaml.parent
6418
+ quest_id = quest_root.name
6419
+ quest_touched = False
6420
+ quest_payload = self.quest_service.read_quest_yaml(quest_root)
6421
+
6422
+ requested_ref = (
6423
+ dict(quest_payload.get("requested_baseline_ref") or {})
6424
+ if isinstance(quest_payload.get("requested_baseline_ref"), dict)
6425
+ else {}
6426
+ )
6427
+ if str(requested_ref.get("baseline_id") or "").strip() == normalized_baseline_id:
6428
+ self.quest_service.update_startup_context(quest_root, requested_baseline_ref=None)
6429
+ cleared_requested_refs += 1
6430
+ quest_touched = True
6431
+
6432
+ confirmed_ref = (
6433
+ dict(quest_payload.get("confirmed_baseline_ref") or {})
6434
+ if isinstance(quest_payload.get("confirmed_baseline_ref"), dict)
6435
+ else {}
6436
+ )
6437
+ if str(confirmed_ref.get("baseline_id") or "").strip() == normalized_baseline_id:
6438
+ self.quest_service.update_baseline_state(
6439
+ quest_root,
6440
+ baseline_gate="pending",
6441
+ confirmed_baseline_ref=None,
6442
+ active_anchor="baseline",
6443
+ )
6444
+ cleared_confirmed_refs += 1
6445
+ quest_touched = True
6446
+
6447
+ for root in self._baseline_workspace_roots(quest_root):
6448
+ try:
6449
+ removed = self._remove_baseline_materialization(root, normalized_baseline_id)
6450
+ except OSError as exc:
6451
+ warnings.append(
6452
+ f"Unable to remove baseline materialization under `{root}` for quest `{quest_id}`: {exc}"
6453
+ )
6454
+ continue
6455
+ if removed:
6456
+ deleted_paths.extend(removed)
6457
+ quest_touched = True
6458
+
6459
+ if quest_touched:
6460
+ affected_quest_ids.append(quest_id)
6461
+
6462
+ return {
6463
+ "ok": True,
6464
+ "baseline_id": normalized_baseline_id,
6465
+ "deleted": not already_deleted,
6466
+ "already_deleted": already_deleted,
6467
+ "baseline_registry_entry": deleted_entry,
6468
+ "affected_quest_ids": affected_quest_ids,
6469
+ "cleared_requested_refs": cleared_requested_refs,
6470
+ "cleared_confirmed_refs": cleared_confirmed_refs,
6471
+ "deleted_paths": deleted_paths,
6472
+ "warnings": warnings,
6473
+ }
6474
+
5262
6475
  def confirm_baseline(
5263
6476
  self,
5264
6477
  quest_root: Path,
@@ -5270,6 +6483,7 @@ class ArtifactService:
5270
6483
  summary: str | None = None,
5271
6484
  baseline_kind: str | None = None,
5272
6485
  metric_contract: dict[str, Any] | None = None,
6486
+ metric_directions: dict[str, str] | None = None,
5273
6487
  metrics_summary: dict[str, Any] | None = None,
5274
6488
  primary_metric: dict[str, Any] | None = None,
5275
6489
  auto_advance: bool = True,
@@ -5370,6 +6584,19 @@ class ArtifactService:
5370
6584
  if isinstance(selected_variant, dict) and selected_variant.get("metrics_summary") is not None
5371
6585
  else entry.get("metrics_summary")
5372
6586
  )
6587
+ entry_metric_contract, entry_primary_metric = self._apply_metric_directions_to_contract(
6588
+ metric_contract=entry.get("metric_contract"),
6589
+ metric_directions=metric_directions,
6590
+ baseline_id=resolved_baseline_id,
6591
+ metrics_summary=source_metrics_summary,
6592
+ primary_metric=entry.get("primary_metric"),
6593
+ baseline_variants=entry.get("baseline_variants"),
6594
+ )
6595
+ entry = {
6596
+ **entry,
6597
+ "metric_contract": entry_metric_contract,
6598
+ "primary_metric": entry_primary_metric or entry.get("primary_metric"),
6599
+ }
5373
6600
  canonical_baseline = (
5374
6601
  validate_baseline_metric_contract_submission(
5375
6602
  metric_contract=entry.get("metric_contract"),
@@ -5534,6 +6761,8 @@ class ArtifactService:
5534
6761
  "snapshot": self.quest_service.snapshot(self._quest_id(quest_root)),
5535
6762
  "metric_details": canonical_baseline["metric_details"],
5536
6763
  "legacy_guidance": "Baseline gate confirmed. Idea selection is now the default next anchor.",
6764
+ "metric_contract_json_path": str(metric_contract_json.get("path") or ""),
6765
+ "metric_contract_json_rel_path": str(metric_contract_json.get("rel_path") or ""),
5537
6766
  }
5538
6767
 
5539
6768
  def waive_baseline(
@@ -6157,11 +7386,18 @@ class ArtifactService:
6157
7386
  return f"run/{run_id or generate_id('run')}"
6158
7387
 
6159
7388
  def _bound_conversations(self, quest_root: Path) -> list[str]:
6160
- state_path = quest_root / ".ds" / "bindings.json"
6161
- payload = read_json(state_path, {"sources": ["local:default"]})
6162
- sources = [self._normalize_conversation_id(str(item)) for item in (payload.get("sources") or ["local:default"])]
6163
- connector_sources = self._connector_bound_conversations(self._quest_id(quest_root))
6164
- return self._dedupe_targets([*connector_sources, *sources])
7389
+ quest_id = self._quest_id(quest_root)
7390
+ sources = [
7391
+ self._normalize_conversation_id(str(item))
7392
+ for item in self.quest_service.binding_sources(quest_id)
7393
+ ]
7394
+ authoritative_keys = {conversation_identity_key(item) for item in sources}
7395
+ connector_sources = [
7396
+ item
7397
+ for item in self._connector_bound_conversations(quest_id)
7398
+ if conversation_identity_key(item) in authoritative_keys
7399
+ ]
7400
+ return self._dedupe_targets([*sources, *connector_sources])
6165
7401
 
6166
7402
  def _connector_bound_conversations(self, quest_id: str) -> list[str]:
6167
7403
  root = self.home / "logs" / "connectors"