@researai/deepscientist 1.5.15 → 1.5.17

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 (202) hide show
  1. package/README.md +385 -104
  2. package/bin/ds.js +1241 -110
  3. package/docs/en/00_QUICK_START.md +100 -19
  4. package/docs/en/01_SETTINGS_REFERENCE.md +34 -1
  5. package/docs/en/02_START_RESEARCH_GUIDE.md +7 -0
  6. package/docs/en/05_TUI_GUIDE.md +6 -0
  7. package/docs/en/06_RUNTIME_AND_CANVAS.md +4 -3
  8. package/docs/en/09_DOCTOR.md +25 -8
  9. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +63 -13
  10. package/docs/en/15_CODEX_PROVIDER_SETUP.md +37 -11
  11. package/docs/en/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
  12. package/docs/en/19_LOCAL_BROWSER_AUTH.md +70 -0
  13. package/docs/en/20_WORKSPACE_MODES_GUIDE.md +250 -0
  14. package/docs/en/21_LOCAL_MODEL_BACKENDS_GUIDE.md +283 -0
  15. package/docs/en/91_DEVELOPMENT.md +237 -0
  16. package/docs/en/README.md +24 -2
  17. package/docs/zh/00_QUICK_START.md +89 -19
  18. package/docs/zh/01_SETTINGS_REFERENCE.md +34 -1
  19. package/docs/zh/02_START_RESEARCH_GUIDE.md +7 -0
  20. package/docs/zh/05_TUI_GUIDE.md +6 -0
  21. package/docs/zh/09_DOCTOR.md +26 -9
  22. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +63 -13
  23. package/docs/zh/15_CODEX_PROVIDER_SETUP.md +37 -11
  24. package/docs/zh/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
  25. package/docs/zh/19_LOCAL_BROWSER_AUTH.md +68 -0
  26. package/docs/zh/20_WORKSPACE_MODES_GUIDE.md +251 -0
  27. package/docs/zh/21_LOCAL_MODEL_BACKENDS_GUIDE.md +281 -0
  28. package/docs/zh/README.md +24 -2
  29. package/install.sh +46 -4
  30. package/package.json +2 -1
  31. package/pyproject.toml +1 -1
  32. package/src/deepscientist/__init__.py +1 -1
  33. package/src/deepscientist/acp/envelope.py +6 -0
  34. package/src/deepscientist/artifact/service.py +647 -22
  35. package/src/deepscientist/bash_exec/service.py +234 -9
  36. package/src/deepscientist/bridges/connectors.py +8 -2
  37. package/src/deepscientist/cli.py +115 -19
  38. package/src/deepscientist/codex_cli_compat.py +367 -22
  39. package/src/deepscientist/config/models.py +2 -1
  40. package/src/deepscientist/config/service.py +183 -13
  41. package/src/deepscientist/daemon/api/handlers.py +255 -31
  42. package/src/deepscientist/daemon/api/router.py +9 -0
  43. package/src/deepscientist/daemon/app.py +1146 -105
  44. package/src/deepscientist/diagnostics/__init__.py +6 -0
  45. package/src/deepscientist/diagnostics/runner_failures.py +130 -0
  46. package/src/deepscientist/doctor.py +207 -3
  47. package/src/deepscientist/gitops/__init__.py +10 -1
  48. package/src/deepscientist/gitops/diff.py +129 -0
  49. package/src/deepscientist/gitops/service.py +4 -1
  50. package/src/deepscientist/mcp/server.py +39 -0
  51. package/src/deepscientist/prompts/builder.py +275 -34
  52. package/src/deepscientist/quest/layout.py +15 -2
  53. package/src/deepscientist/quest/service.py +707 -55
  54. package/src/deepscientist/quest/stage_views.py +6 -1
  55. package/src/deepscientist/runners/codex.py +143 -43
  56. package/src/deepscientist/shared.py +19 -0
  57. package/src/deepscientist/skills/__init__.py +2 -2
  58. package/src/deepscientist/skills/installer.py +196 -5
  59. package/src/deepscientist/skills/registry.py +66 -0
  60. package/src/prompts/connectors/qq.md +18 -8
  61. package/src/prompts/connectors/weixin.md +16 -6
  62. package/src/prompts/contracts/shared_interaction.md +14 -2
  63. package/src/prompts/system.md +23 -5
  64. package/src/prompts/system_copilot.md +56 -0
  65. package/src/skills/analysis-campaign/SKILL.md +1 -0
  66. package/src/skills/baseline/SKILL.md +8 -0
  67. package/src/skills/decision/SKILL.md +8 -0
  68. package/src/skills/experiment/SKILL.md +8 -0
  69. package/src/skills/figure-polish/SKILL.md +1 -0
  70. package/src/skills/finalize/SKILL.md +1 -0
  71. package/src/skills/idea/SKILL.md +1 -0
  72. package/src/skills/intake-audit/SKILL.md +8 -0
  73. package/src/skills/mentor/SKILL.md +217 -0
  74. package/src/skills/mentor/references/correction-rules.md +210 -0
  75. package/src/skills/mentor/references/knowledge-profile.md +91 -0
  76. package/src/skills/mentor/references/persona-profile.md +138 -0
  77. package/src/skills/mentor/references/taste-profile.md +128 -0
  78. package/src/skills/mentor/references/thought-style-profile.md +138 -0
  79. package/src/skills/mentor/references/work-profile.md +289 -0
  80. package/src/skills/mentor/references/workflow-profile.md +240 -0
  81. package/src/skills/optimize/SKILL.md +1 -0
  82. package/src/skills/rebuttal/SKILL.md +1 -0
  83. package/src/skills/review/SKILL.md +1 -0
  84. package/src/skills/scout/SKILL.md +8 -0
  85. package/src/skills/write/SKILL.md +1 -0
  86. package/src/tui/dist/app/AppContainer.js +19 -11
  87. package/src/tui/dist/index.js +4 -1
  88. package/src/tui/dist/lib/api.js +33 -3
  89. package/src/tui/package.json +1 -1
  90. package/src/ui/dist/assets/AiManusChatView-Bv-Z8YpU.js +204 -0
  91. package/src/ui/dist/assets/AnalysisPlugin-BCKAfjba.js +1 -0
  92. package/src/ui/dist/assets/CliPlugin-BCKcpc35.js +109 -0
  93. package/src/ui/dist/assets/CodeEditorPlugin-DbOfSJ8K.js +2 -0
  94. package/src/ui/dist/assets/CodeViewerPlugin-CbaFRrUU.js +270 -0
  95. package/src/ui/dist/assets/DocViewerPlugin-DAjLVeQD.js +7 -0
  96. package/src/ui/dist/assets/GitCommitViewerPlugin-CIUqbUDO.js +1 -0
  97. package/src/ui/dist/assets/GitDiffViewerPlugin-CQACjoAA.js +6 -0
  98. package/src/ui/dist/assets/GitSnapshotViewer-0r4nLPke.js +30 -0
  99. package/src/ui/dist/assets/ImageViewerPlugin-nBOmI2v_.js +26 -0
  100. package/src/ui/dist/assets/LabCopilotPanel-BHxOxF4z.js +14 -0
  101. package/src/ui/dist/assets/LabPlugin-BKoZGs95.js +22 -0
  102. package/src/ui/dist/assets/LatexPlugin-ZwtV8pIp.js +25 -0
  103. package/src/ui/dist/assets/MarkdownViewerPlugin-DKqVfKyW.js +128 -0
  104. package/src/ui/dist/assets/MarketplacePlugin-BwxStZ9D.js +13 -0
  105. package/src/ui/dist/assets/NotebookEditor-BEQhaQbt.js +81 -0
  106. package/src/ui/dist/assets/{NotebookEditor-CccQYZjX.css → NotebookEditor-BHH8rdGj.css} +1 -1
  107. package/src/ui/dist/assets/NotebookEditor-BOr3x3Ej.css +1 -0
  108. package/src/ui/dist/assets/NotebookEditor-DB9N_T9q.js +361 -0
  109. package/src/ui/dist/assets/PdfLoader-Cy5jtWrr.css +1 -0
  110. package/src/ui/dist/assets/PdfLoader-eWBONbQP.js +16 -0
  111. package/src/ui/dist/assets/PdfMarkdownPlugin-D22YOZL3.js +1 -0
  112. package/src/ui/dist/assets/PdfViewerPlugin-c-RK9DLM.js +17 -0
  113. package/src/ui/dist/assets/PdfViewerPlugin-nwwE-fjJ.css +1 -0
  114. package/src/ui/dist/assets/SearchPlugin-CxF9ytAx.js +16 -0
  115. package/src/ui/dist/assets/SearchPlugin-DA4en4hK.css +1 -0
  116. package/src/ui/dist/assets/TextViewerPlugin-C5xqeeUH.js +54 -0
  117. package/src/ui/dist/assets/VNCViewer-BoLGLnHz.js +11 -0
  118. package/src/ui/dist/assets/bot-DREQOxzP.js +6 -0
  119. package/src/ui/dist/assets/browser-CTB2jwNe.js +8 -0
  120. package/src/ui/dist/assets/chevron-up-C9Qpx4DE.js +6 -0
  121. package/src/ui/dist/assets/code-WlFHE7z_.js +6 -0
  122. package/src/ui/dist/assets/file-content-BZMz3RYp.js +1 -0
  123. package/src/ui/dist/assets/file-diff-panel-CQhw0jS2.js +1 -0
  124. package/src/ui/dist/assets/file-jump-queue-DA-SdG__.js +1 -0
  125. package/src/ui/dist/assets/file-socket-CfQPKQKj.js +1 -0
  126. package/src/ui/dist/assets/git-commit-horizontal-DxZ8DCZh.js +6 -0
  127. package/src/ui/dist/assets/image-Bgl4VIyx.js +6 -0
  128. package/src/ui/dist/assets/index-BpV6lusQ.css +33 -0
  129. package/src/ui/dist/assets/index-CBNVuWcP.js +2496 -0
  130. package/src/ui/dist/assets/index-CwNu1aH4.js +11 -0
  131. package/src/ui/dist/assets/index-DrUnlf6K.js +1 -0
  132. package/src/ui/dist/assets/index-NW-h8VzN.js +1 -0
  133. package/src/ui/dist/assets/monaco-CiHMMNH_.js +1 -0
  134. package/src/ui/dist/assets/pdf-effect-queue-J8OnM0jE.js +6 -0
  135. package/src/ui/dist/assets/plugin-monaco-C8UgLomw.js +19 -0
  136. package/src/ui/dist/assets/plugin-notebook-HbW2K-1c.js +169 -0
  137. package/src/ui/dist/assets/plugin-pdf-CR8hgQBV.js +357 -0
  138. package/src/ui/dist/assets/plugin-terminal-MXFIPun8.js +227 -0
  139. package/src/ui/dist/assets/popover-CLc0pPP8.js +1 -0
  140. package/src/ui/dist/assets/project-sync-C9IdzdZW.js +1 -0
  141. package/src/ui/dist/assets/select-Cs2PmzwL.js +11 -0
  142. package/src/ui/dist/assets/sigma-ClKcHAXm.js +6 -0
  143. package/src/ui/dist/assets/trash-DwpbFr3w.js +11 -0
  144. package/src/ui/dist/assets/useCliAccess-NQ8m0Let.js +1 -0
  145. package/src/ui/dist/assets/useFileDiffOverlay-FuhcnKiw.js +1 -0
  146. package/src/ui/dist/assets/wrap-text-BC-Hltpd.js +11 -0
  147. package/src/ui/dist/assets/zoom-out-E_gaeAxL.js +11 -0
  148. package/src/ui/dist/index.html +5 -2
  149. package/src/ui/dist/assets/AiManusChatView-DDjbFnbt.js +0 -26597
  150. package/src/ui/dist/assets/AnalysisPlugin-Yb5IdmaU.js +0 -123
  151. package/src/ui/dist/assets/CliPlugin-e64sreyu.js +0 -31037
  152. package/src/ui/dist/assets/CodeEditorPlugin-C4D2TIkU.js +0 -427
  153. package/src/ui/dist/assets/CodeViewerPlugin-BVoNZIvC.js +0 -905
  154. package/src/ui/dist/assets/DocViewerPlugin-CLChbllo.js +0 -278
  155. package/src/ui/dist/assets/GitDiffViewerPlugin-C4xeFyFQ.js +0 -2661
  156. package/src/ui/dist/assets/ImageViewerPlugin-OiMUAcLi.js +0 -500
  157. package/src/ui/dist/assets/LabCopilotPanel-BjD2ThQF.js +0 -4104
  158. package/src/ui/dist/assets/LabPlugin-DQPg-NrB.js +0 -2677
  159. package/src/ui/dist/assets/LatexPlugin-CI05XAV9.js +0 -1792
  160. package/src/ui/dist/assets/MarkdownViewerPlugin-DpeBLYZf.js +0 -308
  161. package/src/ui/dist/assets/MarketplacePlugin-DolE58Q2.js +0 -413
  162. package/src/ui/dist/assets/NotebookEditor-7Qm2rSWD.js +0 -4214
  163. package/src/ui/dist/assets/NotebookEditor-C1kWaxKi.js +0 -84873
  164. package/src/ui/dist/assets/NotebookEditor-C3VQ7ylN.css +0 -1405
  165. package/src/ui/dist/assets/PdfLoader-BfOHw8Zw.js +0 -25468
  166. package/src/ui/dist/assets/PdfLoader-C-Y707R3.css +0 -49
  167. package/src/ui/dist/assets/PdfMarkdownPlugin-BulDREv1.js +0 -409
  168. package/src/ui/dist/assets/PdfViewerPlugin-C-daaOaL.js +0 -3095
  169. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +0 -3627
  170. package/src/ui/dist/assets/SearchPlugin-CjpaiJ3A.js +0 -741
  171. package/src/ui/dist/assets/SearchPlugin-DDMrGDkh.css +0 -379
  172. package/src/ui/dist/assets/TextViewerPlugin-BxIyqPQC.js +0 -472
  173. package/src/ui/dist/assets/VNCViewer-HAg9mF7M.js +0 -18821
  174. package/src/ui/dist/assets/awareness-C0NPR2Dj.js +0 -292
  175. package/src/ui/dist/assets/bot-0DYntytV.js +0 -21
  176. package/src/ui/dist/assets/browser-BAcuE0Xj.js +0 -2895
  177. package/src/ui/dist/assets/code-B20Slj_w.js +0 -17
  178. package/src/ui/dist/assets/file-content-DT24KFma.js +0 -377
  179. package/src/ui/dist/assets/file-diff-panel-DK13YPql.js +0 -92
  180. package/src/ui/dist/assets/file-jump-queue-r5XKgJEV.js +0 -16
  181. package/src/ui/dist/assets/file-socket-B4T2o4nR.js +0 -58
  182. package/src/ui/dist/assets/function-B5QZkkHC.js +0 -1895
  183. package/src/ui/dist/assets/image-DSeR_sDS.js +0 -18
  184. package/src/ui/dist/assets/index-BrFje2Uk.js +0 -120
  185. package/src/ui/dist/assets/index-BwRJaoTl.js +0 -25
  186. package/src/ui/dist/assets/index-D_E4281X.js +0 -221322
  187. package/src/ui/dist/assets/index-DnYB3xb1.js +0 -159
  188. package/src/ui/dist/assets/index-G7AcWcMu.css +0 -12594
  189. package/src/ui/dist/assets/monaco-LExaAN3Y.js +0 -623
  190. package/src/ui/dist/assets/pdf-effect-queue-BJk5okWJ.js +0 -47
  191. package/src/ui/dist/assets/pdf_viewer-e0g1is2C.js +0 -8206
  192. package/src/ui/dist/assets/popover-D3Gg_FoV.js +0 -476
  193. package/src/ui/dist/assets/project-sync-C_ygLlVU.js +0 -297
  194. package/src/ui/dist/assets/select-CpAK6uWm.js +0 -1690
  195. package/src/ui/dist/assets/sigma-DEccaSgk.js +0 -22
  196. package/src/ui/dist/assets/square-check-big-uUfyVsbD.js +0 -17
  197. package/src/ui/dist/assets/trash-CXvwwSe8.js +0 -32
  198. package/src/ui/dist/assets/useCliAccess-Bnop4mgR.js +0 -957
  199. package/src/ui/dist/assets/useFileDiffOverlay-B8eUAX0I.js +0 -53
  200. package/src/ui/dist/assets/wrap-text-9vbOBpkW.js +0 -35
  201. package/src/ui/dist/assets/yjs-DncrqiZ8.js +0 -11243
  202. package/src/ui/dist/assets/zoom-out-BgVMmOW4.js +0 -34
@@ -18,12 +18,18 @@ from ..gitops import (
18
18
  branch_exists,
19
19
  canonical_worktree_root,
20
20
  checkpoint_repo,
21
+ commit_detail,
22
+ compare_refs,
21
23
  create_worktree,
22
24
  current_branch,
25
+ diff_file_between_refs,
26
+ diff_file_for_commit,
23
27
  ensure_branch,
24
28
  export_git_graph,
25
29
  head_commit,
30
+ log_ref_history,
26
31
  )
32
+ from ..home import repo_root
27
33
  from ..registries import BaselineRegistry
28
34
  from ..shared import (
29
35
  append_jsonl,
@@ -972,6 +978,43 @@ class ArtifactService:
972
978
  return "idea"
973
979
  return "quest"
974
980
 
981
+ @staticmethod
982
+ def _collaboration_workspace_mode(state: dict[str, Any]) -> str | None:
983
+ normalized = str(state.get("workspace_mode") or "").strip().lower()
984
+ if normalized in {"copilot", "autonomous"}:
985
+ return normalized
986
+ return None
987
+
988
+ def _resolve_workspace_modes(
989
+ self,
990
+ state: dict[str, Any],
991
+ *,
992
+ branch_name: str | None,
993
+ has_idea: bool = False,
994
+ ) -> tuple[str, str]:
995
+ branch_mode = self._workspace_mode_for_branch(branch_name, has_idea=has_idea)
996
+ collaboration_mode = self._collaboration_workspace_mode(state)
997
+ return collaboration_mode or branch_mode, branch_mode
998
+
999
+ @staticmethod
1000
+ def _active_workspace_branch_mode(state: dict[str, Any], *, branch_name: str | None) -> str:
1001
+ normalized = str(state.get("workspace_branch_mode") or "").strip().lower()
1002
+ if normalized:
1003
+ return normalized
1004
+ legacy = str(state.get("workspace_mode") or "").strip().lower()
1005
+ if legacy in {"idea", "run", "analysis", "paper", "quest"}:
1006
+ return legacy
1007
+ branch_kind = str(branch_name or "").strip().lower()
1008
+ if branch_kind.startswith("paper/") or branch_kind == "paper":
1009
+ return "paper"
1010
+ if branch_kind.startswith("analysis/") or branch_kind == "analysis":
1011
+ return "analysis"
1012
+ if branch_kind.startswith("run/") or branch_kind == "run":
1013
+ return "run"
1014
+ if branch_kind.startswith("idea/") or branch_kind == "idea":
1015
+ return "idea"
1016
+ return "quest"
1017
+
975
1018
  def _prepare_branch_worktree_root(
976
1019
  self,
977
1020
  quest_root: Path,
@@ -4674,8 +4717,8 @@ class ArtifactService:
4674
4717
  current_branch_raw = str(state.get("current_workspace_branch") or "").strip()
4675
4718
  research_head_branch_raw = str(state.get("research_head_branch") or "").strip()
4676
4719
  paper_parent_branch_raw = str(state.get("paper_parent_branch") or "").strip()
4677
- workspace_mode = str(state.get("workspace_mode") or "").strip().lower()
4678
- prefer_paper_parent = workspace_mode == "paper" or self._branch_kind_from_name(current_branch_raw) == "paper"
4720
+ branch_mode = self._active_workspace_branch_mode(state, branch_name=current_branch_raw)
4721
+ prefer_paper_parent = branch_mode == "paper" or self._branch_kind_from_name(current_branch_raw) == "paper"
4679
4722
  parent_worktree_root: Path | None = None
4680
4723
  root_candidates = (
4681
4724
  (paper_parent_root_raw, head_root_raw, current_root_raw)
@@ -6027,7 +6070,7 @@ class ArtifactService:
6027
6070
  try:
6028
6071
  self.quest_service.schedule_projection_refresh(
6029
6072
  quest_root,
6030
- kinds=("details", "canvas"),
6073
+ kinds=("details", "canvas", "git_canvas"),
6031
6074
  throttle_seconds=0.0,
6032
6075
  )
6033
6076
  except Exception:
@@ -6150,6 +6193,7 @@ class ArtifactService:
6150
6193
  def checkpoint(self, quest_root: Path, message: str, *, allow_empty: bool = False) -> dict:
6151
6194
  result = checkpoint_repo(quest_root, message, allow_empty=allow_empty)
6152
6195
  self._touch_quest_updated_at(quest_root)
6196
+ self._refresh_git_surfaces(quest_root)
6153
6197
  return {
6154
6198
  "ok": True,
6155
6199
  "message": message,
@@ -6157,6 +6201,459 @@ class ArtifactService:
6157
6201
  **result,
6158
6202
  }
6159
6203
 
6204
+ def _refresh_git_surfaces(self, quest_root: Path) -> dict[str, Any]:
6205
+ projection_refresh = {
6206
+ "details": True,
6207
+ "canvas": True,
6208
+ "git_canvas": True,
6209
+ "graph": True,
6210
+ }
6211
+ try:
6212
+ self.quest_service.schedule_projection_refresh(
6213
+ quest_root,
6214
+ kinds=("details", "canvas", "git_canvas"),
6215
+ throttle_seconds=0.0,
6216
+ )
6217
+ except Exception:
6218
+ pass
6219
+ try:
6220
+ export_git_graph(quest_root, ensure_dir(quest_root / "artifacts" / "graphs"))
6221
+ except Exception:
6222
+ projection_refresh["graph"] = False
6223
+ return projection_refresh
6224
+
6225
+ def _append_git_event(
6226
+ self,
6227
+ quest_root: Path,
6228
+ *,
6229
+ action: str,
6230
+ repo: Path,
6231
+ result: dict[str, Any],
6232
+ ) -> None:
6233
+ append_jsonl(
6234
+ quest_root / ".ds" / "events.jsonl",
6235
+ {
6236
+ "type": "artifact.git",
6237
+ "quest_id": quest_root.name,
6238
+ "action": action,
6239
+ "repo": str(repo),
6240
+ "result": result,
6241
+ "recorded_at": utc_now(),
6242
+ },
6243
+ )
6244
+
6245
+ def _record_git_operation_artifact(
6246
+ self,
6247
+ quest_root: Path,
6248
+ *,
6249
+ repo: Path,
6250
+ action: str,
6251
+ payload: dict[str, Any],
6252
+ ) -> dict[str, Any] | None:
6253
+ state = self.quest_service.read_research_state(quest_root)
6254
+ if self._collaboration_workspace_mode(state) != "copilot":
6255
+ return None
6256
+
6257
+ normalized_action = str(action or "").strip().lower()
6258
+ before_branch = str(payload.get("before_branch") or "").strip() or None
6259
+ payload_branch = str(payload.get("branch") or "").strip() or None
6260
+ after_branch = (
6261
+ str(payload.get("after_branch") or "").strip()
6262
+ or payload_branch
6263
+ or before_branch
6264
+ )
6265
+ target_ref = str(payload.get("target_ref") or payload.get("target") or "").strip() or after_branch
6266
+ before_head = str(payload.get("before_head") or "").strip() or None
6267
+ after_head = str(payload.get("after_head") or payload.get("head") or payload.get("sha") or "").strip() or None
6268
+ commit_subject = str(payload.get("subject") or "").strip() or None
6269
+ changed_files = [
6270
+ str(item.get("path") or "").strip()
6271
+ for item in (payload.get("files") or [])
6272
+ if isinstance(item, dict) and str(item.get("path") or "").strip()
6273
+ ]
6274
+
6275
+ should_record = False
6276
+ summary = ""
6277
+ reason = ""
6278
+ status = "completed"
6279
+ record_branch = after_branch
6280
+ parent_branch: str | None = None
6281
+
6282
+ if normalized_action == "commit":
6283
+ if bool(payload.get("committed")) and after_branch:
6284
+ should_record = True
6285
+ record_branch = after_branch
6286
+ summary = (
6287
+ f"Committed on `{after_branch}`: {commit_subject}"
6288
+ if commit_subject
6289
+ else f"Committed changes on `{after_branch}`."
6290
+ )
6291
+ reason = "A durable Git commit changed the active branch state."
6292
+ elif normalized_action == "branch":
6293
+ created = bool(payload.get("created"))
6294
+ switched = bool(after_branch and before_branch and after_branch != before_branch)
6295
+ create_from = str(payload.get("create_from") or "").strip() or before_branch
6296
+ if created or switched:
6297
+ should_record = True
6298
+ record_branch = target_ref or after_branch or payload_branch or before_branch
6299
+ if created and switched:
6300
+ summary = f"Created and switched to `{target_ref}`."
6301
+ elif created:
6302
+ summary = f"Created branch `{target_ref}`."
6303
+ else:
6304
+ summary = f"Switched to existing branch `{after_branch}`."
6305
+ reason = "A durable Git branch operation changed the available branch graph."
6306
+ status = "completed" if created else "existing"
6307
+ if created:
6308
+ parent_branch = create_from
6309
+ elif normalized_action == "checkout":
6310
+ switched = bool(after_branch and before_branch and after_branch != before_branch)
6311
+ moved_head = bool(after_head and before_head and after_head != before_head)
6312
+ if bool(payload.get("ok")) and (switched or moved_head):
6313
+ should_record = True
6314
+ record_branch = after_branch or target_ref
6315
+ summary = (
6316
+ f"Checked out `{target_ref}`."
6317
+ if target_ref
6318
+ else f"Switched workspace branch from `{before_branch or 'unknown'}` to `{after_branch or 'unknown'}`."
6319
+ )
6320
+ reason = "A Git checkout changed the active branch or HEAD for the workspace."
6321
+
6322
+ if not should_record or not record_branch:
6323
+ return None
6324
+
6325
+ worktree_root: str | None = None
6326
+ worktree_rel_path: str | None = None
6327
+ if after_branch and record_branch == after_branch:
6328
+ worktree_root = str(repo)
6329
+ worktree_rel_path = self._workspace_relative(quest_root, repo)
6330
+
6331
+ return self.record(
6332
+ quest_root,
6333
+ {
6334
+ "kind": "report",
6335
+ "status": status,
6336
+ "report_type": "git_operation",
6337
+ "suppress_if_semantically_equivalent": False,
6338
+ "report_id": generate_id("report"),
6339
+ "summary": summary,
6340
+ "reason": reason,
6341
+ "branch": record_branch,
6342
+ "parent_branch": parent_branch,
6343
+ "head_commit": after_head,
6344
+ "worktree_root": worktree_root,
6345
+ "worktree_rel_path": worktree_rel_path,
6346
+ "flow_type": "git_operation",
6347
+ "protocol_step": normalized_action,
6348
+ "paths": {
6349
+ "workspace_root": str(repo),
6350
+ },
6351
+ "details": {
6352
+ "git_action": normalized_action,
6353
+ "target_ref": target_ref,
6354
+ "create_from": str(payload.get("create_from") or "").strip() or None,
6355
+ "before_branch": before_branch,
6356
+ "after_branch": after_branch,
6357
+ "record_branch": record_branch,
6358
+ "before_head": before_head,
6359
+ "after_head": after_head,
6360
+ "commit_subject": commit_subject,
6361
+ "changed_files": changed_files,
6362
+ },
6363
+ "source": {"kind": "system", "role": "artifact"},
6364
+ },
6365
+ checkpoint=False,
6366
+ workspace_root=repo,
6367
+ )
6368
+
6369
+ def _git_status_payload(self, repo: Path) -> dict[str, Any]:
6370
+ result = run_command(["git", "status", "--porcelain", "-b"], cwd=repo, check=False)
6371
+ lines = [line.rstrip() for line in str(result.stdout or "").splitlines()]
6372
+ branch_line = lines[0] if lines else ""
6373
+ changes: list[dict[str, Any]] = []
6374
+ staged_count = 0
6375
+ unstaged_count = 0
6376
+ untracked_count = 0
6377
+ for raw in lines[1:]:
6378
+ if not raw:
6379
+ continue
6380
+ status = raw[:2]
6381
+ path = raw[3:].strip() if len(raw) > 3 else raw.strip()
6382
+ staged = status[0] not in {" ", "?"}
6383
+ unstaged = status[1] not in {" "}
6384
+ untracked = status == "??"
6385
+ if staged:
6386
+ staged_count += 1
6387
+ if unstaged:
6388
+ unstaged_count += 1
6389
+ if untracked:
6390
+ untracked_count += 1
6391
+ changes.append(
6392
+ {
6393
+ "path": path,
6394
+ "status": status,
6395
+ "staged": staged,
6396
+ "unstaged": unstaged,
6397
+ "untracked": untracked,
6398
+ }
6399
+ )
6400
+ return {
6401
+ "ok": result.returncode == 0,
6402
+ "repo": str(repo),
6403
+ "branch": current_branch(repo),
6404
+ "head": head_commit(repo),
6405
+ "branch_status": branch_line[3:].strip() if branch_line.startswith("## ") else None,
6406
+ "has_changes": bool(changes),
6407
+ "staged_count": staged_count,
6408
+ "unstaged_count": unstaged_count,
6409
+ "untracked_count": untracked_count,
6410
+ "changes": changes,
6411
+ }
6412
+
6413
+ def git_action(
6414
+ self,
6415
+ quest_root: Path,
6416
+ *,
6417
+ action: str,
6418
+ workspace_root: Path | None = None,
6419
+ message: str | None = None,
6420
+ ref: str | None = None,
6421
+ base: str | None = None,
6422
+ head: str | None = None,
6423
+ sha: str | None = None,
6424
+ path: str | None = None,
6425
+ branch: str | None = None,
6426
+ create_from: str | None = None,
6427
+ limit: int = 30,
6428
+ allow_empty: bool = False,
6429
+ checkout_new_branch: bool = False,
6430
+ ) -> dict[str, Any]:
6431
+ resolved_action = str(action or "").strip().lower()
6432
+ repo = self._workspace_root_for(quest_root, workspace_root=workspace_root)
6433
+ before_branch = current_branch(repo)
6434
+ before_head = head_commit(repo)
6435
+ projection_refresh = {
6436
+ "details": False,
6437
+ "canvas": False,
6438
+ "git_canvas": False,
6439
+ "graph": False,
6440
+ }
6441
+
6442
+ if resolved_action == "status":
6443
+ result = self._git_status_payload(repo)
6444
+ return {
6445
+ "ok": True,
6446
+ "action": resolved_action,
6447
+ "quest_id": quest_root.name,
6448
+ "current_ref": current_branch(repo),
6449
+ "head": head_commit(repo),
6450
+ "projection_refresh": projection_refresh,
6451
+ "result": result,
6452
+ }
6453
+
6454
+ if resolved_action == "commit":
6455
+ commit_message = str(message or "").strip() or "Update workspace"
6456
+ result = checkpoint_repo(repo, commit_message, allow_empty=allow_empty)
6457
+ self._touch_quest_updated_at(quest_root)
6458
+ projection_refresh = self._refresh_git_surfaces(quest_root)
6459
+ if result.get("committed"):
6460
+ head_sha = str(result.get("head") or "").strip()
6461
+ if head_sha:
6462
+ try:
6463
+ detail = commit_detail(repo, sha=head_sha)
6464
+ except Exception:
6465
+ detail = {
6466
+ "sha": head_sha,
6467
+ "short_sha": head_sha[:7],
6468
+ "subject": commit_message,
6469
+ "parents": [],
6470
+ }
6471
+ else:
6472
+ detail = {
6473
+ "sha": None,
6474
+ "short_sha": None,
6475
+ "subject": commit_message,
6476
+ "parents": [],
6477
+ }
6478
+ else:
6479
+ detail = {
6480
+ "sha": result.get("head"),
6481
+ "short_sha": str(result.get("head") or "")[:7] or None,
6482
+ "subject": commit_message,
6483
+ "parents": [],
6484
+ }
6485
+ payload = {
6486
+ "committed": bool(result.get("committed")),
6487
+ "branch": result.get("branch"),
6488
+ "head": result.get("head"),
6489
+ "before_branch": before_branch,
6490
+ "before_head": before_head,
6491
+ "after_branch": result.get("branch"),
6492
+ "after_head": result.get("head"),
6493
+ "target_ref": result.get("branch") or before_branch,
6494
+ "stdout": result.get("stdout"),
6495
+ "stderr": result.get("stderr"),
6496
+ **detail,
6497
+ }
6498
+ self._append_git_event(quest_root, action=resolved_action, repo=repo, result=payload)
6499
+ self._record_git_operation_artifact(
6500
+ quest_root,
6501
+ repo=repo,
6502
+ action=resolved_action,
6503
+ payload=payload,
6504
+ )
6505
+ return {
6506
+ "ok": True,
6507
+ "action": resolved_action,
6508
+ "quest_id": quest_root.name,
6509
+ "current_ref": current_branch(repo),
6510
+ "head": head_commit(repo),
6511
+ "projection_refresh": projection_refresh,
6512
+ "result": payload,
6513
+ }
6514
+
6515
+ if resolved_action == "branch":
6516
+ branch_name = str(branch or "").strip()
6517
+ if not branch_name:
6518
+ return {"ok": False, "action": resolved_action, "message": "`branch` is required."}
6519
+ result = ensure_branch(repo, branch_name, start_point=create_from, checkout=checkout_new_branch)
6520
+ payload = {
6521
+ **result,
6522
+ "before_branch": before_branch,
6523
+ "before_head": before_head,
6524
+ "after_branch": current_branch(repo),
6525
+ "after_head": head_commit(repo),
6526
+ "target_ref": branch_name,
6527
+ "create_from": str(create_from or "").strip() or before_branch,
6528
+ }
6529
+ self._touch_quest_updated_at(quest_root)
6530
+ projection_refresh = self._refresh_git_surfaces(quest_root)
6531
+ self._append_git_event(quest_root, action=resolved_action, repo=repo, result=payload)
6532
+ self._record_git_operation_artifact(
6533
+ quest_root,
6534
+ repo=repo,
6535
+ action=resolved_action,
6536
+ payload=payload,
6537
+ )
6538
+ return {
6539
+ "ok": True,
6540
+ "action": resolved_action,
6541
+ "quest_id": quest_root.name,
6542
+ "current_ref": current_branch(repo),
6543
+ "head": head_commit(repo),
6544
+ "projection_refresh": projection_refresh,
6545
+ "result": payload,
6546
+ }
6547
+
6548
+ if resolved_action == "checkout":
6549
+ target = str(branch or ref or sha or head or "").strip()
6550
+ if not target:
6551
+ return {"ok": False, "action": resolved_action, "message": "One of `branch`, `ref`, `sha`, or `head` is required."}
6552
+ result = run_command(["git", "checkout", target], cwd=repo, check=False)
6553
+ payload = {
6554
+ "ok": result.returncode == 0,
6555
+ "target": target,
6556
+ "branch": current_branch(repo),
6557
+ "head": head_commit(repo),
6558
+ "before_branch": before_branch,
6559
+ "before_head": before_head,
6560
+ "after_branch": current_branch(repo),
6561
+ "after_head": head_commit(repo),
6562
+ "target_ref": target,
6563
+ "stdout": result.stdout,
6564
+ "stderr": result.stderr,
6565
+ }
6566
+ self._touch_quest_updated_at(quest_root)
6567
+ projection_refresh = self._refresh_git_surfaces(quest_root)
6568
+ self._append_git_event(quest_root, action=resolved_action, repo=repo, result=payload)
6569
+ self._record_git_operation_artifact(
6570
+ quest_root,
6571
+ repo=repo,
6572
+ action=resolved_action,
6573
+ payload=payload,
6574
+ )
6575
+ return {
6576
+ "ok": result.returncode == 0,
6577
+ "action": resolved_action,
6578
+ "quest_id": quest_root.name,
6579
+ "current_ref": current_branch(repo),
6580
+ "head": head_commit(repo),
6581
+ "projection_refresh": projection_refresh,
6582
+ "result": payload,
6583
+ }
6584
+
6585
+ if resolved_action == "log":
6586
+ target_ref = str(ref or branch or "").strip() or current_branch(repo)
6587
+ result = log_ref_history(repo, ref=target_ref, base=(base or "").strip() or None, limit=limit)
6588
+ return {
6589
+ "ok": True,
6590
+ "action": resolved_action,
6591
+ "quest_id": quest_root.name,
6592
+ "current_ref": current_branch(repo),
6593
+ "head": head_commit(repo),
6594
+ "projection_refresh": projection_refresh,
6595
+ "result": result,
6596
+ }
6597
+
6598
+ if resolved_action == "show":
6599
+ target_sha = str(sha or ref or head or "").strip()
6600
+ if not target_sha:
6601
+ return {"ok": False, "action": resolved_action, "message": "`sha` or `ref` is required."}
6602
+ result = commit_detail(repo, sha=target_sha)
6603
+ return {
6604
+ "ok": True,
6605
+ "action": resolved_action,
6606
+ "quest_id": quest_root.name,
6607
+ "current_ref": current_branch(repo),
6608
+ "head": head_commit(repo),
6609
+ "projection_refresh": projection_refresh,
6610
+ "result": result,
6611
+ }
6612
+
6613
+ if resolved_action == "diff":
6614
+ target_path = str(path or "").strip() or None
6615
+ if sha:
6616
+ result = commit_detail(repo, sha=sha) if not target_path else diff_file_for_commit(repo, sha=sha, path=target_path)
6617
+ elif base and head:
6618
+ result = (
6619
+ diff_file_between_refs(repo, base=base, head=head, path=target_path)
6620
+ if target_path
6621
+ else compare_refs(repo, base=base, head=head)
6622
+ )
6623
+ else:
6624
+ return {
6625
+ "ok": False,
6626
+ "action": resolved_action,
6627
+ "message": "Provide `sha` for commit diff or `base` and `head` for compare diff.",
6628
+ }
6629
+ return {
6630
+ "ok": True,
6631
+ "action": resolved_action,
6632
+ "quest_id": quest_root.name,
6633
+ "current_ref": current_branch(repo),
6634
+ "head": head_commit(repo),
6635
+ "projection_refresh": projection_refresh,
6636
+ "result": result,
6637
+ }
6638
+
6639
+ if resolved_action == "graph":
6640
+ projection_refresh = self._refresh_git_surfaces(quest_root)
6641
+ return {
6642
+ "ok": True,
6643
+ "action": resolved_action,
6644
+ "quest_id": quest_root.name,
6645
+ "current_ref": current_branch(repo),
6646
+ "head": head_commit(repo),
6647
+ "projection_refresh": projection_refresh,
6648
+ "result": self.quest_service.git_commit_canvas(quest_root.name),
6649
+ }
6650
+
6651
+ return {
6652
+ "ok": False,
6653
+ "action": resolved_action,
6654
+ "message": "Unsupported git action. Use status, commit, branch, checkout, log, show, diff, or graph.",
6655
+ }
6656
+
6160
6657
  def prepare_branch(
6161
6658
  self,
6162
6659
  quest_root: Path,
@@ -6323,7 +6820,11 @@ class ArtifactService:
6323
6820
  has_idea=bool(resolved_idea_id),
6324
6821
  has_main_result=has_main_result,
6325
6822
  )
6326
- workspace_mode = self._workspace_mode_for_branch(branch_name, has_idea=bool(resolved_idea_id))
6823
+ workspace_mode, branch_mode = self._resolve_workspace_modes(
6824
+ state,
6825
+ branch_name=branch_name,
6826
+ has_idea=bool(resolved_idea_id),
6827
+ )
6327
6828
  source_run_id = (
6328
6829
  str(target.get("run_id") or "").strip()
6329
6830
  or str(latest_main_run.get("run_id") or "").strip()
@@ -6360,7 +6861,7 @@ class ArtifactService:
6360
6861
  "promote_to_head": bool(promote_to_head),
6361
6862
  "worktree_created": worktree_created,
6362
6863
  "next_anchor": next_anchor,
6363
- "workspace_mode": workspace_mode,
6864
+ "workspace_mode": branch_mode,
6364
6865
  "latest_main_run_id": str(latest_main_run.get("run_id") or "").strip() or None,
6365
6866
  "branch_kind": branch_kind,
6366
6867
  "paper_parent_branch": source_parent_branch if branch_kind == "paper" else None,
@@ -6388,6 +6889,7 @@ class ArtifactService:
6388
6889
  "paper_parent_run_id": source_run_id if branch_kind == "paper" else None,
6389
6890
  "next_pending_slice_id": None,
6390
6891
  "workspace_mode": workspace_mode,
6892
+ "workspace_branch_mode": branch_mode,
6391
6893
  "last_flow_type": "branch_activation",
6392
6894
  }
6393
6895
  if promote_to_head:
@@ -6490,6 +6992,12 @@ class ArtifactService:
6490
6992
  checkpoint=False,
6491
6993
  workspace_root=workspace_root,
6492
6994
  )
6995
+ current_state = self.quest_service.read_research_state(quest_root)
6996
+ workspace_mode, branch_mode = self._resolve_workspace_modes(
6997
+ current_state,
6998
+ branch_name=target_branch,
6999
+ has_idea=bool(idea_id),
7000
+ )
6493
7001
  self.quest_service.update_research_state(
6494
7002
  quest_root,
6495
7003
  active_idea_id=idea_id,
@@ -6503,7 +7011,8 @@ class ArtifactService:
6503
7011
  paper_parent_branch=None,
6504
7012
  paper_parent_worktree_root=None,
6505
7013
  paper_parent_run_id=None,
6506
- workspace_mode="run",
7014
+ workspace_mode=workspace_mode,
7015
+ workspace_branch_mode=branch_mode,
6507
7016
  last_flow_type="main_experiment_branch",
6508
7017
  )
6509
7018
  return target_branch, current_branch_name, True
@@ -6522,10 +7031,7 @@ class ArtifactService:
6522
7031
  or current_branch(self._workspace_root_for(quest_root))
6523
7032
  )
6524
7033
  current_workspace_root = self._workspace_root_for(quest_root)
6525
- if (
6526
- str(state.get("workspace_mode") or "").strip() == "paper"
6527
- and self._branch_kind_from_name(current_branch_name) == "paper"
6528
- ):
7034
+ if self._active_workspace_branch_mode(state, branch_name=current_branch_name) == "paper":
6529
7035
  return {
6530
7036
  "ok": True,
6531
7037
  "branch": current_branch_name,
@@ -6632,9 +7138,9 @@ class ArtifactService:
6632
7138
  risks = [str(item).strip() for item in (risks or []) if str(item).strip()]
6633
7139
  next_target = str(next_target or "experiment").strip().lower() or "experiment"
6634
7140
  normalized_lineage_intent = self._normalize_lineage_intent(lineage_intent)
6635
- from ..prompts.builder import STANDARD_SKILLS
7141
+ from ..prompts.builder import current_standard_skills
6636
7142
 
6637
- next_anchor = next_target if next_target in STANDARD_SKILLS else "experiment"
7143
+ next_anchor = next_target if next_target in current_standard_skills(repo_root()) else "experiment"
6638
7144
 
6639
7145
  if normalized_mode == "create":
6640
7146
  resolved_idea_id = str(idea_id or generate_id("idea")).strip()
@@ -6941,6 +7447,12 @@ class ArtifactService:
6941
7447
  checkpoint=False,
6942
7448
  workspace_root=worktree_root,
6943
7449
  )
7450
+ current_state = self.quest_service.read_research_state(quest_root)
7451
+ workspace_mode, branch_mode = self._resolve_workspace_modes(
7452
+ current_state,
7453
+ branch_name=branch_name,
7454
+ has_idea=bool(resolved_idea_id),
7455
+ )
6944
7456
  research_state = self.quest_service.update_research_state(
6945
7457
  quest_root,
6946
7458
  active_idea_id=resolved_idea_id,
@@ -6954,7 +7466,8 @@ class ArtifactService:
6954
7466
  analysis_parent_branch=None,
6955
7467
  analysis_parent_worktree_root=None,
6956
7468
  next_pending_slice_id=None,
6957
- workspace_mode="idea",
7469
+ workspace_mode=workspace_mode,
7470
+ workspace_branch_mode=branch_mode,
6958
7471
  last_flow_type="idea_submission",
6959
7472
  )
6960
7473
  self.quest_service.update_settings(quest_id, active_anchor=next_anchor)
@@ -7404,13 +7917,16 @@ class ArtifactService:
7404
7917
  ) -> dict[str, Any]:
7405
7918
  self._require_baseline_gate_open(quest_root, action="record_main_experiment")
7406
7919
  state = self.quest_service.read_research_state(quest_root)
7407
- workspace_mode = str(state.get("workspace_mode") or "").strip()
7408
- if workspace_mode == "analysis":
7920
+ branch_mode = self._active_workspace_branch_mode(
7921
+ state,
7922
+ branch_name=str(state.get("current_workspace_branch") or "").strip(),
7923
+ )
7924
+ if branch_mode == "analysis":
7409
7925
  raise ValueError(
7410
7926
  "record_main_experiment cannot run while the active workspace is an analysis slice. "
7411
7927
  "Finish or close the analysis campaign first."
7412
7928
  )
7413
- if workspace_mode == "paper":
7929
+ if branch_mode == "paper":
7414
7930
  raise ValueError(
7415
7931
  "record_main_experiment cannot run while the active workspace is a paper branch. "
7416
7932
  "Return to the source evidence branch or create a new run branch first."
@@ -7896,6 +8412,12 @@ class ArtifactService:
7896
8412
  },
7897
8413
  )
7898
8414
  self.quest_service.update_settings(self._quest_id(quest_root), active_anchor="decision")
8415
+ current_state = self.quest_service.read_research_state(quest_root)
8416
+ workspace_mode, branch_mode = self._resolve_workspace_modes(
8417
+ current_state,
8418
+ branch_name=branch_name,
8419
+ has_idea=bool(active_idea_id),
8420
+ )
7899
8421
  research_state = self.quest_service.update_research_state(
7900
8422
  quest_root,
7901
8423
  active_idea_id=active_idea_id,
@@ -7909,7 +8431,8 @@ class ArtifactService:
7909
8431
  paper_parent_branch=None,
7910
8432
  paper_parent_worktree_root=None,
7911
8433
  paper_parent_run_id=None,
7912
- workspace_mode="run",
8434
+ workspace_mode=workspace_mode,
8435
+ workspace_branch_mode=branch_mode,
7913
8436
  last_flow_type="main_experiment_recorded",
7914
8437
  )
7915
8438
  return {
@@ -7994,7 +8517,10 @@ class ArtifactService:
7994
8517
  or normalized_research_questions
7995
8518
  or normalized_experimental_designs
7996
8519
  or normalized_todo_items
7997
- or str(state.get("workspace_mode") or "").strip().lower() == "paper"
8520
+ or self._active_workspace_branch_mode(
8521
+ state,
8522
+ branch_name=str(state.get("current_workspace_branch") or "").strip(),
8523
+ ) == "paper"
7998
8524
  or active_anchor == "write"
7999
8525
  or campaign_origin_kind in {"write", "paper", "rebuttal", "revision"}
8000
8526
  )
@@ -8525,6 +9051,11 @@ class ArtifactService:
8525
9051
  source_run_id=resolved_parent_run_id,
8526
9052
  source_idea_id=active_idea_id,
8527
9053
  )
9054
+ current_state = self.quest_service.read_research_state(quest_root)
9055
+ workspace_mode, branch_mode = self._resolve_workspace_modes(
9056
+ current_state,
9057
+ branch_name=first_slice["branch"],
9058
+ )
8528
9059
  research_state = self.quest_service.update_research_state(
8529
9060
  quest_root,
8530
9061
  active_idea_id=active_idea_id,
@@ -8534,7 +9065,8 @@ class ArtifactService:
8534
9065
  next_pending_slice_id=first_slice["slice_id"],
8535
9066
  current_workspace_branch=first_slice["branch"],
8536
9067
  current_workspace_root=first_slice["worktree_root"],
8537
- workspace_mode="analysis",
9068
+ workspace_mode=workspace_mode,
9069
+ workspace_branch_mode=branch_mode,
8538
9070
  last_flow_type="analysis_campaign",
8539
9071
  )
8540
9072
  baseline_inventory = self._upsert_analysis_baseline_inventory(quest_root, inventory_entries) if inventory_entries else None
@@ -9516,13 +10048,19 @@ class ArtifactService:
9516
10048
  )
9517
10049
 
9518
10050
  if next_slice is not None:
10051
+ current_state = self.quest_service.read_research_state(quest_root)
10052
+ workspace_mode, branch_mode = self._resolve_workspace_modes(
10053
+ current_state,
10054
+ branch_name=next_slice.get("branch"),
10055
+ )
9519
10056
  research_state = self.quest_service.update_research_state(
9520
10057
  quest_root,
9521
10058
  active_analysis_campaign_id=campaign_id,
9522
10059
  next_pending_slice_id=next_slice.get("slice_id"),
9523
10060
  current_workspace_branch=next_slice.get("branch"),
9524
10061
  current_workspace_root=next_slice.get("worktree_root"),
9525
- workspace_mode="analysis",
10062
+ workspace_mode=workspace_mode,
10063
+ workspace_branch_mode=branch_mode,
9526
10064
  last_flow_type="analysis_slice",
9527
10065
  )
9528
10066
  self.quest_service.update_settings(self._quest_id(quest_root), active_anchor="analysis-campaign")
@@ -9624,6 +10162,11 @@ class ArtifactService:
9624
10162
  startup_contract = self._startup_contract(quest_root)
9625
10163
  raw_need_research_paper = startup_contract.get("need_research_paper")
9626
10164
  need_research_paper = raw_need_research_paper if isinstance(raw_need_research_paper, bool) else True
10165
+ current_state = self.quest_service.read_research_state(quest_root)
10166
+ workspace_mode, branch_mode = self._resolve_workspace_modes(
10167
+ current_state,
10168
+ branch_name=parent_branch,
10169
+ )
9627
10170
  base_research_state = self.quest_service.update_research_state(
9628
10171
  quest_root,
9629
10172
  active_idea_id=restored_idea_id,
@@ -9636,7 +10179,8 @@ class ArtifactService:
9636
10179
  next_pending_slice_id=None,
9637
10180
  current_workspace_branch=parent_branch,
9638
10181
  current_workspace_root=str(parent_worktree_root),
9639
- workspace_mode="run" if self._branch_kind_from_name(parent_branch) == "run" else "idea",
10182
+ workspace_mode=workspace_mode,
10183
+ workspace_branch_mode=branch_mode,
9640
10184
  last_flow_type="analysis_campaign_complete",
9641
10185
  )
9642
10186
  writing_workspace: dict[str, Any] | None = None
@@ -10382,11 +10926,12 @@ class ArtifactService:
10382
10926
  }
10383
10927
  suppress_resolved = (kind == "progress") if suppress_if_unchanged is None else bool(suppress_if_unchanged)
10384
10928
  dedupe_key_resolved = str(dedupe_key or self._normalize_interaction_message(full_message)).strip() or None
10929
+ pending_user_message_count = int(self.quest_service.snapshot(self._quest_id(quest_root)).get("pending_user_message_count") or 0)
10385
10930
  if (
10386
10931
  kind == "progress"
10387
10932
  and suppress_resolved
10388
10933
  and dedupe_key_resolved
10389
- and int(self.quest_service.snapshot(self._quest_id(quest_root)).get("pending_user_message_count") or 0) == 0
10934
+ and pending_user_message_count == 0
10390
10935
  ):
10391
10936
  prior_interaction = self._latest_duplicate_progress_interaction(
10392
10937
  quest_root,
@@ -10433,6 +10978,57 @@ class ArtifactService:
10433
10978
  "suppressed_reason": "unchanged_progress",
10434
10979
  "dedupe_key": dedupe_key_resolved,
10435
10980
  }
10981
+ if (
10982
+ kind == "answer"
10983
+ and not deliver_to_bound_conversations
10984
+ and dedupe_key_resolved
10985
+ and pending_user_message_count == 0
10986
+ ):
10987
+ prior_answer = self._latest_duplicate_answer_fallback_interaction(
10988
+ quest_root,
10989
+ dedupe_key=dedupe_key_resolved,
10990
+ min_interval_seconds=120,
10991
+ )
10992
+ if prior_answer is not None:
10993
+ interaction_state = self._read_interaction_state(quest_root)
10994
+ waiting_requests = [
10995
+ dict(item)
10996
+ for item in (interaction_state.get("open_requests") or [])
10997
+ if str(item.get("status") or "") == "waiting"
10998
+ ]
10999
+ return {
11000
+ "status": "suppressed_duplicate",
11001
+ "artifact_id": prior_answer.get("artifact_id"),
11002
+ "interaction_id": prior_answer.get("interaction_id"),
11003
+ "expects_reply": False,
11004
+ "reply_mode": "threaded",
11005
+ "surface_actions": [],
11006
+ "connector_hints": connector_hints_resolved,
11007
+ "normalized_attachments": attachments_resolved,
11008
+ "attachment_issues": attachment_issues,
11009
+ "delivered": False,
11010
+ "delivery_results": [],
11011
+ "response_phase": response_phase,
11012
+ "delivery_targets": [],
11013
+ "delivery_policy": self._delivery_policy(self._connectors_config()),
11014
+ "preferred_connector": self._preferred_connector(self._connectors_config()),
11015
+ "recent_inbound_messages": [],
11016
+ "delivery_batch": None,
11017
+ "recent_interaction_records": self.quest_service.latest_artifact_interaction_records(quest_root, limit=10),
11018
+ "agent_instruction": self.quest_service.localized_copy(
11019
+ quest_root=quest_root,
11020
+ zh="这一轮里相同内容的 answer 已经发出过一次,不要再为本地 fallback 额外创建第二条。",
11021
+ en="An identical answer was already emitted in this user turn. Do not create a second local-only fallback copy.",
11022
+ ),
11023
+ "queued_message_count_before_delivery": 0,
11024
+ "queued_message_count_after_delivery": 0,
11025
+ "open_request_count": len(waiting_requests),
11026
+ "active_request": waiting_requests[-1] if waiting_requests else None,
11027
+ "default_reply_interaction_id": interaction_state.get("default_reply_interaction_id"),
11028
+ "guidance": "Duplicate answer fallback was suppressed because the same answer was already recorded in the current user turn.",
11029
+ "suppressed_reason": "duplicate_answer_fallback",
11030
+ "dedupe_key": dedupe_key_resolved,
11031
+ }
10436
11032
  resolved_artifact_id = generate_id(durable_kind)
10437
11033
  resolved_interaction_id = interaction_id or (
10438
11034
  resolved_artifact_id if reply_mode_resolved != "none" or reply_to_interaction_id else None
@@ -10559,6 +11155,7 @@ class ArtifactService:
10559
11155
  connector_hints=connector_hints_resolved,
10560
11156
  created_at=(artifact.get("record") or {}).get("updated_at"),
10561
11157
  counts_as_visible=counts_as_visible,
11158
+ deliver_to_bound_conversations=deliver_to_bound_conversations,
10562
11159
  )
10563
11160
 
10564
11161
  return {
@@ -10619,6 +11216,34 @@ class ArtifactService:
10619
11216
  return dict(item)
10620
11217
  return None
10621
11218
 
11219
+ def _latest_duplicate_answer_fallback_interaction(
11220
+ self,
11221
+ quest_root: Path,
11222
+ *,
11223
+ dedupe_key: str,
11224
+ min_interval_seconds: int | None,
11225
+ ) -> dict[str, Any] | None:
11226
+ recent = self.quest_service.latest_artifact_interaction_records(quest_root, limit=40)
11227
+ for item in reversed(recent):
11228
+ record_type = str(item.get("type") or "").strip()
11229
+ if record_type == "user_inbound":
11230
+ return None
11231
+ if record_type != "artifact_outbound":
11232
+ continue
11233
+ if str(item.get("kind") or "").strip() != "answer":
11234
+ continue
11235
+ if not bool(item.get("deliver_to_bound_conversations")):
11236
+ continue
11237
+ previous_key = str(item.get("dedupe_key") or self._normalize_interaction_message(item.get("message") or "")).strip()
11238
+ if previous_key != dedupe_key:
11239
+ continue
11240
+ if min_interval_seconds:
11241
+ seconds_since = self.quest_service._seconds_since_iso_timestamp(item.get("created_at"))
11242
+ if seconds_since is not None and seconds_since > int(min_interval_seconds):
11243
+ return None
11244
+ return dict(item)
11245
+ return None
11246
+
10622
11247
  def complete_quest(
10623
11248
  self,
10624
11249
  quest_root: Path,