@researai/deepscientist 1.5.14 → 1.5.16

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 (225) hide show
  1. package/README.md +336 -90
  2. package/assets/branding/logo-raster.png +0 -0
  3. package/bin/ds.js +816 -131
  4. package/docs/en/00_QUICK_START.md +36 -15
  5. package/docs/en/01_SETTINGS_REFERENCE.md +53 -4
  6. package/docs/en/02_START_RESEARCH_GUIDE.md +7 -0
  7. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +19 -0
  8. package/docs/en/05_TUI_GUIDE.md +6 -0
  9. package/docs/en/06_RUNTIME_AND_CANVAS.md +4 -3
  10. package/docs/en/09_DOCTOR.md +11 -5
  11. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  12. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +65 -13
  13. package/docs/en/15_CODEX_PROVIDER_SETUP.md +25 -8
  14. package/docs/en/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  15. package/docs/en/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  16. package/docs/en/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  17. package/docs/en/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
  18. package/docs/en/19_LOCAL_BROWSER_AUTH.md +70 -0
  19. package/docs/en/20_WORKSPACE_MODES_GUIDE.md +250 -0
  20. package/docs/en/README.md +24 -0
  21. package/docs/zh/00_QUICK_START.md +36 -15
  22. package/docs/zh/01_SETTINGS_REFERENCE.md +53 -4
  23. package/docs/zh/02_START_RESEARCH_GUIDE.md +7 -0
  24. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +19 -0
  25. package/docs/zh/05_TUI_GUIDE.md +6 -0
  26. package/docs/zh/09_DOCTOR.md +11 -5
  27. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  28. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +65 -13
  29. package/docs/zh/15_CODEX_PROVIDER_SETUP.md +25 -8
  30. package/docs/zh/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  31. package/docs/zh/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  32. package/docs/zh/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  33. package/docs/zh/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
  34. package/docs/zh/19_LOCAL_BROWSER_AUTH.md +68 -0
  35. package/docs/zh/20_WORKSPACE_MODES_GUIDE.md +251 -0
  36. package/docs/zh/README.md +24 -0
  37. package/install.sh +2 -0
  38. package/package.json +1 -1
  39. package/pyproject.toml +1 -1
  40. package/src/deepscientist/__init__.py +1 -1
  41. package/src/deepscientist/acp/envelope.py +6 -0
  42. package/src/deepscientist/artifact/charts.py +567 -0
  43. package/src/deepscientist/artifact/guidance.py +50 -10
  44. package/src/deepscientist/artifact/metrics.py +228 -5
  45. package/src/deepscientist/artifact/schemas.py +3 -0
  46. package/src/deepscientist/artifact/service.py +4276 -308
  47. package/src/deepscientist/bash_exec/models.py +23 -0
  48. package/src/deepscientist/bash_exec/monitor.py +147 -67
  49. package/src/deepscientist/bash_exec/runtime.py +218 -156
  50. package/src/deepscientist/bash_exec/service.py +309 -69
  51. package/src/deepscientist/bash_exec/shells.py +87 -0
  52. package/src/deepscientist/bridges/connectors.py +51 -2
  53. package/src/deepscientist/cli.py +115 -19
  54. package/src/deepscientist/codex_cli_compat.py +232 -0
  55. package/src/deepscientist/config/models.py +8 -4
  56. package/src/deepscientist/config/service.py +38 -11
  57. package/src/deepscientist/connector/weixin_support.py +122 -1
  58. package/src/deepscientist/daemon/api/handlers.py +199 -9
  59. package/src/deepscientist/daemon/api/router.py +5 -0
  60. package/src/deepscientist/daemon/app.py +1458 -289
  61. package/src/deepscientist/doctor.py +51 -0
  62. package/src/deepscientist/file_lock.py +48 -0
  63. package/src/deepscientist/gitops/__init__.py +10 -1
  64. package/src/deepscientist/gitops/diff.py +296 -1
  65. package/src/deepscientist/gitops/service.py +4 -1
  66. package/src/deepscientist/mcp/server.py +212 -5
  67. package/src/deepscientist/process_control.py +161 -0
  68. package/src/deepscientist/prompts/builder.py +501 -453
  69. package/src/deepscientist/quest/layout.py +15 -2
  70. package/src/deepscientist/quest/service.py +2539 -195
  71. package/src/deepscientist/quest/stage_views.py +177 -1
  72. package/src/deepscientist/runners/base.py +2 -0
  73. package/src/deepscientist/runners/codex.py +169 -31
  74. package/src/deepscientist/runners/runtime_overrides.py +17 -1
  75. package/src/deepscientist/skills/__init__.py +2 -2
  76. package/src/deepscientist/skills/installer.py +196 -5
  77. package/src/deepscientist/skills/registry.py +66 -0
  78. package/src/prompts/connectors/qq.md +18 -8
  79. package/src/prompts/connectors/weixin.md +16 -6
  80. package/src/prompts/contracts/shared_interaction.md +24 -4
  81. package/src/prompts/system.md +921 -72
  82. package/src/prompts/system_copilot.md +43 -0
  83. package/src/skills/analysis-campaign/SKILL.md +32 -2
  84. package/src/skills/analysis-campaign/references/artifact-orchestration.md +1 -1
  85. package/src/skills/analysis-campaign/references/writing-facing-slice-examples.md +65 -0
  86. package/src/skills/baseline/SKILL.md +10 -0
  87. package/src/skills/decision/SKILL.md +27 -2
  88. package/src/skills/experiment/SKILL.md +16 -2
  89. package/src/skills/figure-polish/SKILL.md +1 -0
  90. package/src/skills/finalize/SKILL.md +19 -0
  91. package/src/skills/idea/SKILL.md +79 -0
  92. package/src/skills/idea/references/idea-generation-playbook.md +100 -0
  93. package/src/skills/idea/references/outline-seeding-example.md +60 -0
  94. package/src/skills/intake-audit/SKILL.md +9 -1
  95. package/src/skills/mentor/SKILL.md +217 -0
  96. package/src/skills/mentor/references/correction-rules.md +210 -0
  97. package/src/skills/mentor/references/knowledge-profile.md +91 -0
  98. package/src/skills/mentor/references/persona-profile.md +138 -0
  99. package/src/skills/mentor/references/taste-profile.md +128 -0
  100. package/src/skills/mentor/references/thought-style-profile.md +138 -0
  101. package/src/skills/mentor/references/work-profile.md +289 -0
  102. package/src/skills/mentor/references/workflow-profile.md +240 -0
  103. package/src/skills/optimize/SKILL.md +1645 -0
  104. package/src/skills/rebuttal/SKILL.md +3 -1
  105. package/src/skills/review/SKILL.md +3 -1
  106. package/src/skills/scout/SKILL.md +8 -0
  107. package/src/skills/write/SKILL.md +81 -12
  108. package/src/skills/write/references/outline-evidence-contract-example.md +107 -0
  109. package/src/tui/dist/app/AppContainer.js +22 -11
  110. package/src/tui/dist/index.js +4 -1
  111. package/src/tui/dist/lib/api.js +33 -3
  112. package/src/tui/package.json +1 -1
  113. package/src/ui/dist/assets/AiManusChatView-COFACy7V.js +204 -0
  114. package/src/ui/dist/assets/AnalysisPlugin-DnSm0GZn.js +1 -0
  115. package/src/ui/dist/assets/CliPlugin-CvwCmDQ5.js +109 -0
  116. package/src/ui/dist/assets/CodeEditorPlugin-cOqSa0xq.js +2 -0
  117. package/src/ui/dist/assets/CodeViewerPlugin-itb0tltR.js +270 -0
  118. package/src/ui/dist/assets/DocViewerPlugin-DqKkiCI6.js +7 -0
  119. package/src/ui/dist/assets/GitCommitViewerPlugin-DVgNHBCS.js +1 -0
  120. package/src/ui/dist/assets/GitDiffViewerPlugin-DxL2ezFG.js +6 -0
  121. package/src/ui/dist/assets/GitSnapshotViewer-B_RQm1YZ.js +30 -0
  122. package/src/ui/dist/assets/ImageViewerPlugin-tHqlXY3n.js +26 -0
  123. package/src/ui/dist/assets/LabCopilotPanel-ClMbq5Yu.js +14 -0
  124. package/src/ui/dist/assets/LabPlugin-L_SuE8ow.js +22 -0
  125. package/src/ui/dist/assets/LatexPlugin-B495DTXC.js +25 -0
  126. package/src/ui/dist/assets/MarkdownViewerPlugin-DG28-61B.js +128 -0
  127. package/src/ui/dist/assets/MarketplacePlugin-BiOGT-Kj.js +13 -0
  128. package/src/ui/dist/assets/{NotebookEditor-CccQYZjX.css → NotebookEditor-BHH8rdGj.css} +1 -1
  129. package/src/ui/dist/assets/NotebookEditor-BOr3x3Ej.css +1 -0
  130. package/src/ui/dist/assets/NotebookEditor-C-4Kt1p9.js +81 -0
  131. package/src/ui/dist/assets/NotebookEditor-CVsj8h_T.js +361 -0
  132. package/src/ui/dist/assets/PdfLoader-CASDQmxJ.js +16 -0
  133. package/src/ui/dist/assets/PdfLoader-Cy5jtWrr.css +1 -0
  134. package/src/ui/dist/assets/PdfMarkdownPlugin-BFhwoKsY.js +1 -0
  135. package/src/ui/dist/assets/PdfViewerPlugin-DcOzU9vd.js +17 -0
  136. package/src/ui/dist/assets/PdfViewerPlugin-nwwE-fjJ.css +1 -0
  137. package/src/ui/dist/assets/SearchPlugin-CHj7M58O.js +16 -0
  138. package/src/ui/dist/assets/SearchPlugin-DA4en4hK.css +1 -0
  139. package/src/ui/dist/assets/TextViewerPlugin-CB4DYfWO.js +54 -0
  140. package/src/ui/dist/assets/VNCViewer-CjlbyCB3.js +11 -0
  141. package/src/ui/dist/assets/bot-CFkZY-JP.js +6 -0
  142. package/src/ui/dist/assets/browser-CTB2jwNe.js +8 -0
  143. package/src/ui/dist/assets/chevron-up-Dq5ofbht.js +6 -0
  144. package/src/ui/dist/assets/code-DLC6G24T.js +6 -0
  145. package/src/ui/dist/assets/file-content-Dv4LoZec.js +1 -0
  146. package/src/ui/dist/assets/file-diff-panel-Denq-lC3.js +1 -0
  147. package/src/ui/dist/assets/file-jump-queue-DA-SdG__.js +1 -0
  148. package/src/ui/dist/assets/file-socket-Cu4Qln7Y.js +1 -0
  149. package/src/ui/dist/assets/git-commit-horizontal-BUh6G52n.js +6 -0
  150. package/src/ui/dist/assets/image-B9HUUddG.js +6 -0
  151. package/src/ui/dist/assets/index-B2B1sg-M.js +1 -0
  152. package/src/ui/dist/assets/index-Cgla8biy.css +33 -0
  153. package/src/ui/dist/assets/index-DRyx7vAc.js +1 -0
  154. package/src/ui/dist/assets/index-Gbl53BNp.js +2496 -0
  155. package/src/ui/dist/assets/index-wQ7RIIRd.js +11 -0
  156. package/src/ui/dist/assets/monaco-CiHMMNH_.js +1 -0
  157. package/src/ui/dist/assets/pdf-effect-queue-ZtnHFCAi.js +6 -0
  158. package/src/ui/dist/assets/plugin-monaco-C8UgLomw.js +19 -0
  159. package/src/ui/dist/assets/plugin-notebook-HbW2K-1c.js +169 -0
  160. package/src/ui/dist/assets/plugin-pdf-CR8hgQBV.js +357 -0
  161. package/src/ui/dist/assets/plugin-terminal-MXFIPun8.js +227 -0
  162. package/src/ui/dist/assets/popover-DL6h35vr.js +1 -0
  163. package/src/ui/dist/assets/project-sync-CsX08Qno.js +1 -0
  164. package/src/ui/dist/assets/select-DvmXt1yY.js +11 -0
  165. package/src/ui/dist/assets/sigma-7jpXazui.js +6 -0
  166. package/src/ui/dist/assets/trash-xA7kFt8i.js +11 -0
  167. package/src/ui/dist/assets/useCliAccess-DsMwDjOp.js +1 -0
  168. package/src/ui/dist/assets/useFileDiffOverlay-FuhcnKiw.js +1 -0
  169. package/src/ui/dist/assets/wrap-text-CwMn-iqb.js +11 -0
  170. package/src/ui/dist/assets/zoom-out-R-GWEhzS.js +11 -0
  171. package/src/ui/dist/index.html +5 -2
  172. package/src/ui/dist/assets/AiManusChatView-DaF9Nge_.js +0 -26597
  173. package/src/ui/dist/assets/AnalysisPlugin-BSVx6dXE.js +0 -123
  174. package/src/ui/dist/assets/CliPlugin-C9gzJX41.js +0 -5905
  175. package/src/ui/dist/assets/CodeEditorPlugin-DU9G0Tox.js +0 -427
  176. package/src/ui/dist/assets/CodeViewerPlugin-DoX_fI9l.js +0 -905
  177. package/src/ui/dist/assets/DocViewerPlugin-C4FWIXuU.js +0 -278
  178. package/src/ui/dist/assets/GitDiffViewerPlugin-BgfFMgtf.js +0 -2661
  179. package/src/ui/dist/assets/ImageViewerPlugin-tcPkfY_x.js +0 -500
  180. package/src/ui/dist/assets/LabCopilotPanel-_dKV60Bf.js +0 -4104
  181. package/src/ui/dist/assets/LabPlugin-Bje0ayoC.js +0 -2677
  182. package/src/ui/dist/assets/LatexPlugin-CVsBzAln.js +0 -1792
  183. package/src/ui/dist/assets/MarkdownViewerPlugin-xjmrqv_8.js +0 -308
  184. package/src/ui/dist/assets/MarketplacePlugin-mMM2A8wP.js +0 -413
  185. package/src/ui/dist/assets/NotebookEditor-3kVDSOBo.js +0 -4214
  186. package/src/ui/dist/assets/NotebookEditor-C3VQ7ylN.css +0 -1405
  187. package/src/ui/dist/assets/NotebookEditor-SoJ8X-MO.js +0 -84873
  188. package/src/ui/dist/assets/PdfLoader-C-Y707R3.css +0 -49
  189. package/src/ui/dist/assets/PdfLoader-DElVuHl9.js +0 -25468
  190. package/src/ui/dist/assets/PdfMarkdownPlugin-Bq88XT4G.js +0 -409
  191. package/src/ui/dist/assets/PdfViewerPlugin-CsCXMo9S.js +0 -3095
  192. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +0 -3627
  193. package/src/ui/dist/assets/SearchPlugin-DDMrGDkh.css +0 -379
  194. package/src/ui/dist/assets/SearchPlugin-oUPvy19k.js +0 -741
  195. package/src/ui/dist/assets/TextViewerPlugin-CRkT9yNy.js +0 -472
  196. package/src/ui/dist/assets/VNCViewer-BgbuvWhR.js +0 -18821
  197. package/src/ui/dist/assets/awareness-C0NPR2Dj.js +0 -292
  198. package/src/ui/dist/assets/bot-v_RASACv.js +0 -21
  199. package/src/ui/dist/assets/browser-BAcuE0Xj.js +0 -2895
  200. package/src/ui/dist/assets/code-5hC9d0VH.js +0 -17
  201. package/src/ui/dist/assets/file-content-D1PxfOrp.js +0 -377
  202. package/src/ui/dist/assets/file-diff-panel-DG1oT_Hj.js +0 -92
  203. package/src/ui/dist/assets/file-jump-queue-r5XKgJEV.js +0 -16
  204. package/src/ui/dist/assets/file-socket-BmdFYQlk.js +0 -58
  205. package/src/ui/dist/assets/function-B5QZkkHC.js +0 -1895
  206. package/src/ui/dist/assets/image-Dqe2X2tW.js +0 -18
  207. package/src/ui/dist/assets/index-BQG-1s2o.css +0 -12553
  208. package/src/ui/dist/assets/index-DVsMKK_y.js +0 -25
  209. package/src/ui/dist/assets/index-Duvz8Ip0.js +0 -159
  210. package/src/ui/dist/assets/index-Nt9hS4ck.js +0 -244829
  211. package/src/ui/dist/assets/index-RDlNXXx1.js +0 -120
  212. package/src/ui/dist/assets/monaco-DIXge1CP.js +0 -623
  213. package/src/ui/dist/assets/pdf-effect-queue-BBTTQaO-.js +0 -47
  214. package/src/ui/dist/assets/pdf_viewer-e0g1is2C.js +0 -8206
  215. package/src/ui/dist/assets/popover-BWlolyxo.js +0 -476
  216. package/src/ui/dist/assets/project-sync-BM5PkFH4.js +0 -297
  217. package/src/ui/dist/assets/select-D4dAtrA8.js +0 -1690
  218. package/src/ui/dist/assets/sigma-CKbE5jJT.js +0 -22
  219. package/src/ui/dist/assets/square-check-big-CZNGMgiB.js +0 -17
  220. package/src/ui/dist/assets/trash-DaB37xAz.js +0 -32
  221. package/src/ui/dist/assets/useCliAccess-C2OmAcWe.js +0 -957
  222. package/src/ui/dist/assets/useFileDiffOverlay-Dowd1Ij4.js +0 -53
  223. package/src/ui/dist/assets/wrap-text-BGjAhAUq.js +0 -35
  224. package/src/ui/dist/assets/yjs-DncrqiZ8.js +0 -11243
  225. package/src/ui/dist/assets/zoom-out-dMZQMXzc.js +0 -34
@@ -11,6 +11,7 @@ from typing import Any
11
11
  from urllib.error import URLError
12
12
  from urllib.request import Request, urlopen
13
13
 
14
+ from .bash_exec.shells import build_exec_shell_launch, build_terminal_shell_launch
14
15
  from .config import ConfigManager
15
16
  from .home import ensure_home_layout
16
17
  from .runtime_tools import RuntimeToolService
@@ -321,6 +322,55 @@ def _check_bundles(repo_root: Path) -> dict[str, Any]:
321
322
  )
322
323
 
323
324
 
325
+ def _check_shell_backend() -> dict[str, Any]:
326
+ exec_launch = build_exec_shell_launch("echo ok")
327
+ terminal_launch = build_terminal_shell_launch(Path("doctor-terminal-probe"))
328
+
329
+ def resolve_binary(binary: str) -> str | None:
330
+ candidate = str(binary or "").strip()
331
+ if not candidate:
332
+ return None
333
+ if os.path.isabs(candidate) or os.path.sep in candidate or (os.path.altsep and os.path.altsep in candidate):
334
+ return candidate if Path(candidate).exists() else None
335
+ return which(candidate)
336
+
337
+ details = {
338
+ "exec_shell": exec_launch.shell_name,
339
+ "exec_shell_family": exec_launch.family,
340
+ "exec_argv": exec_launch.argv,
341
+ "terminal_shell": terminal_launch.shell_name,
342
+ "terminal_shell_family": terminal_launch.family,
343
+ "terminal_argv": terminal_launch.argv,
344
+ }
345
+ warnings: list[str] = []
346
+ guidance: list[str] = []
347
+ errors: list[str] = []
348
+
349
+ exec_binary = resolve_binary(exec_launch.argv[0])
350
+ terminal_binary = resolve_binary(terminal_launch.argv[0])
351
+ details["exec_resolved_binary"] = exec_binary
352
+ details["terminal_resolved_binary"] = terminal_binary
353
+
354
+ if sys.platform == "win32":
355
+ warnings.append("Native Windows support is currently experimental; WSL2 remains the most battle-tested path.")
356
+ if not exec_binary:
357
+ errors.append("DeepScientist could not resolve a Windows command shell for bash_exec.")
358
+ guidance.append("Install PowerShell (`pwsh`) or ensure `powershell.exe` is available on PATH.")
359
+ if not terminal_binary:
360
+ errors.append("DeepScientist could not resolve a Windows interactive shell backend.")
361
+ guidance.append("Ensure `powershell.exe` is available on PATH for the interactive terminal surface.")
362
+ return _make_check(
363
+ check_id="shell_backend",
364
+ label="Shell backend",
365
+ ok=len(errors) == 0,
366
+ summary="DeepScientist resolved platform shell backends for command and terminal sessions." if len(errors) == 0 else "DeepScientist could not resolve a required shell backend.",
367
+ warnings=warnings,
368
+ errors=errors,
369
+ guidance=guidance,
370
+ details=details,
371
+ )
372
+
373
+
324
374
  def _check_latex_runtime(home: Path) -> dict[str, Any]:
325
375
  runtime = RuntimeToolService(home).status("tinytex")
326
376
  pdflatex = runtime.get("binaries", {}).get("pdflatex") or {}
@@ -436,6 +486,7 @@ def run_doctor(home: Path, *, repo_root: Path) -> dict[str, Any]:
436
486
  _check_python_runtime(),
437
487
  _check_home_writable(home),
438
488
  _check_uv(home),
489
+ _check_shell_backend(),
439
490
  _check_git(config_manager),
440
491
  _check_config_validation(config_manager),
441
492
  _check_runner_support(config_manager),
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Iterator, TextIO
7
+
8
+ if os.name == "nt": # pragma: no cover - exercised on Windows
9
+ import msvcrt
10
+ else: # pragma: no cover - exercised on POSIX
11
+ import fcntl
12
+
13
+
14
+ def _ensure_lockable_file(handle: TextIO) -> None:
15
+ handle.seek(0, os.SEEK_END)
16
+ if handle.tell() > 0:
17
+ handle.seek(0)
18
+ return
19
+ handle.write("\0")
20
+ handle.flush()
21
+ handle.seek(0)
22
+
23
+
24
+ def _lock_handle(handle: TextIO) -> None:
25
+ if os.name == "nt": # pragma: no cover - exercised on Windows
26
+ _ensure_lockable_file(handle)
27
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
28
+ return
29
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
30
+
31
+
32
+ def _unlock_handle(handle: TextIO) -> None:
33
+ if os.name == "nt": # pragma: no cover - exercised on Windows
34
+ handle.seek(0)
35
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
36
+ return
37
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
38
+
39
+
40
+ @contextmanager
41
+ def advisory_file_lock(path: Path) -> Iterator[TextIO]:
42
+ path.parent.mkdir(parents=True, exist_ok=True)
43
+ with path.open("a+", encoding="utf-8") as handle:
44
+ _lock_handle(handle)
45
+ try:
46
+ yield handle
47
+ finally:
48
+ _unlock_handle(handle)
@@ -1,4 +1,12 @@
1
- from .diff import commit_detail, compare_refs, diff_file_between_refs, diff_file_for_commit, list_branch_canvas, log_ref_history
1
+ from .diff import (
2
+ commit_detail,
3
+ compare_refs,
4
+ diff_file_between_refs,
5
+ diff_file_for_commit,
6
+ list_branch_canvas,
7
+ list_commit_canvas,
8
+ log_ref_history,
9
+ )
2
10
  from .graph import export_git_graph
3
11
  from .service import (
4
12
  branch_exists,
@@ -26,5 +34,6 @@ __all__ = [
26
34
  "head_commit",
27
35
  "init_repo",
28
36
  "list_branch_canvas",
37
+ "list_commit_canvas",
29
38
  "log_ref_history",
30
39
  ]
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
  from typing import Any
6
6
 
7
7
  from ..artifact.metrics import extract_latest_metric
8
- from ..shared import read_json, run_command
8
+ from ..shared import read_json, run_command, slugify
9
9
  from .service import branch_exists, current_branch, head_commit
10
10
 
11
11
 
@@ -68,6 +68,12 @@ def list_branch_canvas(repo: Path, *, quest_id: str) -> dict[str, Any]:
68
68
  "run_id": state.get("run_id"),
69
69
  "run_kind": state.get("run_kind"),
70
70
  "idea_id": state.get("idea_id"),
71
+ "paper_line_id": state.get("paper_line_id"),
72
+ "paper_line_branch": state.get("paper_line_branch"),
73
+ "selected_outline_ref": state.get("selected_outline_ref"),
74
+ "source_branch": state.get("source_branch"),
75
+ "source_run_id": state.get("source_run_id"),
76
+ "source_idea_id": state.get("source_idea_id"),
71
77
  "parent_branch_recorded": state.get("parent_branch"),
72
78
  "worktree_root": state.get("worktree_root"),
73
79
  "latest_metric": state.get("latest_metric"),
@@ -107,6 +113,67 @@ def list_branch_canvas(repo: Path, *, quest_id: str) -> dict[str, Any]:
107
113
  }
108
114
 
109
115
 
116
+ def list_commit_canvas(repo: Path, *, quest_id: str, limit: int = 80) -> dict[str, Any]:
117
+ resolved_limit = max(1, min(int(limit), 200))
118
+ head = head_commit(repo)
119
+ current_ref = current_branch(repo)
120
+ commits = _git_commit_canvas_log(repo, limit=resolved_limit)
121
+ nodes: list[dict[str, Any]] = []
122
+ edges: list[dict[str, Any]] = []
123
+ seen_shas = {item["sha"] for item in commits if str(item.get("sha") or "").strip()}
124
+
125
+ for commit in commits:
126
+ sha = str(commit.get("sha") or "").strip()
127
+ if not sha:
128
+ continue
129
+ detail = commit_detail(repo, sha=sha)
130
+ parents = [str(item).strip() for item in (detail.get("parents") or []) if str(item).strip()]
131
+ files = detail.get("files") or []
132
+ changed_paths = [
133
+ str(item.get("path") or "").strip()
134
+ for item in files
135
+ if str(item.get("path") or "").strip()
136
+ ]
137
+ node = {
138
+ "sha": sha,
139
+ "short_sha": str(detail.get("short_sha") or commit.get("short_sha") or sha[:7]).strip(),
140
+ "parents": parents,
141
+ "subject": str(detail.get("subject") or commit.get("subject") or "").strip() or sha[:7],
142
+ "body_preview": _body_preview(str(detail.get("body") or "").strip()),
143
+ "authored_at": str(detail.get("authored_at") or commit.get("authored_at") or "").strip() or None,
144
+ "author_name": str(detail.get("author_name") or commit.get("author_name") or "").strip() or None,
145
+ "branch_refs": _normalize_branch_refs(commit.get("decorations")),
146
+ "current": bool(head and sha == head),
147
+ "active_workspace": bool(head and sha == head),
148
+ "changed_paths": changed_paths,
149
+ "file_count": int(detail.get("file_count") or len(files)),
150
+ "added": int(((detail.get("stats") or {}) if isinstance(detail.get("stats"), dict) else {}).get("added") or 0),
151
+ "removed": int(((detail.get("stats") or {}) if isinstance(detail.get("stats"), dict) else {}).get("removed") or 0),
152
+ "compare_base": parents[0] if parents else None,
153
+ "compare_head": sha,
154
+ "selection_type": "git_commit_node",
155
+ }
156
+ nodes.append(node)
157
+ for parent in parents:
158
+ if parent in seen_shas:
159
+ edges.append(
160
+ {
161
+ "from": parent,
162
+ "to": sha,
163
+ "relation": "parent",
164
+ }
165
+ )
166
+
167
+ return {
168
+ "quest_id": quest_id,
169
+ "workspace_mode": "copilot",
170
+ "head": head,
171
+ "current_ref": current_ref,
172
+ "nodes": nodes,
173
+ "edges": edges,
174
+ }
175
+
176
+
110
177
  def compare_refs(repo: Path, *, base: str, head: str) -> dict[str, Any]:
111
178
  _require_ref(repo, base)
112
179
  _require_ref(repo, head)
@@ -356,9 +423,158 @@ def _collect_branch_state(repo: Path) -> dict[str, dict[str, Any]]:
356
423
  for item in state.get("recent_artifacts", []):
357
424
  if isinstance(item, dict):
358
425
  item.pop("_sort_key", None)
426
+ for workspace_root in _canvas_workspace_roots(repo):
427
+ state_path = workspace_root / "paper" / "paper_line_state.json"
428
+ if not state_path.exists():
429
+ continue
430
+ payload = read_json(state_path, {})
431
+ if not isinstance(payload, dict) or not payload:
432
+ continue
433
+ paper_branch = str(payload.get("paper_branch") or "").strip() or current_branch(workspace_root)
434
+ if not paper_branch:
435
+ continue
436
+ state = branch_state[paper_branch]
437
+ state.setdefault("branch", paper_branch)
438
+ state["worktree_root"] = str(workspace_root)
439
+ state["paper_line_id"] = str(payload.get("paper_line_id") or "").strip() or state.get("paper_line_id")
440
+ state["paper_line_branch"] = paper_branch
441
+ state["selected_outline_ref"] = str(payload.get("selected_outline_ref") or "").strip() or state.get("selected_outline_ref")
442
+ state["source_branch"] = str(payload.get("source_branch") or "").strip() or state.get("source_branch")
443
+ state["source_run_id"] = str(payload.get("source_run_id") or "").strip() or state.get("source_run_id")
444
+ state["source_idea_id"] = str(payload.get("source_idea_id") or "").strip() or state.get("source_idea_id")
445
+ state["updated_at"] = str(payload.get("updated_at") or state.get("updated_at") or "")
446
+ if not state.get("parent_branch") and state.get("source_branch"):
447
+ state["parent_branch"] = state.get("source_branch")
448
+ for workspace_root in _canvas_workspace_roots(repo):
449
+ paper_root = workspace_root / "paper"
450
+ if not paper_root.exists():
451
+ continue
452
+ state_path = paper_root / "paper_line_state.json"
453
+ if state_path.exists():
454
+ continue
455
+ selected_outline = read_json(paper_root / "selected_outline.json", {})
456
+ bundle_manifest = read_json(paper_root / "paper_bundle_manifest.json", {})
457
+ selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
458
+ bundle_manifest = bundle_manifest if isinstance(bundle_manifest, dict) else {}
459
+ if not selected_outline and not bundle_manifest:
460
+ continue
461
+ paper_branch = str(bundle_manifest.get("paper_branch") or "").strip() or current_branch(workspace_root)
462
+ if not paper_branch:
463
+ continue
464
+ selected_outline_ref = str(
465
+ selected_outline.get("outline_id") or bundle_manifest.get("selected_outline_ref") or ""
466
+ ).strip() or None
467
+ source_run_id = str(bundle_manifest.get("source_run_id") or "").strip() or None
468
+ state = branch_state[paper_branch]
469
+ state.setdefault("branch", paper_branch)
470
+ state["worktree_root"] = str(workspace_root)
471
+ state["paper_line_id"] = state.get("paper_line_id") or slugify(
472
+ "::".join([paper_branch or "paper", selected_outline_ref or "outline", source_run_id or "run"]),
473
+ "paper-line",
474
+ )
475
+ state["paper_line_branch"] = paper_branch
476
+ state["selected_outline_ref"] = selected_outline_ref or state.get("selected_outline_ref")
477
+ state["source_branch"] = str(bundle_manifest.get("source_branch") or "").strip() or state.get("source_branch")
478
+ state["source_run_id"] = source_run_id or state.get("source_run_id")
479
+ state["source_idea_id"] = str(bundle_manifest.get("source_idea_id") or "").strip() or state.get("source_idea_id")
480
+ if not state.get("parent_branch") and state.get("source_branch"):
481
+ state["parent_branch"] = state.get("source_branch")
482
+ campaigns_root = repo / ".ds" / "analysis_campaigns"
483
+ if campaigns_root.exists():
484
+ for path in sorted(campaigns_root.glob("*.json")):
485
+ manifest = read_json(path, {})
486
+ if not isinstance(manifest, dict) or not manifest:
487
+ continue
488
+ campaign_id = str(manifest.get("campaign_id") or path.stem).strip() or path.stem
489
+ paper_line_id = str(manifest.get("paper_line_id") or "").strip() or None
490
+ paper_line_branch = str(manifest.get("paper_line_branch") or "").strip() or None
491
+ analysis_parent_branch = str(manifest.get("parent_branch") or "").strip() or None
492
+ selected_outline_ref = str(manifest.get("selected_outline_ref") or "").strip() or None
493
+ source_idea_id = str(manifest.get("active_idea_id") or "").strip() or None
494
+ if not paper_line_branch:
495
+ paper_line_branch = _infer_paper_line_branch_for_campaign(
496
+ manifest,
497
+ branch_state=branch_state,
498
+ )
499
+ for item in manifest.get("slices") or []:
500
+ if not isinstance(item, dict):
501
+ continue
502
+ branch_name = str(item.get("branch") or "").strip()
503
+ if not branch_name:
504
+ continue
505
+ state = branch_state[branch_name]
506
+ state.setdefault("branch", branch_name)
507
+ state["campaign_id"] = campaign_id
508
+ state["paper_line_id"] = paper_line_id or state.get("paper_line_id")
509
+ state["paper_line_branch"] = paper_line_branch or state.get("paper_line_branch")
510
+ state["analysis_parent_branch"] = analysis_parent_branch or state.get("analysis_parent_branch")
511
+ state["selected_outline_ref"] = selected_outline_ref or state.get("selected_outline_ref")
512
+ state["source_idea_id"] = source_idea_id or state.get("source_idea_id")
513
+ if item.get("worktree_root"):
514
+ state["worktree_root"] = item.get("worktree_root")
359
515
  return branch_state
360
516
 
361
517
 
518
+ def _canvas_workspace_roots(repo: Path) -> list[Path]:
519
+ roots: list[Path] = [repo]
520
+ research_state = read_json(repo / ".ds" / "research_state.json", {})
521
+ preferred_raw = str((research_state or {}).get("research_head_worktree_root") or "").strip()
522
+ if preferred_raw:
523
+ preferred = Path(preferred_raw)
524
+ if preferred.exists():
525
+ roots.append(preferred)
526
+ worktrees_root = repo / ".ds" / "worktrees"
527
+ if worktrees_root.exists():
528
+ roots.extend(path for path in sorted(worktrees_root.iterdir()) if path.is_dir())
529
+ deduped: list[Path] = []
530
+ seen: set[str] = set()
531
+ for root in roots:
532
+ key = str(root.resolve())
533
+ if key in seen:
534
+ continue
535
+ seen.add(key)
536
+ deduped.append(root)
537
+ return deduped
538
+
539
+
540
+ def _infer_paper_line_branch_for_campaign(
541
+ manifest: dict[str, Any],
542
+ *,
543
+ branch_state: dict[str, dict[str, Any]],
544
+ ) -> str | None:
545
+ selected_outline_ref = str(manifest.get("selected_outline_ref") or "").strip() or None
546
+ source_idea_id = str(manifest.get("active_idea_id") or "").strip() or None
547
+ source_run_id = str(manifest.get("parent_run_id") or "").strip() or None
548
+ source_branch = str(manifest.get("parent_branch") or "").strip() or None
549
+ ranked: list[tuple[int, str]] = []
550
+ for branch_name, state in branch_state.items():
551
+ if not branch_name.startswith("paper/"):
552
+ continue
553
+ score = 0
554
+ candidate_outline = str(state.get("selected_outline_ref") or "").strip() or None
555
+ candidate_idea = str(state.get("source_idea_id") or "").strip() or None
556
+ candidate_run = str(state.get("source_run_id") or "").strip() or None
557
+ candidate_branch = str(state.get("source_branch") or "").strip() or None
558
+ if selected_outline_ref:
559
+ if candidate_outline != selected_outline_ref:
560
+ continue
561
+ score += 2
562
+ if source_idea_id and candidate_idea == source_idea_id:
563
+ score += 4
564
+ if source_run_id and candidate_run == source_run_id:
565
+ score += 3
566
+ if source_branch and candidate_branch == source_branch:
567
+ score += 2
568
+ if score > 0:
569
+ ranked.append((score, branch_name))
570
+ if not ranked:
571
+ return None
572
+ ranked.sort(key=lambda item: (item[0], item[1]), reverse=True)
573
+ if len(ranked) > 1 and ranked[0][0] == ranked[1][0]:
574
+ return None
575
+ return ranked[0][1]
576
+
577
+
362
578
  def _resolve_run_result_payload(repo: Path, record: dict[str, Any]) -> dict[str, Any]:
363
579
  details = dict(record.get("details") or {}) if isinstance(record.get("details"), dict) else {}
364
580
  paths = dict(record.get("paths") or {}) if isinstance(record.get("paths"), dict) else {}
@@ -469,9 +685,17 @@ def _infer_parent_ref(
469
685
  ) -> str | None:
470
686
  if ref == default_ref:
471
687
  return None
688
+ if classifications[ref]["branch_kind"] == "analysis":
689
+ paper_line_branch = str(state.get("paper_line_branch") or "").strip()
690
+ if paper_line_branch and paper_line_branch in refs and paper_line_branch != ref:
691
+ return paper_line_branch
472
692
  parent_branch = str(state.get("parent_branch") or "").strip()
473
693
  if parent_branch and parent_branch in refs and parent_branch != ref:
474
694
  return parent_branch
695
+ if classifications[ref]["branch_kind"] == "paper":
696
+ source_branch = str(state.get("source_branch") or "").strip()
697
+ if source_branch and source_branch in refs and source_branch != ref:
698
+ return source_branch
475
699
  if ref.startswith("idea/"):
476
700
  return default_ref
477
701
  if state.get("idea_id"):
@@ -479,6 +703,9 @@ def _infer_parent_ref(
479
703
  if candidate in refs:
480
704
  return candidate
481
705
  if classifications[ref]["branch_kind"] == "analysis":
706
+ analysis_parent_branch = str(state.get("analysis_parent_branch") or "").strip()
707
+ if analysis_parent_branch and analysis_parent_branch in refs and analysis_parent_branch != ref:
708
+ return analysis_parent_branch
482
709
  major_refs = [
483
710
  candidate
484
711
  for candidate, meta in classifications.items()
@@ -609,6 +836,74 @@ def _git_log(repo: Path, *, revspec: str, limit: int = 30) -> list[dict[str, Any
609
836
  return commits
610
837
 
611
838
 
839
+ def _git_commit_canvas_log(repo: Path, *, limit: int = 80) -> list[dict[str, Any]]:
840
+ result = _git_stdout(
841
+ repo,
842
+ [
843
+ "log",
844
+ "--all",
845
+ "--topo-order",
846
+ "--date=iso-strict",
847
+ f"-n{limit}",
848
+ "--decorate=short",
849
+ "--pretty=format:%H%x1f%h%x1f%P%x1f%ad%x1f%an%x1f%s%x1f%b%x1f%D",
850
+ ],
851
+ )
852
+ commits: list[dict[str, Any]] = []
853
+ for line in result.splitlines():
854
+ if not line.strip():
855
+ continue
856
+ sha, short_sha, parents_raw, authored_at, author_name, subject, body, decorations = (
857
+ line.split("\x1f") + ["", "", "", "", "", "", "", ""]
858
+ )[:8]
859
+ commits.append(
860
+ {
861
+ "sha": sha.strip(),
862
+ "short_sha": short_sha.strip(),
863
+ "parents": [item for item in parents_raw.strip().split() if item],
864
+ "authored_at": authored_at.strip(),
865
+ "author_name": author_name.strip(),
866
+ "subject": subject.strip(),
867
+ "body": body.strip(),
868
+ "decorations": decorations.strip(),
869
+ }
870
+ )
871
+ return commits
872
+
873
+
874
+ def _normalize_branch_refs(raw: Any) -> list[str]:
875
+ text = str(raw or "").strip()
876
+ if not text:
877
+ return []
878
+ refs: list[str] = []
879
+ for part in text.split(","):
880
+ cleaned = part.strip()
881
+ if not cleaned:
882
+ continue
883
+ if cleaned.startswith("HEAD -> "):
884
+ cleaned = cleaned[len("HEAD -> ") :].strip()
885
+ if cleaned.startswith("tag: "):
886
+ continue
887
+ if cleaned.startswith("origin/"):
888
+ continue
889
+ if cleaned == "HEAD":
890
+ continue
891
+ refs.append(cleaned)
892
+ return refs
893
+
894
+
895
+ def _body_preview(body: str, *, max_lines: int = 3, max_chars: int = 220) -> str | None:
896
+ if not body:
897
+ return None
898
+ lines = [line.strip() for line in body.splitlines() if line.strip()]
899
+ if not lines:
900
+ return None
901
+ preview = " ".join(lines[:max_lines]).strip()
902
+ if len(preview) > max_chars:
903
+ preview = preview[: max_chars - 1].rstrip() + "…"
904
+ return preview or None
905
+
906
+
612
907
  def _normalize_patch_lines(patch: str) -> list[str]:
613
908
  lines = [line.rstrip("\n") for line in patch.splitlines()]
614
909
  if not lines:
@@ -100,7 +100,10 @@ def checkpoint_repo(repo: Path, message: str, allow_empty: bool = False) -> dict
100
100
  "branch": current_branch(repo),
101
101
  "head": head_commit(repo),
102
102
  }
103
- result = run_command(["git", "commit", "-m", message], cwd=repo, check=False)
103
+ command = ["git", "commit", "-m", message]
104
+ if allow_empty:
105
+ command.insert(2, "--allow-empty")
106
+ result = run_command(command, cwd=repo, check=False)
104
107
  return {
105
108
  "committed": result.returncode == 0,
106
109
  "branch": current_branch(repo),