@researai/deepscientist 1.5.14 → 1.5.15

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 (119) hide show
  1. package/README.md +8 -0
  2. package/assets/branding/logo-raster.png +0 -0
  3. package/bin/ds.js +134 -49
  4. package/docs/en/00_QUICK_START.md +2 -2
  5. package/docs/en/01_SETTINGS_REFERENCE.md +20 -4
  6. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +19 -0
  7. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  8. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
  9. package/docs/en/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  10. package/docs/en/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  11. package/docs/en/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  12. package/docs/en/README.md +6 -0
  13. package/docs/zh/00_QUICK_START.md +2 -2
  14. package/docs/zh/01_SETTINGS_REFERENCE.md +20 -4
  15. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +19 -0
  16. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  17. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
  18. package/docs/zh/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  19. package/docs/zh/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  20. package/docs/zh/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  21. package/docs/zh/README.md +6 -0
  22. package/install.sh +2 -0
  23. package/package.json +1 -1
  24. package/pyproject.toml +1 -1
  25. package/src/deepscientist/__init__.py +1 -1
  26. package/src/deepscientist/artifact/charts.py +567 -0
  27. package/src/deepscientist/artifact/guidance.py +50 -10
  28. package/src/deepscientist/artifact/metrics.py +228 -5
  29. package/src/deepscientist/artifact/schemas.py +3 -0
  30. package/src/deepscientist/artifact/service.py +3534 -191
  31. package/src/deepscientist/bash_exec/models.py +23 -0
  32. package/src/deepscientist/bash_exec/monitor.py +147 -67
  33. package/src/deepscientist/bash_exec/runtime.py +218 -156
  34. package/src/deepscientist/bash_exec/service.py +79 -64
  35. package/src/deepscientist/bash_exec/shells.py +87 -0
  36. package/src/deepscientist/bridges/connectors.py +51 -2
  37. package/src/deepscientist/config/models.py +6 -3
  38. package/src/deepscientist/config/service.py +7 -2
  39. package/src/deepscientist/connector/weixin_support.py +122 -1
  40. package/src/deepscientist/daemon/api/handlers.py +75 -4
  41. package/src/deepscientist/daemon/api/router.py +1 -0
  42. package/src/deepscientist/daemon/app.py +758 -206
  43. package/src/deepscientist/doctor.py +51 -0
  44. package/src/deepscientist/file_lock.py +48 -0
  45. package/src/deepscientist/gitops/diff.py +167 -1
  46. package/src/deepscientist/mcp/server.py +173 -5
  47. package/src/deepscientist/process_control.py +161 -0
  48. package/src/deepscientist/prompts/builder.py +267 -442
  49. package/src/deepscientist/quest/service.py +2255 -163
  50. package/src/deepscientist/quest/stage_views.py +171 -0
  51. package/src/deepscientist/runners/base.py +2 -0
  52. package/src/deepscientist/runners/codex.py +88 -5
  53. package/src/deepscientist/runners/runtime_overrides.py +17 -1
  54. package/src/prompts/contracts/shared_interaction.md +13 -4
  55. package/src/prompts/system.md +916 -72
  56. package/src/skills/analysis-campaign/SKILL.md +31 -2
  57. package/src/skills/analysis-campaign/references/artifact-orchestration.md +1 -1
  58. package/src/skills/analysis-campaign/references/writing-facing-slice-examples.md +65 -0
  59. package/src/skills/baseline/SKILL.md +2 -0
  60. package/src/skills/decision/SKILL.md +19 -2
  61. package/src/skills/experiment/SKILL.md +8 -2
  62. package/src/skills/finalize/SKILL.md +18 -0
  63. package/src/skills/idea/SKILL.md +78 -0
  64. package/src/skills/idea/references/idea-generation-playbook.md +100 -0
  65. package/src/skills/idea/references/outline-seeding-example.md +60 -0
  66. package/src/skills/intake-audit/SKILL.md +1 -1
  67. package/src/skills/optimize/SKILL.md +1644 -0
  68. package/src/skills/rebuttal/SKILL.md +2 -1
  69. package/src/skills/review/SKILL.md +2 -1
  70. package/src/skills/write/SKILL.md +80 -12
  71. package/src/skills/write/references/outline-evidence-contract-example.md +107 -0
  72. package/src/tui/dist/app/AppContainer.js +3 -0
  73. package/src/tui/package.json +1 -1
  74. package/src/ui/dist/assets/{AiManusChatView-DaF9Nge_.js → AiManusChatView-DDjbFnbt.js} +12 -12
  75. package/src/ui/dist/assets/{AnalysisPlugin-BSVx6dXE.js → AnalysisPlugin-Yb5IdmaU.js} +1 -1
  76. package/src/ui/dist/assets/CliPlugin-e64sreyu.js +31037 -0
  77. package/src/ui/dist/assets/{CodeEditorPlugin-DU9G0Tox.js → CodeEditorPlugin-C4D2TIkU.js} +8 -8
  78. package/src/ui/dist/assets/{CodeViewerPlugin-DoX_fI9l.js → CodeViewerPlugin-BVoNZIvC.js} +5 -5
  79. package/src/ui/dist/assets/{DocViewerPlugin-C4FWIXuU.js → DocViewerPlugin-CLChbllo.js} +3 -3
  80. package/src/ui/dist/assets/{GitDiffViewerPlugin-BgfFMgtf.js → GitDiffViewerPlugin-C4xeFyFQ.js} +20 -20
  81. package/src/ui/dist/assets/{ImageViewerPlugin-tcPkfY_x.js → ImageViewerPlugin-OiMUAcLi.js} +5 -5
  82. package/src/ui/dist/assets/{LabCopilotPanel-_dKV60Bf.js → LabCopilotPanel-BjD2ThQF.js} +11 -11
  83. package/src/ui/dist/assets/{LabPlugin-Bje0ayoC.js → LabPlugin-DQPg-NrB.js} +2 -2
  84. package/src/ui/dist/assets/{LatexPlugin-CVsBzAln.js → LatexPlugin-CI05XAV9.js} +7 -7
  85. package/src/ui/dist/assets/{MarkdownViewerPlugin-xjmrqv_8.js → MarkdownViewerPlugin-DpeBLYZf.js} +4 -4
  86. package/src/ui/dist/assets/{MarketplacePlugin-mMM2A8wP.js → MarketplacePlugin-DolE58Q2.js} +3 -3
  87. package/src/ui/dist/assets/{NotebookEditor-3kVDSOBo.js → NotebookEditor-7Qm2rSWD.js} +11 -11
  88. package/src/ui/dist/assets/{NotebookEditor-SoJ8X-MO.js → NotebookEditor-C1kWaxKi.js} +1 -1
  89. package/src/ui/dist/assets/{PdfLoader-DElVuHl9.js → PdfLoader-BfOHw8Zw.js} +1 -1
  90. package/src/ui/dist/assets/{PdfMarkdownPlugin-Bq88XT4G.js → PdfMarkdownPlugin-BulDREv1.js} +2 -2
  91. package/src/ui/dist/assets/{PdfViewerPlugin-CsCXMo9S.js → PdfViewerPlugin-C-daaOaL.js} +10 -10
  92. package/src/ui/dist/assets/{SearchPlugin-oUPvy19k.js → SearchPlugin-CjpaiJ3A.js} +1 -1
  93. package/src/ui/dist/assets/{TextViewerPlugin-CRkT9yNy.js → TextViewerPlugin-BxIyqPQC.js} +5 -5
  94. package/src/ui/dist/assets/{VNCViewer-BgbuvWhR.js → VNCViewer-HAg9mF7M.js} +10 -10
  95. package/src/ui/dist/assets/{bot-v_RASACv.js → bot-0DYntytV.js} +1 -1
  96. package/src/ui/dist/assets/{code-5hC9d0VH.js → code-B20Slj_w.js} +1 -1
  97. package/src/ui/dist/assets/{file-content-D1PxfOrp.js → file-content-DT24KFma.js} +1 -1
  98. package/src/ui/dist/assets/{file-diff-panel-DG1oT_Hj.js → file-diff-panel-DK13YPql.js} +1 -1
  99. package/src/ui/dist/assets/{file-socket-BmdFYQlk.js → file-socket-B4T2o4nR.js} +1 -1
  100. package/src/ui/dist/assets/{image-Dqe2X2tW.js → image-DSeR_sDS.js} +1 -1
  101. package/src/ui/dist/assets/{index-RDlNXXx1.js → index-BrFje2Uk.js} +2 -2
  102. package/src/ui/dist/assets/{index-DVsMKK_y.js → index-BwRJaoTl.js} +1 -1
  103. package/src/ui/dist/assets/{index-Nt9hS4ck.js → index-D_E4281X.js} +5007 -28514
  104. package/src/ui/dist/assets/{index-Duvz8Ip0.js → index-DnYB3xb1.js} +12 -12
  105. package/src/ui/dist/assets/{index-BQG-1s2o.css → index-G7AcWcMu.css} +43 -2
  106. package/src/ui/dist/assets/{monaco-DIXge1CP.js → monaco-LExaAN3Y.js} +1 -1
  107. package/src/ui/dist/assets/{pdf-effect-queue-BBTTQaO-.js → pdf-effect-queue-BJk5okWJ.js} +1 -1
  108. package/src/ui/dist/assets/{popover-BWlolyxo.js → popover-D3Gg_FoV.js} +1 -1
  109. package/src/ui/dist/assets/{project-sync-BM5PkFH4.js → project-sync-C_ygLlVU.js} +1 -1
  110. package/src/ui/dist/assets/{select-D4dAtrA8.js → select-CpAK6uWm.js} +2 -2
  111. package/src/ui/dist/assets/{sigma-CKbE5jJT.js → sigma-DEccaSgk.js} +1 -1
  112. package/src/ui/dist/assets/{square-check-big-CZNGMgiB.js → square-check-big-uUfyVsbD.js} +1 -1
  113. package/src/ui/dist/assets/{trash-DaB37xAz.js → trash-CXvwwSe8.js} +1 -1
  114. package/src/ui/dist/assets/{useCliAccess-C2OmAcWe.js → useCliAccess-Bnop4mgR.js} +1 -1
  115. package/src/ui/dist/assets/{useFileDiffOverlay-Dowd1Ij4.js → useFileDiffOverlay-B8eUAX0I.js} +1 -1
  116. package/src/ui/dist/assets/{wrap-text-BGjAhAUq.js → wrap-text-9vbOBpkW.js} +1 -1
  117. package/src/ui/dist/assets/{zoom-out-dMZQMXzc.js → zoom-out-BgVMmOW4.js} +1 -1
  118. package/src/ui/dist/index.html +2 -2
  119. package/src/ui/dist/assets/CliPlugin-C9gzJX41.js +0 -5905
@@ -13,7 +13,7 @@ from urllib.request import Request
13
13
 
14
14
  from .. import __version__ as DEEPSCIENTIST_VERSION
15
15
  from ..network import urlopen_with_proxy as urlopen
16
- from ..shared import ensure_dir, read_json, write_json
16
+ from ..shared import ensure_dir, read_json, utc_now, write_json
17
17
 
18
18
  DEFAULT_WEIXIN_BASE_URL = "https://ilinkai.weixin.qq.com"
19
19
  DEFAULT_WEIXIN_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
@@ -611,6 +611,22 @@ def save_weixin_context_tokens(root: Path, items: dict[str, dict[str, Any]]) ->
611
611
  write_json(weixin_context_tokens_path(root), {"tokens": items})
612
612
 
613
613
 
614
+ def _weixin_replay_cursor(value: Any) -> int:
615
+ try:
616
+ return max(0, int(value or 0))
617
+ except (TypeError, ValueError):
618
+ return 0
619
+
620
+
621
+ def get_weixin_context_entry(root: Path, user_id: str) -> dict[str, Any]:
622
+ normalized_user_id = str(user_id or "").strip()
623
+ if not normalized_user_id:
624
+ return {}
625
+ items = load_weixin_context_tokens(root)
626
+ current = items.get(normalized_user_id)
627
+ return dict(current) if isinstance(current, dict) else {}
628
+
629
+
614
630
  def remember_weixin_context_token(
615
631
  root: Path,
616
632
  *,
@@ -635,6 +651,16 @@ def remember_weixin_context_token(
635
651
  "conversation_id": str(conversation_id or current.get("conversation_id") or "").strip() or None,
636
652
  "message_id": str(message_id or current.get("message_id") or "").strip() or None,
637
653
  "updated_at": str(updated_at or current.get("updated_at") or "").strip() or None,
654
+ "stale_context": False,
655
+ "stale_since": None,
656
+ "last_ret_minus_2_at": None,
657
+ "last_outbound_error": None,
658
+ "last_outbound_kind": None,
659
+ "queued_replay_cursor": _weixin_replay_cursor(current.get("queued_replay_cursor")),
660
+ "last_replay_at": str(current.get("last_replay_at") or "").strip() or None,
661
+ "last_replay_trigger_message_id": str(current.get("last_replay_trigger_message_id") or "").strip() or None,
662
+ "last_replayed_count": _weixin_replay_cursor(current.get("last_replayed_count")) or None,
663
+ "last_replay_dropped_count": _weixin_replay_cursor(current.get("last_replay_dropped_count")) or None,
638
664
  }
639
665
  save_weixin_context_tokens(root, items)
640
666
 
@@ -648,6 +674,101 @@ def get_weixin_context_token(root: Path, user_id: str) -> str | None:
648
674
  return token or None
649
675
 
650
676
 
677
+ def get_weixin_replay_cursor(root: Path, user_id: str) -> int:
678
+ entry = get_weixin_context_entry(root, user_id)
679
+ return _weixin_replay_cursor(entry.get("queued_replay_cursor"))
680
+
681
+
682
+ def update_weixin_replay_cursor(
683
+ root: Path,
684
+ *,
685
+ user_id: str,
686
+ queued_replay_cursor: int,
687
+ last_replay_at: str | None = None,
688
+ last_replay_trigger_message_id: str | None = None,
689
+ last_replayed_count: int | None = None,
690
+ last_replay_dropped_count: int | None = None,
691
+ ) -> dict[str, Any]:
692
+ normalized_user_id = str(user_id or "").strip()
693
+ if not normalized_user_id:
694
+ return {}
695
+ items = load_weixin_context_tokens(root)
696
+ current = dict(items.get(normalized_user_id) or {})
697
+ current.update(
698
+ {
699
+ "user_id": normalized_user_id,
700
+ "queued_replay_cursor": _weixin_replay_cursor(queued_replay_cursor),
701
+ "last_replay_at": str(last_replay_at or utc_now()).strip() or utc_now(),
702
+ "last_replay_trigger_message_id": str(last_replay_trigger_message_id or "").strip() or None,
703
+ "last_replayed_count": _weixin_replay_cursor(last_replayed_count) if last_replayed_count is not None else None,
704
+ "last_replay_dropped_count": _weixin_replay_cursor(last_replay_dropped_count)
705
+ if last_replay_dropped_count is not None
706
+ else None,
707
+ }
708
+ )
709
+ items[normalized_user_id] = current
710
+ save_weixin_context_tokens(root, items)
711
+ return current
712
+
713
+
714
+ def mark_weixin_context_stale(
715
+ root: Path,
716
+ *,
717
+ user_id: str,
718
+ error: str,
719
+ kind: str | None = None,
720
+ updated_at: str | None = None,
721
+ ) -> dict[str, Any]:
722
+ normalized_user_id = str(user_id or "").strip()
723
+ if not normalized_user_id:
724
+ return {}
725
+ timestamp = str(updated_at or utc_now()).strip() or utc_now()
726
+ items = load_weixin_context_tokens(root)
727
+ current = dict(items.get(normalized_user_id) or {})
728
+ current.update(
729
+ {
730
+ "user_id": normalized_user_id,
731
+ "stale_context": True,
732
+ "stale_since": str(current.get("stale_since") or "").strip() or timestamp,
733
+ "last_ret_minus_2_at": timestamp,
734
+ "last_outbound_error": str(error or "").strip() or None,
735
+ "last_outbound_kind": str(kind or "").strip() or None,
736
+ }
737
+ )
738
+ items[normalized_user_id] = current
739
+ save_weixin_context_tokens(root, items)
740
+ return current
741
+
742
+
743
+ def clear_weixin_context_send_state(
744
+ root: Path,
745
+ *,
746
+ user_id: str,
747
+ kind: str | None = None,
748
+ updated_at: str | None = None,
749
+ ) -> dict[str, Any]:
750
+ normalized_user_id = str(user_id or "").strip()
751
+ if not normalized_user_id:
752
+ return {}
753
+ timestamp = str(updated_at or utc_now()).strip() or utc_now()
754
+ items = load_weixin_context_tokens(root)
755
+ current = dict(items.get(normalized_user_id) or {})
756
+ current.update(
757
+ {
758
+ "user_id": normalized_user_id,
759
+ "stale_context": False,
760
+ "stale_since": None,
761
+ "last_ret_minus_2_at": None,
762
+ "last_outbound_error": None,
763
+ "last_outbound_kind": str(kind or "").strip() or None,
764
+ "last_success_at": timestamp,
765
+ }
766
+ )
767
+ items[normalized_user_id] = current
768
+ save_weixin_context_tokens(root, items)
769
+ return current
770
+
771
+
651
772
  def weixin_sync_state_path(root: Path) -> Path:
652
773
  return ensure_dir(root) / "sync_state.json"
653
774
 
@@ -11,7 +11,7 @@ from urllib.parse import parse_qs, unquote
11
11
  from ...acp import OptionalACPBridge, build_session_descriptor, build_session_update, get_acp_bridge_status
12
12
  from ...bash_exec.service import DEFAULT_TERMINAL_SESSION_ID
13
13
  from ... import __version__ as DEEPSCIENTIST_VERSION
14
- from ...gitops import commit_detail, compare_refs, diff_file_between_refs, diff_file_for_commit, export_git_graph, list_branch_canvas, log_ref_history
14
+ from ...gitops import commit_detail, compare_refs, diff_file_between_refs, diff_file_for_commit, export_git_graph, log_ref_history
15
15
  from ...memory import MemoryService
16
16
  from ...quest import QuestService
17
17
  from ...shared import generate_id, read_json, read_text, resolve_within, run_command, sha256_text, utc_now
@@ -474,6 +474,11 @@ npm --prefix src/ui run build</pre>
474
474
 
475
475
  def quest_session(self, quest_id: str) -> dict:
476
476
  snapshot = self.app.quest_service.snapshot_fast(quest_id)
477
+ for kind in ("details", "canvas"):
478
+ try:
479
+ self.app.quest_service.prime_projection(quest_id, kind)
480
+ except Exception:
481
+ continue
477
482
  return {
478
483
  "ok": True,
479
484
  "quest_id": quest_id,
@@ -768,7 +773,20 @@ npm --prefix src/ui run build</pre>
768
773
  return self.app.control_quest(quest_id, action=action, source=source)
769
774
 
770
775
  def workflow(self, quest_id: str) -> dict:
771
- return self.app.quest_service.workflow(quest_id)
776
+ payload = self.app.quest_service.workflow(quest_id)
777
+ projection_state = str(((payload or {}).get("projection_status") or {}).get("state") or "").strip().lower()
778
+ if projection_state and projection_state != "ready":
779
+ if isinstance(payload, dict):
780
+ payload["optimization_frontier"] = None
781
+ return payload
782
+ quest_root = self._fresh_quest_service()._quest_root(quest_id)
783
+ try:
784
+ frontier = self.app.artifact_service.get_optimization_frontier(quest_root)
785
+ except Exception:
786
+ frontier = {"ok": False}
787
+ if isinstance(payload, dict):
788
+ payload["optimization_frontier"] = frontier.get("optimization_frontier") if isinstance(frontier, dict) else None
789
+ return payload
772
790
 
773
791
  def quest_layout(self, quest_id: str) -> dict:
774
792
  quest_root = self._fresh_quest_service()._quest_root(quest_id)
@@ -816,15 +834,21 @@ npm --prefix src/ui run build</pre>
816
834
  def metrics_timeline(self, quest_id: str) -> dict:
817
835
  return self.app.quest_service.metrics_timeline(quest_id)
818
836
 
837
+ def baseline_compare(self, quest_id: str) -> dict:
838
+ return self.app.quest_service.baseline_compare(quest_id)
839
+
819
840
  def git_branches(self, quest_id: str) -> dict:
820
841
  quest_root = self._fresh_quest_service()._quest_root(quest_id)
821
- payload = list_branch_canvas(quest_root, quest_id=quest_id)
842
+ payload = self.app.quest_service.git_branch_canvas(quest_id)
822
843
  research_state = self.app.quest_service.read_research_state(quest_root)
823
844
  active_workspace_branch = str(research_state.get("current_workspace_branch") or "").strip() or None
824
845
  research_head_branch = str(research_state.get("research_head_branch") or "").strip() or None
825
846
  payload["active_workspace_ref"] = active_workspace_branch
826
847
  payload["research_head_ref"] = research_head_branch
827
848
  payload["workspace_mode"] = str(research_state.get("workspace_mode") or "quest").strip() or "quest"
849
+ projection_state = str(((payload or {}).get("projection_status") or {}).get("state") or "").strip().lower()
850
+ if projection_state and projection_state != "ready" and not (payload.get("nodes") or []):
851
+ return payload
828
852
  quest_data = self.app.quest_service.read_quest_yaml(quest_root)
829
853
  active_anchor = str(quest_data.get("active_anchor") or "").strip().lower()
830
854
  active_analysis_campaign_id = str(research_state.get("active_analysis_campaign_id") or "").strip() or None
@@ -837,11 +861,40 @@ npm --prefix src/ui run build</pre>
837
861
  branch_summary = self.app.artifact_service.list_research_branches(quest_root)
838
862
  except Exception:
839
863
  branch_summary = {"branches": []}
864
+ try:
865
+ optimization_frontier = self.app.artifact_service.get_optimization_frontier(quest_root)
866
+ except Exception:
867
+ optimization_frontier = {"ok": False}
840
868
  branch_summary_by_name = {
841
869
  str(item.get("branch_name") or "").strip(): item
842
870
  for item in (branch_summary.get("branches") or [])
843
871
  if str(item.get("branch_name") or "").strip()
844
872
  }
873
+ frontier_payload = (
874
+ dict(optimization_frontier.get("optimization_frontier") or {})
875
+ if isinstance(optimization_frontier, dict)
876
+ and isinstance(optimization_frontier.get("optimization_frontier"), dict)
877
+ else {}
878
+ )
879
+ best_branch_name = str(((frontier_payload.get("best_branch") or {}) if isinstance(frontier_payload.get("best_branch"), dict) else {}).get("branch_name") or "").strip() or None
880
+ stagnant_branch_names = {
881
+ str(item.get("branch_name") or "").strip()
882
+ for item in (frontier_payload.get("stagnant_branches") or [])
883
+ if isinstance(item, dict) and str(item.get("branch_name") or "").strip()
884
+ }
885
+ fusion_candidate_names = {
886
+ str(item.get("branch_name") or "").strip()
887
+ for item in (frontier_payload.get("fusion_candidates") or [])
888
+ if isinstance(item, dict) and str(item.get("branch_name") or "").strip()
889
+ }
890
+ candidate_count_by_branch: dict[str, int] = {}
891
+ for item in frontier_payload.get("implementation_candidates") or []:
892
+ if not isinstance(item, dict):
893
+ continue
894
+ branch_name = str(item.get("branch") or "").strip()
895
+ if not branch_name:
896
+ continue
897
+ candidate_count_by_branch[branch_name] = candidate_count_by_branch.get(branch_name, 0) + 1
845
898
  active_campaign = {}
846
899
  if active_analysis_campaign_id:
847
900
  try:
@@ -856,6 +909,11 @@ npm --prefix src/ui run build</pre>
856
909
  if isinstance(active_campaign, dict)
857
910
  else None
858
911
  )
912
+ campaign_paper_line_branch = (
913
+ str(active_campaign.get("paper_line_branch") or "").strip() or None
914
+ if isinstance(active_campaign, dict)
915
+ else None
916
+ )
859
917
  campaign_slices = [
860
918
  dict(item)
861
919
  for item in ((active_campaign or {}).get("slices") or [])
@@ -903,6 +961,14 @@ npm --prefix src/ui run build</pre>
903
961
  workflow_state["status_reason"] = "Analysis slice pending."
904
962
  return workflow_state
905
963
  if branch_kind == "paper":
964
+ if campaign_paper_line_branch and ref == campaign_paper_line_branch and next_pending_slice_id is not None:
965
+ workflow_state["analysis_state"] = "active"
966
+ workflow_state["writing_state"] = "blocked_by_analysis"
967
+ workflow_state["status_reason"] = (
968
+ f"Analysis {campaign_completed_slices}/{campaign_total_slices} done"
969
+ + (f" · next: {next_pending_slice_id}" if next_pending_slice_id else "")
970
+ )
971
+ return workflow_state
906
972
  if ref == current_workspace_branch and workspace_mode == "paper":
907
973
  workflow_state["writing_state"] = "completed" if active_anchor == "finalize" else "active"
908
974
  workflow_state["status_reason"] = (
@@ -912,7 +978,7 @@ npm --prefix src/ui run build</pre>
912
978
  workflow_state["writing_state"] = "ready"
913
979
  workflow_state["status_reason"] = "Writing workspace prepared."
914
980
  return workflow_state
915
- if campaign_parent_branch and ref == campaign_parent_branch:
981
+ if campaign_parent_branch and not campaign_paper_line_branch and ref == campaign_parent_branch:
916
982
  workflow_state["analysis_state"] = "completed" if next_pending_slice_id is None else "active"
917
983
  if has_main_result:
918
984
  workflow_state["writing_state"] = "ready" if next_pending_slice_id is None else "blocked_by_analysis"
@@ -955,6 +1021,11 @@ npm --prefix src/ui run build</pre>
955
1021
  node["latest_main_experiment"] = summary.get("latest_main_experiment")
956
1022
  node["experiment_count"] = summary.get("experiment_count")
957
1023
  node["has_main_result"] = summary.get("has_main_result")
1024
+ node["optimization_mode"] = frontier_payload.get("mode")
1025
+ node["optimization_best"] = ref == best_branch_name
1026
+ node["optimization_stagnant"] = ref in stagnant_branch_names
1027
+ node["optimization_fusion_candidate"] = ref in fusion_candidate_names
1028
+ node["optimization_candidate_count"] = candidate_count_by_branch.get(ref, 0)
958
1029
  return payload
959
1030
 
960
1031
  def git_log(self, quest_id: str, path: str) -> dict:
@@ -61,6 +61,7 @@ ROUTES: list[tuple[str, re.Pattern[str], str]] = [
61
61
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/graph$"), "graph"),
62
62
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/graph/(?P<kind>svg|png|json)$"), "graph_asset"),
63
63
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/metrics/timeline$"), "metrics_timeline"),
64
+ ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/baselines/compare$"), "baseline_compare"),
64
65
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/git/branches$"), "git_branches"),
65
66
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/git/log$"), "git_log"),
66
67
  ("GET", re.compile(r"^/api/quests/(?P<quest_id>[^/]+)/git/compare$"), "git_compare"),