@researai/deepscientist 1.5.0 → 1.5.1

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 (163) hide show
  1. package/AGENTS.md +26 -0
  2. package/README.md +19 -179
  3. package/assets/connectors/lingzhu/openclaw-bridge/README.md +124 -0
  4. package/assets/connectors/lingzhu/openclaw-bridge/index.ts +162 -0
  5. package/assets/connectors/lingzhu/openclaw-bridge/openclaw.plugin.json +145 -0
  6. package/assets/connectors/lingzhu/openclaw-bridge/package.json +35 -0
  7. package/assets/connectors/lingzhu/openclaw-bridge/src/cli.ts +180 -0
  8. package/assets/connectors/lingzhu/openclaw-bridge/src/config.ts +196 -0
  9. package/assets/connectors/lingzhu/openclaw-bridge/src/debug-log.ts +111 -0
  10. package/assets/connectors/lingzhu/openclaw-bridge/src/events.ts +4 -0
  11. package/assets/connectors/lingzhu/openclaw-bridge/src/http-handler.ts +1133 -0
  12. package/assets/connectors/lingzhu/openclaw-bridge/src/image-cache.ts +75 -0
  13. package/assets/connectors/lingzhu/openclaw-bridge/src/lingzhu-tools.ts +246 -0
  14. package/assets/connectors/lingzhu/openclaw-bridge/src/transform.ts +541 -0
  15. package/assets/connectors/lingzhu/openclaw-bridge/src/types.ts +131 -0
  16. package/assets/connectors/lingzhu/openclaw-bridge/tsconfig.json +14 -0
  17. package/assets/connectors/lingzhu/openclaw.lingzhu.config.template.json +39 -0
  18. package/bin/ds.js +233 -53
  19. package/docs/en/00_QUICK_START.md +134 -0
  20. package/docs/en/01_SETTINGS_REFERENCE.md +1104 -0
  21. package/docs/en/02_START_RESEARCH_GUIDE.md +404 -0
  22. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +325 -0
  23. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +216 -0
  24. package/docs/en/05_TUI_GUIDE.md +141 -0
  25. package/docs/en/06_RUNTIME_AND_CANVAS.md +679 -0
  26. package/docs/en/07_MEMORY_AND_MCP.md +253 -0
  27. package/docs/en/08_FIGURE_STYLE_GUIDE.md +97 -0
  28. package/docs/en/09_DOCTOR.md +108 -0
  29. package/docs/en/90_ARCHITECTURE.md +245 -0
  30. package/docs/en/91_DEVELOPMENT.md +195 -0
  31. package/docs/en/99_ACKNOWLEDGEMENTS.md +29 -0
  32. package/docs/zh/00_QUICK_START.md +134 -0
  33. package/docs/zh/01_SETTINGS_REFERENCE.md +1137 -0
  34. package/docs/zh/02_START_RESEARCH_GUIDE.md +414 -0
  35. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +324 -0
  36. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +230 -0
  37. package/docs/zh/05_TUI_GUIDE.md +128 -0
  38. package/docs/zh/06_RUNTIME_AND_CANVAS.md +271 -0
  39. package/docs/zh/07_MEMORY_AND_MCP.md +235 -0
  40. package/docs/zh/08_FIGURE_STYLE_GUIDE.md +97 -0
  41. package/docs/zh/09_DOCTOR.md +112 -0
  42. package/docs/zh/99_ACKNOWLEDGEMENTS.md +29 -0
  43. package/install.sh +32 -8
  44. package/package.json +4 -2
  45. package/pyproject.toml +1 -1
  46. package/src/deepscientist/artifact/guidance.py +9 -2
  47. package/src/deepscientist/artifact/service.py +482 -22
  48. package/src/deepscientist/bash_exec/monitor.py +27 -5
  49. package/src/deepscientist/bash_exec/runtime.py +639 -0
  50. package/src/deepscientist/bash_exec/service.py +99 -16
  51. package/src/deepscientist/bridges/base.py +3 -0
  52. package/src/deepscientist/bridges/connectors.py +292 -13
  53. package/src/deepscientist/channels/qq.py +19 -2
  54. package/src/deepscientist/channels/relay.py +1 -0
  55. package/src/deepscientist/cli.py +32 -25
  56. package/src/deepscientist/config/models.py +28 -2
  57. package/src/deepscientist/config/service.py +201 -6
  58. package/src/deepscientist/connector_runtime.py +2 -0
  59. package/src/deepscientist/daemon/api/handlers.py +50 -5
  60. package/src/deepscientist/daemon/api/router.py +1 -0
  61. package/src/deepscientist/daemon/app.py +442 -15
  62. package/src/deepscientist/doctor.py +444 -0
  63. package/src/deepscientist/home.py +1 -0
  64. package/src/deepscientist/latex_runtime.py +17 -4
  65. package/src/deepscientist/lingzhu_support.py +182 -0
  66. package/src/deepscientist/mcp/server.py +49 -2
  67. package/src/deepscientist/prompts/builder.py +181 -58
  68. package/src/deepscientist/quest/layout.py +1 -0
  69. package/src/deepscientist/quest/service.py +63 -2
  70. package/src/deepscientist/quest/stage_views.py +19 -1
  71. package/src/deepscientist/runtime_tools/__init__.py +16 -0
  72. package/src/deepscientist/runtime_tools/builtins.py +19 -0
  73. package/src/deepscientist/runtime_tools/models.py +29 -0
  74. package/src/deepscientist/runtime_tools/registry.py +40 -0
  75. package/src/deepscientist/runtime_tools/service.py +59 -0
  76. package/src/deepscientist/runtime_tools/tinytex.py +25 -0
  77. package/src/deepscientist/tinytex.py +276 -0
  78. package/src/prompts/connectors/lingzhu.md +12 -0
  79. package/src/prompts/connectors/qq.md +121 -0
  80. package/src/prompts/system.md +177 -33
  81. package/src/skills/analysis-campaign/SKILL.md +22 -6
  82. package/src/skills/baseline/SKILL.md +5 -4
  83. package/src/skills/decision/SKILL.md +4 -3
  84. package/src/skills/experiment/SKILL.md +5 -4
  85. package/src/skills/finalize/SKILL.md +5 -4
  86. package/src/skills/idea/SKILL.md +5 -4
  87. package/src/skills/intake-audit/SKILL.md +277 -0
  88. package/src/skills/intake-audit/references/state-audit-template.md +41 -0
  89. package/src/skills/rebuttal/SKILL.md +407 -0
  90. package/src/skills/rebuttal/references/action-plan-template.md +63 -0
  91. package/src/skills/rebuttal/references/evidence-update-template.md +30 -0
  92. package/src/skills/rebuttal/references/response-letter-template.md +113 -0
  93. package/src/skills/rebuttal/references/review-matrix-template.md +55 -0
  94. package/src/skills/review/SKILL.md +293 -0
  95. package/src/skills/review/references/experiment-todo-template.md +29 -0
  96. package/src/skills/review/references/review-report-template.md +83 -0
  97. package/src/skills/review/references/revision-log-template.md +40 -0
  98. package/src/skills/scout/SKILL.md +5 -4
  99. package/src/skills/write/SKILL.md +7 -3
  100. package/src/tui/dist/components/WelcomePanel.js +17 -43
  101. package/src/tui/dist/components/messages/BashExecOperationMessage.js +3 -2
  102. package/src/tui/package.json +1 -1
  103. package/src/ui/dist/assets/{AiManusChatView-7v-dHngU.js → AiManusChatView-w5lF2Ttt.js} +109 -575
  104. package/src/ui/dist/assets/{AnalysisPlugin-B_Xmz-KE.js → AnalysisPlugin-DJOED79I.js} +1 -1
  105. package/src/ui/dist/assets/{AutoFigurePlugin-Cko-0tm1.js → AutoFigurePlugin-DaG61Y0M.js} +63 -8
  106. package/src/ui/dist/assets/{CliPlugin-BsU0ht7q.js → CliPlugin-CV4LqUB_.js} +43 -609
  107. package/src/ui/dist/assets/{CodeEditorPlugin-DcMMP0Rt.js → CodeEditorPlugin-DylfAea4.js} +8 -8
  108. package/src/ui/dist/assets/{CodeViewerPlugin-BqoQ5QyY.js → CodeViewerPlugin-F7saY0LM.js} +5 -5
  109. package/src/ui/dist/assets/{DocViewerPlugin-D7eHNhU6.js → DocViewerPlugin-COP0c7jf.js} +3 -3
  110. package/src/ui/dist/assets/{GitDiffViewerPlugin-DLJN42T5.js → GitDiffViewerPlugin-CAS05pT9.js} +1 -1
  111. package/src/ui/dist/assets/{ImageViewerPlugin-gJMV7MOu.js → ImageViewerPlugin-Bco1CN_w.js} +5 -6
  112. package/src/ui/dist/assets/{LabCopilotPanel-B857sfxP.js → LabCopilotPanel-CvMlCD99.js} +12 -15
  113. package/src/ui/dist/assets/LabPlugin-BYankkE4.js +2676 -0
  114. package/src/ui/dist/assets/LabPlugin-D9jVIo0A.css +2698 -0
  115. package/src/ui/dist/assets/{LatexPlugin-DWKEo-Wj.js → LatexPlugin-LDSMR-t-.js} +16 -16
  116. package/src/ui/dist/assets/{MarkdownViewerPlugin-DBzoEmhv.js → MarkdownViewerPlugin-B7o80jgm.js} +4 -4
  117. package/src/ui/dist/assets/{MarketplacePlugin-DoHc-8vo.js → MarketplacePlugin-CM6ZOcpC.js} +3 -3
  118. package/src/ui/dist/assets/{NotebookEditor-CKjKH-yS.js → NotebookEditor-Dc61cXmK.js} +3 -3
  119. package/src/ui/dist/assets/{PdfLoader-zFoL0VPo.js → PdfLoader-DWowuQwx.js} +1 -1
  120. package/src/ui/dist/assets/{PdfMarkdownPlugin-DXPaL9Nt.js → PdfMarkdownPlugin-BsJM1q_a.js} +3 -3
  121. package/src/ui/dist/assets/{PdfViewerPlugin-DhK8qCFp.js → PdfViewerPlugin-DB2eEEFQ.js} +10 -10
  122. package/src/ui/dist/assets/{SearchPlugin-CdSi6krf.js → SearchPlugin-CraThSvt.js} +1 -1
  123. package/src/ui/dist/assets/{Stepper-V-WiDQJl.js → Stepper-CgocRTPq.js} +1 -1
  124. package/src/ui/dist/assets/{TextViewerPlugin-hIs1Efiu.js → TextViewerPlugin-B1JGhKtd.js} +4 -4
  125. package/src/ui/dist/assets/{VNCViewer-DG8b0q2X.js → VNCViewer-CclFC7FM.js} +9 -10
  126. package/src/ui/dist/assets/{bibtex-HDac6fVW.js → bibtex-D3IKsMl7.js} +1 -1
  127. package/src/ui/dist/assets/{code-BnBeNxBc.js → code-BP37Xx0p.js} +1 -1
  128. package/src/ui/dist/assets/{file-content-IRQ3jHb8.js → file-content-BAJSu-9r.js} +1 -1
  129. package/src/ui/dist/assets/{file-diff-panel-DZoQ9I6r.js → file-diff-panel-DUGeCTuy.js} +1 -1
  130. package/src/ui/dist/assets/{file-socket-BMCdLc-P.js → file-socket-CXc1Ojf7.js} +1 -1
  131. package/src/ui/dist/assets/{file-utils-CltILB3w.js → file-utils-2J21jt7M.js} +1 -1
  132. package/src/ui/dist/assets/{image-Boe6ffhu.js → image-CMMmgvcn.js} +1 -1
  133. package/src/ui/dist/assets/{index-BlplpvE1.js → index-BaVumsQT.js} +2 -2
  134. package/src/ui/dist/assets/{index-DZqJ-qAM.js → index-CWgMgpow.js} +60 -2154
  135. package/src/ui/dist/assets/{index-DO43pFZP.js → index-DmwmJmbW.js} +6372 -8434
  136. package/src/ui/dist/assets/{index-Bq2bvfkl.css → index-KGt-z-dD.css} +225 -2920
  137. package/src/ui/dist/assets/{index-2Zf65FZt.js → index-s7aHnNQ4.js} +1 -1
  138. package/src/ui/dist/assets/{message-square-mUHn_Ssb.js → message-square-CQRfX0Am.js} +1 -1
  139. package/src/ui/dist/assets/{monaco-fe0arNEU.js → monaco-B4TbdsrF.js} +1 -1
  140. package/src/ui/dist/assets/{popover-D_7i19qU.js → popover-B8Rokodk.js} +1 -1
  141. package/src/ui/dist/assets/{project-sync-DyVGrU7H.js → project-sync-D_i96KH4.js} +2 -8
  142. package/src/ui/dist/assets/{sigma-BzazRyxQ.js → sigma-D12PnzCN.js} +1 -1
  143. package/src/ui/dist/assets/{tooltip-DN_yjHFH.js → tooltip-B6YrI4aJ.js} +1 -1
  144. package/src/ui/dist/assets/trash-Bc8jGp0V.js +32 -0
  145. package/src/ui/dist/assets/{useCliAccess-DV2L2Qxy.js → useCliAccess-mXVCYSZ-.js} +12 -42
  146. package/src/ui/dist/assets/{useFileDiffOverlay-DyTj-p_V.js → useFileDiffOverlay-Bg6b9H9K.js} +1 -1
  147. package/src/ui/dist/assets/{wrap-text-ozYHtUwq.js → wrap-text-Drh5GEnL.js} +1 -1
  148. package/src/ui/dist/assets/{zoom-out-BN9MUyCQ.js → zoom-out-CJj9DZLn.js} +1 -1
  149. package/src/ui/dist/index.html +2 -2
  150. package/assets/fonts/Inter-Variable.ttf +0 -0
  151. package/assets/fonts/NotoSerifSC-Regular-C94HN_ZN.ttf +0 -0
  152. package/assets/fonts/NunitoSans-Variable.ttf +0 -0
  153. package/assets/fonts/Satoshi-Medium-ByP-Zb-9.woff2 +0 -0
  154. package/assets/fonts/SourceSans3-Variable.ttf +0 -0
  155. package/assets/fonts/ds-fonts.css +0 -83
  156. package/src/ui/dist/assets/Inter-Variable-VF2RPR_K.ttf +0 -0
  157. package/src/ui/dist/assets/LabPlugin-bL7rpic8.js +0 -43
  158. package/src/ui/dist/assets/NotoSerifSC-Regular-C94HN_ZN-C94HN_ZN.ttf +0 -0
  159. package/src/ui/dist/assets/NunitoSans-Variable-B_ZymHAd.ttf +0 -0
  160. package/src/ui/dist/assets/Satoshi-Medium-ByP-Zb-9-GkA34YXu.woff2 +0 -0
  161. package/src/ui/dist/assets/SourceSans3-Variable-CD-WOsSK.ttf +0 -0
  162. package/src/ui/dist/assets/info-CcsK_htA.js +0 -18
  163. package/src/ui/dist/assets/user-plus-BusDx-hF.js +0 -79
@@ -422,6 +422,45 @@ class ArtifactService:
422
422
  def _normalize_string_list(values: list[object] | None) -> list[str]:
423
423
  return [str(item).strip() for item in (values or []) if str(item).strip()]
424
424
 
425
+ def _normalize_campaign_origin(self, payload: dict[str, Any] | None) -> dict[str, Any] | None:
426
+ if not isinstance(payload, dict):
427
+ return None
428
+ origin_kind = str(payload.get("kind") or "analysis").strip().lower() or "analysis"
429
+ normalized = {
430
+ "kind": origin_kind,
431
+ "reason": str(payload.get("reason") or "").strip() or None,
432
+ "source_artifact_id": str(payload.get("source_artifact_id") or "").strip() or None,
433
+ "source_outline_ref": str(payload.get("source_outline_ref") or "").strip() or None,
434
+ "source_review_round": str(payload.get("source_review_round") or "").strip() or None,
435
+ "reviewer_item_ids": self._normalize_string_list(payload.get("reviewer_item_ids")),
436
+ }
437
+ if not any(value for key, value in normalized.items() if key != "kind"):
438
+ normalized["reason"] = None
439
+ return normalized
440
+
441
+ def _normalize_campaign_todo_items(self, todo_items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
442
+ normalized_items: list[dict[str, Any]] = []
443
+ for raw in todo_items or []:
444
+ if not isinstance(raw, dict):
445
+ continue
446
+ normalized_items.append(
447
+ {
448
+ "todo_id": str(raw.get("todo_id") or raw.get("slice_id") or "").strip() or None,
449
+ "slice_id": str(raw.get("slice_id") or "").strip() or None,
450
+ "title": str(raw.get("title") or "").strip() or None,
451
+ "status": str(raw.get("status") or "pending").strip() or "pending",
452
+ "research_question": str(raw.get("research_question") or "").strip() or None,
453
+ "experimental_design": str(raw.get("experimental_design") or "").strip() or None,
454
+ "completion_condition": str(raw.get("completion_condition") or "").strip() or None,
455
+ "why_now": str(raw.get("why_now") or "").strip() or None,
456
+ "success_criteria": str(raw.get("success_criteria") or "").strip() or None,
457
+ "abandonment_criteria": str(raw.get("abandonment_criteria") or "").strip() or None,
458
+ "reviewer_item_ids": self._normalize_string_list(raw.get("reviewer_item_ids")),
459
+ "manuscript_targets": self._normalize_string_list(raw.get("manuscript_targets")),
460
+ }
461
+ )
462
+ return normalized_items
463
+
425
464
  def _normalize_paper_outline_record(
426
465
  self,
427
466
  *,
@@ -1073,6 +1112,80 @@ class ArtifactService:
1073
1112
  ]
1074
1113
  return candidates[-1] if candidates else None
1075
1114
 
1115
+ def _latest_branch_idea_id(self, quest_root: Path, branch_name: str) -> str | None:
1116
+ normalized_branch = str(branch_name or "").strip()
1117
+ if not normalized_branch:
1118
+ return None
1119
+ latest_idea = self._latest_idea_for_branch(quest_root, normalized_branch)
1120
+ if isinstance(latest_idea, dict):
1121
+ candidate = str(latest_idea.get("idea_id") or "").strip()
1122
+ if candidate:
1123
+ return candidate
1124
+ latest_main_run = self._latest_main_run_for_branch(quest_root, normalized_branch)
1125
+ if isinstance(latest_main_run, dict):
1126
+ candidate = str(latest_main_run.get("idea_id") or "").strip()
1127
+ if candidate:
1128
+ return candidate
1129
+ latest_match: tuple[str, int, str] | None = None
1130
+ latest_candidate: str | None = None
1131
+ for item in self.quest_service._collect_artifacts(quest_root):
1132
+ payload = dict(item.get("payload") or {}) if isinstance(item.get("payload"), dict) else {}
1133
+ if not payload:
1134
+ continue
1135
+ if str(payload.get("branch") or "").strip() != normalized_branch:
1136
+ continue
1137
+ candidate = str(payload.get("idea_id") or "").strip()
1138
+ if not candidate:
1139
+ continue
1140
+ artifact_path = str(item.get("path") or "")
1141
+ try:
1142
+ artifact_mtime_ns = Path(artifact_path).stat().st_mtime_ns if artifact_path else 0
1143
+ except OSError:
1144
+ artifact_mtime_ns = 0
1145
+ sort_key = (
1146
+ str(payload.get("updated_at") or payload.get("created_at") or ""),
1147
+ artifact_mtime_ns,
1148
+ artifact_path,
1149
+ )
1150
+ if latest_match is None or sort_key > latest_match:
1151
+ latest_match = sort_key
1152
+ latest_candidate = candidate
1153
+ if latest_match is not None and latest_candidate:
1154
+ return latest_candidate
1155
+ return None
1156
+
1157
+ def _resolve_analysis_parent_context(
1158
+ self,
1159
+ quest_root: Path,
1160
+ *,
1161
+ state: dict[str, Any],
1162
+ ) -> tuple[str, Path, str | None]:
1163
+ current_root_raw = str(state.get("current_workspace_root") or "").strip()
1164
+ head_root_raw = str(state.get("research_head_worktree_root") or "").strip()
1165
+ parent_worktree_root: Path | None = None
1166
+ for raw in (current_root_raw, head_root_raw):
1167
+ if not raw:
1168
+ continue
1169
+ candidate = Path(raw)
1170
+ if candidate.exists():
1171
+ parent_worktree_root = candidate
1172
+ break
1173
+ if parent_worktree_root is None:
1174
+ parent_worktree_root = self._workspace_root_for(quest_root)
1175
+
1176
+ parent_branch = (
1177
+ str(state.get("current_workspace_branch") or "").strip()
1178
+ or str(state.get("research_head_branch") or "").strip()
1179
+ or current_branch(parent_worktree_root)
1180
+ or current_branch(self._workspace_root_for(quest_root))
1181
+ )
1182
+ parent_branch = str(parent_branch or "").strip()
1183
+ if not parent_branch:
1184
+ raise ValueError("Unable to resolve a parent branch for the analysis campaign.")
1185
+
1186
+ idea_id = self._latest_branch_idea_id(quest_root, parent_branch) or str(state.get("active_idea_id") or "").strip() or None
1187
+ return parent_branch, parent_worktree_root, idea_id
1188
+
1076
1189
  def _idea_parent_branch(self, record: dict[str, Any] | None) -> str | None:
1077
1190
  if not isinstance(record, dict) or not record:
1078
1191
  return None
@@ -1357,6 +1470,111 @@ class ArtifactService:
1357
1470
  "branches": branches,
1358
1471
  }
1359
1472
 
1473
+ def resolve_runtime_refs(self, quest_root: Path) -> dict[str, Any]:
1474
+ state = self.quest_service.read_research_state(quest_root)
1475
+ snapshot = self.quest_service.snapshot(self._quest_id(quest_root))
1476
+ active_campaign_id = str(state.get("active_analysis_campaign_id") or "").strip() or None
1477
+ analysis_parent_branch = str(state.get("analysis_parent_branch") or "").strip() or None
1478
+ current_workspace_branch = str(state.get("current_workspace_branch") or "").strip() or None
1479
+ research_head_branch = str(state.get("research_head_branch") or "").strip() or None
1480
+ canonical_branch = analysis_parent_branch or current_workspace_branch or research_head_branch
1481
+ latest_main_run = self._latest_main_run_for_branch(quest_root, canonical_branch or "")
1482
+ selected_outline = read_json(self._paper_selected_outline_path(quest_root), {})
1483
+ selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
1484
+ active_campaign = (
1485
+ self._read_analysis_manifest(quest_root, active_campaign_id)
1486
+ if active_campaign_id
1487
+ else {}
1488
+ )
1489
+ active_campaign = active_campaign if isinstance(active_campaign, dict) else {}
1490
+ latest_paths = (
1491
+ dict(latest_main_run.get("paths") or {})
1492
+ if isinstance(latest_main_run, dict) and isinstance(latest_main_run.get("paths"), dict)
1493
+ else {}
1494
+ )
1495
+ return {
1496
+ "ok": True,
1497
+ "active_idea_id": str(state.get("active_idea_id") or "").strip() or None,
1498
+ "research_head_branch": research_head_branch,
1499
+ "research_head_worktree_root": str(state.get("research_head_worktree_root") or "").strip() or None,
1500
+ "current_workspace_branch": current_workspace_branch,
1501
+ "current_workspace_root": str(state.get("current_workspace_root") or "").strip() or None,
1502
+ "analysis_parent_branch": analysis_parent_branch,
1503
+ "analysis_parent_worktree_root": str(state.get("analysis_parent_worktree_root") or "").strip() or None,
1504
+ "current_canonical_branch": canonical_branch,
1505
+ "active_analysis_campaign_id": active_campaign_id,
1506
+ "active_campaign_title": str(active_campaign.get("title") or "").strip() or None,
1507
+ "next_pending_slice_id": str(state.get("next_pending_slice_id") or "").strip() or None,
1508
+ "latest_main_run_id": str((latest_main_run or {}).get("run_id") or "").strip() or None,
1509
+ "latest_main_run_branch": str((latest_main_run or {}).get("branch") or "").strip() or None,
1510
+ "latest_main_result_json": str(latest_paths.get("result_json") or "").strip() or None,
1511
+ "selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
1512
+ "default_reply_interaction_id": str(snapshot.get("default_reply_interaction_id") or "").strip() or None,
1513
+ }
1514
+
1515
+ def get_analysis_campaign(self, quest_root: Path, campaign_id: str | None = None) -> dict[str, Any]:
1516
+ resolved_campaign_id = str(campaign_id or "").strip()
1517
+ if not resolved_campaign_id or resolved_campaign_id == "active":
1518
+ state = self.quest_service.read_research_state(quest_root)
1519
+ resolved_campaign_id = str(state.get("active_analysis_campaign_id") or "").strip()
1520
+ if not resolved_campaign_id:
1521
+ raise ValueError("No active analysis campaign is available.")
1522
+ manifest = self._read_analysis_manifest(quest_root, resolved_campaign_id)
1523
+ slices = [dict(item) for item in (manifest.get("slices") or []) if isinstance(item, dict)]
1524
+ pending_slices = [item for item in slices if str(item.get("status") or "pending").strip() == "pending"]
1525
+ completed_slices = [item for item in slices if str(item.get("status") or "").strip() != "pending"]
1526
+ next_pending_slice = pending_slices[0] if pending_slices else None
1527
+ return {
1528
+ "ok": True,
1529
+ "campaign_id": resolved_campaign_id,
1530
+ "title": str(manifest.get("title") or "").strip() or None,
1531
+ "goal": str(manifest.get("goal") or "").strip() or None,
1532
+ "active_idea_id": str(manifest.get("active_idea_id") or "").strip() or None,
1533
+ "parent_run_id": str(manifest.get("parent_run_id") or "").strip() or None,
1534
+ "parent_branch": str(manifest.get("parent_branch") or "").strip() or None,
1535
+ "parent_worktree_root": str(manifest.get("parent_worktree_root") or "").strip() or None,
1536
+ "selected_outline_ref": str(manifest.get("selected_outline_ref") or "").strip() or None,
1537
+ "campaign_origin": dict(manifest.get("campaign_origin") or {}) if isinstance(manifest.get("campaign_origin"), dict) else None,
1538
+ "todo_items": [dict(item) for item in (manifest.get("todo_items") or []) if isinstance(item, dict)],
1539
+ "slices": slices,
1540
+ "next_pending_slice_id": str((next_pending_slice or {}).get("slice_id") or "").strip() or None,
1541
+ "pending_slice_count": len(pending_slices),
1542
+ "completed_slice_count": len(completed_slices),
1543
+ "manifest": manifest,
1544
+ }
1545
+
1546
+ def list_paper_outlines(self, quest_root: Path) -> dict[str, Any]:
1547
+ selected_outline = read_json(self._paper_selected_outline_path(quest_root), {})
1548
+ selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
1549
+ outlines: list[dict[str, Any]] = []
1550
+ for status, root in (
1551
+ ("candidate", self._paper_outline_candidates_root(quest_root)),
1552
+ ("revised", self._paper_outline_revisions_root(quest_root)),
1553
+ ):
1554
+ for path in sorted(root.glob("outline-*.json")):
1555
+ record = read_json(path, {})
1556
+ if not isinstance(record, dict) or not record:
1557
+ continue
1558
+ outline_id = str(record.get("outline_id") or path.stem).strip() or path.stem
1559
+ outlines.append(
1560
+ {
1561
+ "outline_id": outline_id,
1562
+ "title": str(record.get("title") or outline_id).strip() or outline_id,
1563
+ "status": str(record.get("status") or status).strip() or status,
1564
+ "review_result": str(record.get("review_result") or "").strip() or None,
1565
+ "path": str(path),
1566
+ "is_selected": outline_id == str(selected_outline.get("outline_id") or "").strip(),
1567
+ }
1568
+ )
1569
+ outlines.sort(key=lambda item: (str(item.get("outline_id") or ""), str(item.get("status") or "")))
1570
+ return {
1571
+ "ok": True,
1572
+ "selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
1573
+ "selected_outline": selected_outline or None,
1574
+ "count": len(outlines),
1575
+ "outlines": outlines,
1576
+ }
1577
+
1360
1578
  def _previous_primary_best(
1361
1579
  self,
1362
1580
  quest_root: Path,
@@ -2508,6 +2726,7 @@ class ArtifactService:
2508
2726
  campaign_goal: str,
2509
2727
  parent_run_id: str | None = None,
2510
2728
  slices: list[dict[str, Any]],
2729
+ campaign_origin: dict[str, Any] | None = None,
2511
2730
  selected_outline_ref: str | None = None,
2512
2731
  research_questions: list[str] | None = None,
2513
2732
  experimental_designs: list[str] | None = None,
@@ -2515,20 +2734,23 @@ class ArtifactService:
2515
2734
  ) -> dict[str, Any]:
2516
2735
  self._require_baseline_gate_open(quest_root, action="create_analysis_campaign")
2517
2736
  state = self.quest_service.read_research_state(quest_root)
2518
- active_idea_id = str(state.get("active_idea_id") or "").strip()
2737
+ parent_branch, parent_worktree_root, resolved_idea_id = self._resolve_analysis_parent_context(
2738
+ quest_root,
2739
+ state=state,
2740
+ )
2741
+ active_idea_id = str(resolved_idea_id or "").strip()
2519
2742
  if not active_idea_id:
2520
2743
  raise ValueError("An active idea is required before starting an analysis campaign.")
2521
2744
  if not slices:
2522
2745
  raise ValueError("At least one analysis slice is required.")
2523
- parent_branch = str(state.get("research_head_branch") or current_branch(self._workspace_root_for(quest_root))).strip()
2524
- parent_worktree_root = Path(str(state.get("research_head_worktree_root") or self._workspace_root_for(quest_root)))
2525
2746
  campaign_id = generate_id("analysis")
2526
2747
  charter_dir = ensure_dir(parent_worktree_root / "experiments" / "analysis-results" / campaign_id)
2527
2748
  charter_path = charter_dir / "campaign.md"
2749
+ normalized_campaign_origin = self._normalize_campaign_origin(campaign_origin)
2528
2750
  resolved_outline_ref = str(selected_outline_ref or "").strip() or None
2529
2751
  normalized_research_questions = self._normalize_string_list(research_questions)
2530
2752
  normalized_experimental_designs = self._normalize_string_list(experimental_designs)
2531
- normalized_todo_items = [dict(item) for item in (todo_items or []) if isinstance(item, dict)]
2753
+ normalized_todo_items = self._normalize_campaign_todo_items(todo_items)
2532
2754
  slice_contexts: list[dict[str, Any]] = []
2533
2755
  for index, raw in enumerate(slices, start=1):
2534
2756
  slice_id = str(raw.get("slice_id") or generate_id("slice")).strip()
@@ -2550,6 +2772,17 @@ class ArtifactService:
2550
2772
  worktree_root=worktree_root,
2551
2773
  start_point=parent_branch,
2552
2774
  )
2775
+ reviewer_item_ids = self._normalize_string_list(
2776
+ raw.get("reviewer_item_ids") or matched_todo.get("reviewer_item_ids")
2777
+ )
2778
+ manuscript_targets = self._normalize_string_list(
2779
+ raw.get("manuscript_targets") or matched_todo.get("manuscript_targets")
2780
+ )
2781
+ why_now = str(raw.get("why_now") or matched_todo.get("why_now") or "").strip()
2782
+ success_criteria = str(raw.get("success_criteria") or matched_todo.get("success_criteria") or "").strip()
2783
+ abandonment_criteria = str(
2784
+ raw.get("abandonment_criteria") or matched_todo.get("abandonment_criteria") or ""
2785
+ ).strip()
2553
2786
  plan_dir = ensure_dir(worktree_root / "experiments" / "analysis" / campaign_id / slice_id)
2554
2787
  plan_path = plan_dir / "plan.md"
2555
2788
  requirement_lines = [
@@ -2567,6 +2800,10 @@ class ArtifactService:
2567
2800
  "",
2568
2801
  str(raw.get("experimental_design") or matched_todo.get("experimental_design") or "").strip() or "TBD",
2569
2802
  "",
2803
+ "## Why Now",
2804
+ "",
2805
+ why_now or "TBD",
2806
+ "",
2570
2807
  "## Hypothesis",
2571
2808
  "",
2572
2809
  str(raw.get("hypothesis") or "").strip() or "TBD",
@@ -2587,6 +2824,14 @@ class ArtifactService:
2587
2824
  "",
2588
2825
  str(raw.get("must_not_simplify") or "").strip() or "Full dataset / full protocol only unless explicitly approved.",
2589
2826
  "",
2827
+ "## Success Criteria",
2828
+ "",
2829
+ success_criteria or "TBD",
2830
+ "",
2831
+ "## Abandonment Criteria",
2832
+ "",
2833
+ abandonment_criteria or "TBD",
2834
+ "",
2590
2835
  "## Completion Condition",
2591
2836
  "",
2592
2837
  str(raw.get("completion_condition") or matched_todo.get("completion_condition") or "").strip()
@@ -2594,6 +2839,17 @@ class ArtifactService:
2594
2839
  or "Complete the planned analysis slice and mirror the durable result back to the parent branch.",
2595
2840
  "",
2596
2841
  ]
2842
+ requirement_lines.extend(["## Reviewer Item IDs", ""])
2843
+ if reviewer_item_ids:
2844
+ requirement_lines.extend([f"- `{item}`" for item in reviewer_item_ids])
2845
+ else:
2846
+ requirement_lines.append("- None recorded.")
2847
+ requirement_lines.extend(["", "## Manuscript Targets", ""])
2848
+ if manuscript_targets:
2849
+ requirement_lines.extend([f"- {item}" for item in manuscript_targets])
2850
+ else:
2851
+ requirement_lines.append("- None recorded.")
2852
+ requirement_lines.append("")
2597
2853
  write_text(plan_path, "\n".join(requirement_lines))
2598
2854
  slice_contexts.append(
2599
2855
  {
@@ -2612,20 +2868,26 @@ class ArtifactService:
2612
2868
  "experimental_design": str(
2613
2869
  raw.get("experimental_design") or matched_todo.get("experimental_design") or ""
2614
2870
  ).strip(),
2871
+ "why_now": why_now,
2615
2872
  "hypothesis": str(raw.get("hypothesis") or "").strip(),
2616
2873
  "required_changes": str(raw.get("required_changes") or "").strip(),
2617
2874
  "metric_contract": str(raw.get("metric_contract") or "").strip(),
2618
2875
  "environment_notes": str(raw.get("environment_notes") or "").strip(),
2619
2876
  "must_not_simplify": str(raw.get("must_not_simplify") or "").strip(),
2877
+ "success_criteria": success_criteria,
2878
+ "abandonment_criteria": abandonment_criteria,
2620
2879
  "completion_condition": str(
2621
2880
  raw.get("completion_condition") or matched_todo.get("completion_condition") or ""
2622
2881
  ).strip(),
2882
+ "reviewer_item_ids": reviewer_item_ids,
2883
+ "manuscript_targets": manuscript_targets,
2623
2884
  }
2624
2885
  )
2625
2886
 
2626
2887
  todo_manifest = {
2627
2888
  "schema_version": 1,
2628
2889
  "campaign_id": campaign_id,
2890
+ "campaign_origin": normalized_campaign_origin,
2629
2891
  "selected_outline_ref": resolved_outline_ref,
2630
2892
  "research_questions": normalized_research_questions,
2631
2893
  "experimental_designs": normalized_experimental_designs,
@@ -2635,9 +2897,14 @@ class ArtifactService:
2635
2897
  "slice_id": context["slice_id"],
2636
2898
  "title": str(item.get("title") or context["title"]).strip() or context["title"],
2637
2899
  "status": str(item.get("status") or "pending").strip() or "pending",
2638
- "research_question": context.get("research_question") or item.get("research_question"),
2639
- "experimental_design": context.get("experimental_design") or item.get("experimental_design"),
2640
- "completion_condition": context.get("completion_condition") or item.get("completion_condition") or context.get("must_not_simplify"),
2900
+ "research_question": item.get("research_question") or context.get("research_question"),
2901
+ "experimental_design": item.get("experimental_design") or context.get("experimental_design"),
2902
+ "completion_condition": item.get("completion_condition") or context.get("completion_condition") or context.get("must_not_simplify"),
2903
+ "why_now": item.get("why_now") or context.get("why_now"),
2904
+ "success_criteria": item.get("success_criteria") or context.get("success_criteria"),
2905
+ "abandonment_criteria": item.get("abandonment_criteria") or context.get("abandonment_criteria"),
2906
+ "reviewer_item_ids": item.get("reviewer_item_ids") or context.get("reviewer_item_ids") or [],
2907
+ "manuscript_targets": item.get("manuscript_targets") or context.get("manuscript_targets") or [],
2641
2908
  }
2642
2909
  for context, item in zip(slice_contexts, normalized_todo_items + [{}] * max(0, len(slice_contexts) - len(normalized_todo_items)))
2643
2910
  ],
@@ -2666,6 +2933,14 @@ class ArtifactService:
2666
2933
  "",
2667
2934
  f"`{resolved_outline_ref or 'none'}`",
2668
2935
  "",
2936
+ "## Campaign Origin",
2937
+ "",
2938
+ f"- Kind: `{(normalized_campaign_origin or {}).get('kind') or 'analysis'}`",
2939
+ f"- Reason: {str((normalized_campaign_origin or {}).get('reason') or 'Not recorded')}",
2940
+ f"- Source Artifact: `{str((normalized_campaign_origin or {}).get('source_artifact_id') or 'none')}`",
2941
+ f"- Source Outline: `{str((normalized_campaign_origin or {}).get('source_outline_ref') or 'none')}`",
2942
+ f"- Source Review Round: `{str((normalized_campaign_origin or {}).get('source_review_round') or 'none')}`",
2943
+ "",
2669
2944
  "## Slices",
2670
2945
  "",
2671
2946
  ]
@@ -2681,8 +2956,13 @@ class ArtifactService:
2681
2956
  f"- Goal: {item['goal'] or 'TBD'}",
2682
2957
  f"- Research question: {item['research_question'] or 'TBD'}",
2683
2958
  f"- Experimental design: {item['experimental_design'] or 'TBD'}",
2959
+ f"- Why now: {item['why_now'] or 'TBD'}",
2960
+ f"- Success criteria: {item['success_criteria'] or 'TBD'}",
2961
+ f"- Abandonment criteria: {item['abandonment_criteria'] or 'TBD'}",
2684
2962
  f"- Completion condition: {item['completion_condition'] or item['must_not_simplify'] or 'TBD'}",
2685
2963
  f"- Requirement: {item['must_not_simplify'] or 'TBD'}",
2964
+ f"- Reviewer items: {', '.join(item['reviewer_item_ids']) or 'none'}",
2965
+ f"- Manuscript targets: {', '.join(item['manuscript_targets']) or 'none'}",
2686
2966
  "",
2687
2967
  ]
2688
2968
  )
@@ -2697,6 +2977,7 @@ class ArtifactService:
2697
2977
  "active_idea_id": active_idea_id,
2698
2978
  "parent_branch": parent_branch,
2699
2979
  "parent_worktree_root": str(parent_worktree_root),
2980
+ "campaign_origin": normalized_campaign_origin,
2700
2981
  "selected_outline_ref": resolved_outline_ref,
2701
2982
  "research_questions": normalized_research_questions,
2702
2983
  "experimental_designs": normalized_experimental_designs,
@@ -2707,6 +2988,44 @@ class ArtifactService:
2707
2988
  "created_at": utc_now(),
2708
2989
  },
2709
2990
  )
2991
+ for item in slice_contexts:
2992
+ self.record(
2993
+ quest_root,
2994
+ {
2995
+ "kind": "milestone",
2996
+ "status": "prepared",
2997
+ "summary": f"Analysis slice `{item['slice_id']}` prepared as a child branch.",
2998
+ "reason": "Expose the pending follow-up branch durably so Canvas and Git lineage stay visible before execution.",
2999
+ "idea_id": active_idea_id,
3000
+ "campaign_id": campaign_id,
3001
+ "slice_id": item["slice_id"],
3002
+ "branch": item["branch"],
3003
+ "parent_branch": parent_branch,
3004
+ "worktree_root": item["worktree_root"],
3005
+ "worktree_rel_path": self._workspace_relative(quest_root, Path(item["worktree_root"])),
3006
+ "flow_type": "analysis_slice",
3007
+ "protocol_step": "prepare",
3008
+ "paths": {
3009
+ "plan_md": item["plan_path"],
3010
+ },
3011
+ "details": {
3012
+ "title": item["title"],
3013
+ "goal": item["goal"],
3014
+ "run_kind": item["run_kind"],
3015
+ "research_question": item["research_question"],
3016
+ "experimental_design": item["experimental_design"],
3017
+ "why_now": item["why_now"],
3018
+ "completion_condition": item["completion_condition"] or item["must_not_simplify"],
3019
+ "must_not_simplify": item["must_not_simplify"],
3020
+ "success_criteria": item["success_criteria"],
3021
+ "abandonment_criteria": item["abandonment_criteria"],
3022
+ "reviewer_item_ids": item["reviewer_item_ids"],
3023
+ "manuscript_targets": item["manuscript_targets"],
3024
+ },
3025
+ },
3026
+ checkpoint=False,
3027
+ workspace_root=Path(item["worktree_root"]),
3028
+ )
2710
3029
  first_slice = slice_contexts[0]
2711
3030
  artifact = self.record(
2712
3031
  quest_root,
@@ -2729,6 +3048,7 @@ class ArtifactService:
2729
3048
  "campaign_title": campaign_title,
2730
3049
  "campaign_goal": campaign_goal,
2731
3050
  "parent_run_id": parent_run_id,
3051
+ "campaign_origin": normalized_campaign_origin,
2732
3052
  "selected_outline_ref": resolved_outline_ref,
2733
3053
  "todo_manifest_path": str(todo_manifest_path),
2734
3054
  "slice_count": len(slice_contexts),
@@ -2742,8 +3062,13 @@ class ArtifactService:
2742
3062
  "goal": item["goal"],
2743
3063
  "research_question": item["research_question"],
2744
3064
  "experimental_design": item["experimental_design"],
3065
+ "why_now": item["why_now"],
2745
3066
  "completion_condition": item["completion_condition"] or item["must_not_simplify"],
2746
3067
  "must_not_simplify": item["must_not_simplify"],
3068
+ "success_criteria": item["success_criteria"],
3069
+ "abandonment_criteria": item["abandonment_criteria"],
3070
+ "reviewer_item_ids": item["reviewer_item_ids"],
3071
+ "manuscript_targets": item["manuscript_targets"],
2747
3072
  }
2748
3073
  for item in slice_contexts
2749
3074
  ],
@@ -2754,6 +3079,7 @@ class ArtifactService:
2754
3079
  )
2755
3080
  research_state = self.quest_service.update_research_state(
2756
3081
  quest_root,
3082
+ active_idea_id=active_idea_id,
2757
3083
  active_analysis_campaign_id=campaign_id,
2758
3084
  analysis_parent_branch=parent_branch,
2759
3085
  analysis_parent_worktree_root=str(parent_worktree_root),
@@ -2785,16 +3111,17 @@ class ArtifactService:
2785
3111
  attachments=[
2786
3112
  {
2787
3113
  "kind": "analysis_campaign",
2788
- "campaign_id": campaign_id,
2789
- "parent_branch": parent_branch,
2790
- "parent_worktree_root": str(parent_worktree_root),
2791
- "selected_outline_ref": resolved_outline_ref,
2792
- "todo_manifest_path": str(todo_manifest_path),
2793
- "next_slice": first_slice,
2794
- "todo_items": todo_manifest["todo_items"],
2795
- "slices": slice_contexts,
2796
- }
2797
- ],
3114
+ "campaign_id": campaign_id,
3115
+ "parent_branch": parent_branch,
3116
+ "parent_worktree_root": str(parent_worktree_root),
3117
+ "campaign_origin": normalized_campaign_origin,
3118
+ "selected_outline_ref": resolved_outline_ref,
3119
+ "todo_manifest_path": str(todo_manifest_path),
3120
+ "next_slice": first_slice,
3121
+ "todo_items": todo_manifest["todo_items"],
3122
+ "slices": slice_contexts,
3123
+ }
3124
+ ],
2798
3125
  )
2799
3126
  return {
2800
3127
  "ok": True,
@@ -2807,6 +3134,7 @@ class ArtifactService:
2807
3134
  "campaign_id": campaign_id,
2808
3135
  "parent_branch": parent_branch,
2809
3136
  "parent_worktree_root": str(parent_worktree_root),
3137
+ "campaign_origin": normalized_campaign_origin,
2810
3138
  "charter_path": str(charter_path),
2811
3139
  "slices": slice_contexts,
2812
3140
  "manifest": manifest,
@@ -3060,6 +3388,10 @@ class ArtifactService:
3060
3388
  evidence_paths: list[str] | None = None,
3061
3389
  metric_rows: list[dict[str, Any]] | None = None,
3062
3390
  deviations: list[str] | None = None,
3391
+ claim_impact: str | None = None,
3392
+ reviewer_resolution: str | None = None,
3393
+ manuscript_update_hint: str | None = None,
3394
+ next_recommendation: str | None = None,
3063
3395
  dataset_scope: str = "full",
3064
3396
  subset_approval_ref: str | None = None,
3065
3397
  ) -> dict[str, Any]:
@@ -3076,6 +3408,10 @@ class ArtifactService:
3076
3408
  evidence_paths = [str(item).strip() for item in (evidence_paths or []) if str(item).strip()]
3077
3409
  deviations = [str(item).strip() for item in (deviations or []) if str(item).strip()]
3078
3410
  metric_rows = [item for item in (metric_rows or []) if isinstance(item, dict)]
3411
+ normalized_claim_impact = str(claim_impact or "").strip() or None
3412
+ normalized_reviewer_resolution = str(reviewer_resolution or "").strip() or None
3413
+ normalized_manuscript_update_hint = str(manuscript_update_hint or "").strip() or None
3414
+ normalized_next_recommendation = str(next_recommendation or "").strip() or None
3079
3415
  slice_worktree_root = Path(str(target.get("worktree_root") or ""))
3080
3416
  parent_worktree_root = Path(str(manifest.get("parent_worktree_root") or ""))
3081
3417
  parent_branch = str(manifest.get("parent_branch") or "")
@@ -3104,6 +3440,22 @@ class ArtifactService:
3104
3440
  "",
3105
3441
  results.strip() or "TBD",
3106
3442
  "",
3443
+ "## Claim Impact",
3444
+ "",
3445
+ normalized_claim_impact or "Not recorded.",
3446
+ "",
3447
+ "## Reviewer Resolution",
3448
+ "",
3449
+ normalized_reviewer_resolution or "Not recorded.",
3450
+ "",
3451
+ "## Manuscript Update Hint",
3452
+ "",
3453
+ normalized_manuscript_update_hint or "Not recorded.",
3454
+ "",
3455
+ "## Next Recommendation",
3456
+ "",
3457
+ normalized_next_recommendation or "Not recorded.",
3458
+ "",
3107
3459
  "## Deviations",
3108
3460
  "",
3109
3461
  ]
@@ -3164,6 +3516,14 @@ class ArtifactService:
3164
3516
  "",
3165
3517
  results.strip() or "TBD",
3166
3518
  "",
3519
+ "## Claim Impact",
3520
+ "",
3521
+ normalized_claim_impact or "Not recorded.",
3522
+ "",
3523
+ "## Manuscript Update Hint",
3524
+ "",
3525
+ normalized_manuscript_update_hint or "Not recorded.",
3526
+ "",
3167
3527
  ]
3168
3528
  write_text(mirror_path, "\n".join(mirror_lines).rstrip() + "\n")
3169
3529
 
@@ -3197,6 +3557,10 @@ class ArtifactService:
3197
3557
  "dataset_scope": normalized_scope,
3198
3558
  "subset_approval_ref": subset_approval_ref,
3199
3559
  "metric_rows": metric_rows,
3560
+ "claim_impact": normalized_claim_impact,
3561
+ "reviewer_resolution": normalized_reviewer_resolution,
3562
+ "manuscript_update_hint": normalized_manuscript_update_hint,
3563
+ "next_recommendation": normalized_next_recommendation,
3200
3564
  "deviations": deviations,
3201
3565
  "evidence_paths": evidence_paths,
3202
3566
  },
@@ -3223,6 +3587,10 @@ class ArtifactService:
3223
3587
  updated["completed_at"] = utc_now()
3224
3588
  updated["result_path"] = str(result_path)
3225
3589
  updated["mirror_path"] = str(mirror_path)
3590
+ updated["claim_impact"] = normalized_claim_impact
3591
+ updated["reviewer_resolution"] = normalized_reviewer_resolution
3592
+ updated["manuscript_update_hint"] = normalized_manuscript_update_hint
3593
+ updated["next_recommendation"] = normalized_next_recommendation
3226
3594
  updated_slices.append(updated)
3227
3595
  next_slice = next((item for item in updated_slices if str(item.get("status") or "") == "pending"), None)
3228
3596
  manifest = self._write_analysis_manifest(
@@ -3336,12 +3704,14 @@ class ArtifactService:
3336
3704
  parent_worktree_root,
3337
3705
  message=f"analysis: summarize {campaign_id}",
3338
3706
  )
3707
+ restored_idea_id = self._latest_branch_idea_id(quest_root, parent_branch) or str(manifest.get("active_idea_id") or "").strip() or None
3339
3708
  research_state = self.quest_service.update_research_state(
3340
3709
  quest_root,
3710
+ active_idea_id=restored_idea_id,
3341
3711
  active_analysis_campaign_id=None,
3342
3712
  next_pending_slice_id=None,
3343
- current_workspace_branch=state.get("research_head_branch") or parent_branch,
3344
- current_workspace_root=state.get("research_head_worktree_root") or str(parent_worktree_root),
3713
+ current_workspace_branch=parent_branch,
3714
+ current_workspace_root=str(parent_worktree_root),
3345
3715
  workspace_mode="idea",
3346
3716
  last_flow_type="analysis_campaign_complete",
3347
3717
  )
@@ -3789,6 +4159,8 @@ class ArtifactService:
3789
4159
  expects_reply: bool | None = None,
3790
4160
  reply_mode: str | None = None,
3791
4161
  options: list[dict[str, Any]] | None = None,
4162
+ surface_actions: list[dict[str, Any]] | None = None,
4163
+ connector_hints: dict[str, Any] | None = None,
3792
4164
  allow_free_text: bool = True,
3793
4165
  reply_schema: dict[str, Any] | None = None,
3794
4166
  reply_to_interaction_id: str | None = None,
@@ -3801,6 +4173,9 @@ class ArtifactService:
3801
4173
  "approval_result": "approval",
3802
4174
  }.get(kind, "progress")
3803
4175
  options_resolved = options or []
4176
+ surface_actions_resolved = [dict(item) for item in (surface_actions or []) if isinstance(item, dict)]
4177
+ connector_hints_resolved = self._normalize_connector_hints(connector_hints)
4178
+ attachments_resolved, attachment_issues = self._normalize_interaction_attachments(quest_root, attachments)
3804
4179
  reply_schema_resolved = reply_schema if isinstance(reply_schema, dict) else {}
3805
4180
  reply_mode_resolved = str(
3806
4181
  reply_mode
@@ -3860,10 +4235,14 @@ class ArtifactService:
3860
4235
  "expects_reply": False,
3861
4236
  "reply_mode": "none",
3862
4237
  "delivered": False,
4238
+ "delivery_results": [],
3863
4239
  "response_phase": response_phase,
3864
4240
  "delivery_targets": [],
3865
4241
  "delivery_policy": self._delivery_policy(self._connectors_config()),
3866
4242
  "preferred_connector": self._preferred_connector(self._connectors_config()),
4243
+ "connector_hints": connector_hints_resolved,
4244
+ "normalized_attachments": attachments_resolved,
4245
+ "attachment_issues": attachment_issues,
3867
4246
  "recent_inbound_messages": mailbox_payload.get("recent_inbound_messages") or [],
3868
4247
  "delivery_batch": mailbox_payload.get("delivery_batch"),
3869
4248
  "recent_interaction_records": mailbox_payload.get("recent_interaction_records") or [],
@@ -3889,11 +4268,13 @@ class ArtifactService:
3889
4268
  "summary": message,
3890
4269
  "interaction_phase": "request" if kind == "decision_request" else response_phase,
3891
4270
  "importance": importance,
3892
- "attachments": attachments or [],
4271
+ "attachments": attachments_resolved,
3893
4272
  "interaction_id": resolved_interaction_id,
3894
4273
  "expects_reply": expects_reply_resolved,
3895
4274
  "reply_mode": reply_mode_resolved,
3896
4275
  "options": options_resolved,
4276
+ "surface_actions": surface_actions_resolved,
4277
+ "connector_hints": connector_hints_resolved,
3897
4278
  "allow_free_text": allow_free_text,
3898
4279
  "reply_schema": reply_schema_resolved,
3899
4280
  "reply_to_interaction_id": reply_to_interaction_id,
@@ -3929,6 +4310,7 @@ class ArtifactService:
3929
4310
  )
3930
4311
  delivery_targets: list[str] = []
3931
4312
  delivered = False
4313
+ delivery_results: list[dict[str, Any]] = []
3932
4314
  if deliver_to_bound_conversations:
3933
4315
  connectors = self._connectors_config()
3934
4316
  targets = self._select_delivery_targets(
@@ -3950,12 +4332,17 @@ class ArtifactService:
3950
4332
  "expects_reply": expects_reply_resolved,
3951
4333
  "reply_mode": reply_mode_resolved,
3952
4334
  "options": options_resolved,
4335
+ "surface_actions": surface_actions_resolved,
4336
+ "connector_hints": connector_hints_resolved,
3953
4337
  "allow_free_text": allow_free_text,
3954
4338
  "reply_schema": reply_schema_resolved,
3955
4339
  "reply_to_interaction_id": reply_to_interaction_id,
3956
- "attachments": attachments or [],
4340
+ "attachments": attachments_resolved,
3957
4341
  }
3958
- if self._send_to_channel(channel_name, payload, connectors=connectors):
4342
+ delivery_result = self._deliver_to_channel(channel_name, payload, connectors=connectors)
4343
+ delivery_result["conversation_id"] = target
4344
+ delivery_results.append(delivery_result)
4345
+ if delivery_result.get("ok", False) or delivery_result.get("queued", False):
3959
4346
  delivery_targets.append(target)
3960
4347
  delivered = True
3961
4348
 
@@ -3985,6 +4372,8 @@ class ArtifactService:
3985
4372
  message=message,
3986
4373
  response_phase=response_phase,
3987
4374
  reply_mode=reply_mode_resolved,
4375
+ surface_actions=surface_actions_resolved,
4376
+ connector_hints=connector_hints_resolved,
3988
4377
  created_at=(artifact.get("record") or {}).get("updated_at"),
3989
4378
  )
3990
4379
 
@@ -3994,7 +4383,12 @@ class ArtifactService:
3994
4383
  "interaction_id": request_state.get("interaction_id"),
3995
4384
  "expects_reply": expects_reply_resolved,
3996
4385
  "reply_mode": reply_mode_resolved,
4386
+ "surface_actions": surface_actions_resolved,
4387
+ "connector_hints": connector_hints_resolved,
4388
+ "normalized_attachments": attachments_resolved,
4389
+ "attachment_issues": attachment_issues,
3997
4390
  "delivered": delivered,
4391
+ "delivery_results": delivery_results,
3998
4392
  "response_phase": response_phase,
3999
4393
  "delivery_targets": delivery_targets,
4000
4394
  "delivery_policy": self._delivery_policy(self._connectors_config()),
@@ -4296,6 +4690,67 @@ class ArtifactService:
4296
4690
  return enabled[0]
4297
4691
  return None
4298
4692
 
4693
+ @staticmethod
4694
+ def _normalize_connector_hints(connector_hints: dict[str, Any] | None) -> dict[str, Any]:
4695
+ if not isinstance(connector_hints, dict):
4696
+ return {}
4697
+ normalized: dict[str, Any] = {}
4698
+ for key, value in connector_hints.items():
4699
+ name = str(key or "").strip().lower()
4700
+ if not name or not isinstance(value, dict):
4701
+ continue
4702
+ normalized[name] = dict(value)
4703
+ return normalized
4704
+
4705
+ def _normalize_interaction_attachments(
4706
+ self,
4707
+ quest_root: Path,
4708
+ attachments: list[dict[str, Any]] | None,
4709
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
4710
+ normalized: list[dict[str, Any]] = []
4711
+ issues: list[dict[str, Any]] = []
4712
+ for index, raw_item in enumerate(attachments or [], start=1):
4713
+ if not isinstance(raw_item, dict):
4714
+ issues.append(
4715
+ {
4716
+ "attachment_index": index,
4717
+ "error": "attachment must be an object",
4718
+ }
4719
+ )
4720
+ continue
4721
+ item = dict(raw_item)
4722
+ path_value = str(item.get("path") or "").strip()
4723
+ if path_value:
4724
+ resolved_path = Path(path_value).expanduser()
4725
+ if not resolved_path.is_absolute():
4726
+ resolved_path = (quest_root / resolved_path).resolve()
4727
+ else:
4728
+ resolved_path = resolved_path.resolve()
4729
+ item["path"] = str(resolved_path)
4730
+ if not resolved_path.exists():
4731
+ item["path_error"] = "path_not_found"
4732
+ issues.append(
4733
+ {
4734
+ "attachment_index": index,
4735
+ "path": str(resolved_path),
4736
+ "error": "attachment path does not exist",
4737
+ }
4738
+ )
4739
+ connector_delivery = item.get("connector_delivery")
4740
+ if isinstance(connector_delivery, dict):
4741
+ normalized_delivery: dict[str, Any] = {}
4742
+ for key, value in connector_delivery.items():
4743
+ name = str(key or "").strip().lower()
4744
+ if not name or not isinstance(value, dict):
4745
+ continue
4746
+ normalized_delivery[name] = dict(value)
4747
+ if normalized_delivery:
4748
+ item["connector_delivery"] = normalized_delivery
4749
+ else:
4750
+ item.pop("connector_delivery", None)
4751
+ normalized.append(item)
4752
+ return normalized, issues
4753
+
4299
4754
  def _select_delivery_targets(self, targets: list[str], *, connectors: dict[str, Any]) -> list[str]:
4300
4755
  if not targets:
4301
4756
  return ["local:default"]
@@ -4445,6 +4900,11 @@ class ArtifactService:
4445
4900
  "transport": transport,
4446
4901
  "response_phase": str(payload.get("response_phase") or "").strip() or None,
4447
4902
  "importance": str(payload.get("importance") or "").strip() or None,
4903
+ "artifact_id": str(payload.get("artifact_id") or "").strip() or None,
4904
+ "interaction_id": str(payload.get("interaction_id") or "").strip() or None,
4905
+ "surface_actions": payload.get("surface_actions") if isinstance(payload.get("surface_actions"), list) else [],
4906
+ "connector_hints": payload.get("connector_hints") if isinstance(payload.get("connector_hints"), dict) else {},
4907
+ "delivery_parts": delivery.get("parts") if isinstance(delivery.get("parts"), list) else [],
4448
4908
  "error": str(result.get("error") or delivery.get("error") or "").strip() or None,
4449
4909
  "created_at": utc_now(),
4450
4910
  },