@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
@@ -0,0 +1,6 @@
1
+ from .runner_failures import FailureDiagnosis, diagnose_runner_failure
2
+
3
+ __all__ = [
4
+ "FailureDiagnosis",
5
+ "diagnose_runner_failure",
6
+ ]
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class FailureDiagnosis:
8
+ code: str
9
+ problem: str
10
+ why: str
11
+ guidance: tuple[str, ...]
12
+ retriable: bool
13
+ matched_text: str | None = None
14
+
15
+
16
+ _MODEL_UNAVAILABLE_MARKERS = (
17
+ "unknown model",
18
+ "invalid model",
19
+ "model not found",
20
+ "unsupported model",
21
+ "model is not available",
22
+ "not authorized to use model",
23
+ "you do not have access",
24
+ "access to model",
25
+ "model access",
26
+ "unrecognized model",
27
+ )
28
+
29
+
30
+ def _build_haystack(*values: object) -> str:
31
+ return "\n".join(str(value or "") for value in values if str(value or "").strip())
32
+
33
+
34
+ def _contains(text: str, marker: str) -> bool:
35
+ return marker in text.lower()
36
+
37
+
38
+ def diagnose_runner_failure(
39
+ *,
40
+ runner_name: str,
41
+ summary: str = "",
42
+ stderr_text: str = "",
43
+ output_text: str = "",
44
+ ) -> FailureDiagnosis | None:
45
+ haystack = _build_haystack(summary, stderr_text, output_text)
46
+ lower = haystack.lower()
47
+ normalized_runner = str(runner_name or "").strip().lower()
48
+
49
+ if (
50
+ "tool call result does not follow tool call (2013)" in lower
51
+ or "tool result's tool id" in lower
52
+ ):
53
+ return FailureDiagnosis(
54
+ code="minimax_tool_result_sequence_error",
55
+ problem="MiniMax rejected the tool result sequence.",
56
+ why=(
57
+ "The tool result did not immediately follow the corresponding tool call, "
58
+ "or the tool result referenced a tool call id that was no longer valid."
59
+ ),
60
+ guidance=(
61
+ "Keep each tool result immediately after its matching tool call.",
62
+ "Do not insert an extra assistant message between a tool call and its tool result.",
63
+ "For MiniMax chat-wire sessions, serialize tool use one call at a time.",
64
+ ),
65
+ retriable=False,
66
+ matched_text="2013",
67
+ )
68
+
69
+ if (
70
+ "invalid function arguments json string" in lower
71
+ or "failed to parse tool call arguments" in lower
72
+ or "trailing characters at line 1 column" in lower
73
+ ):
74
+ return FailureDiagnosis(
75
+ code="chat_wire_tool_argument_parse_error",
76
+ problem="The runner emitted malformed tool-call arguments.",
77
+ why=(
78
+ "The tool-call arguments were not a single valid JSON object. "
79
+ "This usually happens when multiple tool calls are batched into one response "
80
+ "or when the arguments string contains trailing characters."
81
+ ),
82
+ guidance=(
83
+ "Serialize tool calls one at a time instead of batching multiple MCP calls together.",
84
+ "Make sure each tool call emits exactly one complete JSON object for its arguments.",
85
+ "If this is a MiniMax chat-wire path, stay on the serialized single-tool compatibility route.",
86
+ ),
87
+ retriable=False,
88
+ matched_text="tool-call arguments",
89
+ )
90
+
91
+ if "missing environment variable" in lower:
92
+ return FailureDiagnosis(
93
+ code="provider_env_missing",
94
+ problem="A required provider environment variable is missing.",
95
+ why="The configured model provider expects an API key or env var that was not present in the runner environment.",
96
+ guidance=(
97
+ "Set the required key in `~/DeepScientist/config/runners.yaml` under `runners.codex.env`.",
98
+ "If you launch from a shell, export the provider key in that same shell before starting `ds`.",
99
+ ),
100
+ retriable=False,
101
+ matched_text="missing environment variable",
102
+ )
103
+
104
+ if any(marker in lower for marker in _MODEL_UNAVAILABLE_MARKERS):
105
+ return FailureDiagnosis(
106
+ code="runner_model_unavailable",
107
+ problem="The configured runner model is not available.",
108
+ why="The selected provider or Codex account could not access the requested model id.",
109
+ guidance=(
110
+ "Set `model: inherit` for provider-backed Codex profiles unless the provider explicitly supports the model id.",
111
+ "If you need a fixed model, verify that the same model works in plain `codex exec` before retrying DeepScientist.",
112
+ ),
113
+ retriable=False,
114
+ matched_text="model unavailable",
115
+ )
116
+
117
+ if normalized_runner == "codex" and "invalid params" in lower and "bad_request_error" in lower:
118
+ return FailureDiagnosis(
119
+ code="provider_invalid_params",
120
+ problem="The provider rejected the request parameters.",
121
+ why="The upstream provider returned a deterministic request-shape error instead of a transient transport failure.",
122
+ guidance=(
123
+ "Inspect the immediately preceding tool call / tool result sequence for protocol ordering or JSON-shape mistakes.",
124
+ "Do not keep retrying the same request until the request payload or provider config is corrected.",
125
+ ),
126
+ retriable=False,
127
+ matched_text="invalid params",
128
+ )
129
+
130
+ return None
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from datetime import UTC, datetime, timedelta
3
4
  import os
4
5
  import socket
5
6
  import subprocess
@@ -13,9 +14,13 @@ from urllib.request import Request, urlopen
13
14
 
14
15
  from .bash_exec.shells import build_exec_shell_launch, build_terminal_shell_launch
15
16
  from .config import ConfigManager
17
+ from .diagnostics import diagnose_runner_failure
16
18
  from .home import ensure_home_layout
17
19
  from .runtime_tools import RuntimeToolService
18
- from .shared import resolve_runner_binary, utc_now
20
+ from .shared import read_json, read_jsonl_tail, resolve_runner_binary, utc_now
21
+
22
+
23
+ _RUNTIME_FAILURE_LOOKBACK = timedelta(hours=24)
19
24
 
20
25
 
21
26
  def _browser_ui_url(host: str, port: int) -> str:
@@ -42,6 +47,10 @@ def _make_check(
42
47
  errors: list[str] | None = None,
43
48
  guidance: list[str] | None = None,
44
49
  details: dict[str, Any] | None = None,
50
+ problem: str | None = None,
51
+ why: str | None = None,
52
+ fix: list[str] | None = None,
53
+ evidence: list[str] | None = None,
45
54
  ) -> dict[str, Any]:
46
55
  normalized_warnings = list(warnings or [])
47
56
  normalized_errors = list(errors or [])
@@ -55,6 +64,10 @@ def _make_check(
55
64
  "errors": normalized_errors,
56
65
  "guidance": list(guidance or []),
57
66
  "details": dict(details or {}),
67
+ "problem": str(problem or "").strip() or None,
68
+ "why": str(why or "").strip() or None,
69
+ "fix": [str(line) for line in (fix or []) if str(line).strip()],
70
+ "evidence": [str(line) for line in (evidence or []) if str(line).strip()],
58
71
  }
59
72
 
60
73
 
@@ -273,6 +286,13 @@ def _check_codex(config_manager: ConfigManager) -> dict[str, Any]:
273
286
  probe_warnings = [str(value) for value in probe.get("warnings") or []]
274
287
  probe_guidance = [str(value) for value in probe.get("guidance") or []]
275
288
  summary = str(probe.get("summary") or "Codex startup probe completed.")
289
+ probe_details = probe.get("details") if isinstance(probe.get("details"), dict) else {}
290
+ diagnosis = diagnose_runner_failure(
291
+ runner_name="codex",
292
+ summary="\n".join([summary, *probe_errors]),
293
+ stderr_text=str(probe_details.get("stderr_excerpt") or ""),
294
+ output_text=str(probe_details.get("stdout_excerpt") or ""),
295
+ )
276
296
  if probe.get("ok"):
277
297
  return _make_check(
278
298
  check_id="codex",
@@ -284,17 +304,188 @@ def _check_codex(config_manager: ConfigManager) -> dict[str, Any]:
284
304
  )
285
305
  if not probe_guidance:
286
306
  probe_guidance = [
287
- "Run `codex --login` (or `codex`) manually once and complete login, then retry `ds doctor`.",
307
+ "Run `codex login` (or just `codex`) manually once and complete login, then retry `ds doctor`.",
288
308
  ]
289
309
  return _make_check(
290
310
  check_id="codex",
291
311
  label="Codex CLI",
292
312
  ok=False,
293
- summary=summary,
313
+ summary=diagnosis.problem if diagnosis is not None else summary,
294
314
  warnings=probe_warnings,
295
315
  errors=probe_errors or ["Codex startup probe did not succeed."],
296
316
  guidance=probe_guidance,
297
317
  details={"resolved_binary": resolved_binary},
318
+ problem=diagnosis.problem if diagnosis is not None else None,
319
+ why=diagnosis.why if diagnosis is not None else None,
320
+ fix=list(diagnosis.guidance) if diagnosis is not None else None,
321
+ evidence=(
322
+ [f"matched: {diagnosis.matched_text}"] if diagnosis is not None and diagnosis.matched_text else None
323
+ ),
324
+ )
325
+
326
+
327
+ def _parse_timestamp(value: object) -> datetime | None:
328
+ normalized = str(value or "").strip()
329
+ if not normalized:
330
+ return None
331
+ candidate = normalized.replace("Z", "+00:00")
332
+ try:
333
+ parsed = datetime.fromisoformat(candidate)
334
+ except ValueError:
335
+ return None
336
+ if parsed.tzinfo is None:
337
+ parsed = parsed.replace(tzinfo=UTC)
338
+ return parsed.astimezone(UTC)
339
+
340
+
341
+ def _read_runtime_failure_record(home: Path) -> dict[str, Any] | None:
342
+ quests_root = home / "quests"
343
+ if not quests_root.exists():
344
+ return None
345
+
346
+ latest: dict[str, Any] | None = None
347
+ latest_at: datetime | None = None
348
+ cutoff = datetime.now(UTC) - _RUNTIME_FAILURE_LOOKBACK
349
+ interesting_types = {
350
+ "runner.turn_error",
351
+ "runner.turn_retry_exhausted",
352
+ "quest.runtime_auto_resume_suppressed",
353
+ }
354
+
355
+ for quest_root in sorted(quests_root.glob("*/")):
356
+ events = read_jsonl_tail(quest_root / ".ds" / "events.jsonl", 300)
357
+ for event in reversed(events):
358
+ event_type = str(event.get("type") or "").strip()
359
+ if event_type not in interesting_types:
360
+ continue
361
+ created_at = _parse_timestamp(event.get("created_at"))
362
+ if created_at is None or created_at < cutoff:
363
+ continue
364
+ run_id = str(event.get("run_id") or "").strip() or None
365
+ stderr_text = ""
366
+ output_text = ""
367
+ if run_id:
368
+ run_root = quest_root / ".ds" / "runs" / run_id
369
+ result_payload = read_json(run_root / "result.json", {})
370
+ if isinstance(result_payload, dict):
371
+ stderr_text = str(result_payload.get("stderr_text") or "").strip()
372
+ output_text = str(result_payload.get("output_text") or "").strip()
373
+ stderr_path = run_root / "stderr.txt"
374
+ if not stderr_text and stderr_path.exists():
375
+ try:
376
+ stderr_text = stderr_path.read_text(encoding="utf-8")
377
+ except OSError:
378
+ stderr_text = ""
379
+ candidate = {
380
+ "quest_id": quest_root.name,
381
+ "run_id": run_id,
382
+ "event_type": event_type,
383
+ "summary": str(event.get("summary") or "").strip(),
384
+ "created_at": created_at.isoformat(),
385
+ "stderr_text": stderr_text,
386
+ "output_text": output_text,
387
+ "recent_attempts": event.get("recent_attempts"),
388
+ }
389
+ if latest_at is None or created_at > latest_at:
390
+ latest = candidate
391
+ latest_at = created_at
392
+ break
393
+ return latest
394
+
395
+
396
+ def _check_recent_runtime_failures(home: Path) -> dict[str, Any]:
397
+ record = _read_runtime_failure_record(home)
398
+ if record is None:
399
+ return _make_check(
400
+ check_id="recent_runtime_failures",
401
+ label="Recent runtime failures",
402
+ ok=True,
403
+ summary="No recent quest runtime failures were found.",
404
+ )
405
+
406
+ event_type = str(record.get("event_type") or "").strip()
407
+ quest_id = str(record.get("quest_id") or "").strip() or None
408
+ run_id = str(record.get("run_id") or "").strip() or None
409
+ summary = str(record.get("summary") or "").strip()
410
+ details = {
411
+ "quest_id": quest_id,
412
+ "run_id": run_id,
413
+ "event_type": event_type,
414
+ "observed_at": record.get("created_at"),
415
+ }
416
+
417
+ if event_type == "quest.runtime_auto_resume_suppressed":
418
+ recent_attempts = int(record.get("recent_attempts") or 0)
419
+ return _make_check(
420
+ check_id="recent_runtime_failures",
421
+ label="Recent runtime failures",
422
+ ok=True,
423
+ summary="DeepScientist recently suppressed auto-resume to avoid a crash loop.",
424
+ warnings=["Automatic continuation was paused after repeated recovery attempts in a short window."],
425
+ guidance=[
426
+ "Inspect the most recent failing runner path before using `/resume` again.",
427
+ "If the failure was a provider-side 400/protocol error, fix that request path first instead of retrying immediately.",
428
+ ],
429
+ details=details,
430
+ problem="Automatic crash recovery was suppressed.",
431
+ why="The same quest hit repeated recovery attempts in a short window, so DeepScientist parked it instead of looping forever.",
432
+ fix=[
433
+ "Open the latest failing quest logs and identify the deterministic runner/provider error.",
434
+ "Resume manually only after the underlying runner or provider issue is corrected.",
435
+ ],
436
+ evidence=[
437
+ *( [f"quest: {quest_id}"] if quest_id else [] ),
438
+ f"recent recovery attempts: {recent_attempts}",
439
+ ],
440
+ )
441
+
442
+ diagnosis = diagnose_runner_failure(
443
+ runner_name="codex",
444
+ summary=summary,
445
+ stderr_text=str(record.get("stderr_text") or ""),
446
+ output_text=str(record.get("output_text") or ""),
447
+ )
448
+ if diagnosis is None:
449
+ return _make_check(
450
+ check_id="recent_runtime_failures",
451
+ label="Recent runtime failures",
452
+ ok=True,
453
+ summary="A recent quest runtime failure was found.",
454
+ warnings=[summary or "The latest quest run failed, but doctor could not classify it precisely yet."],
455
+ guidance=[
456
+ "Open the latest run stderr and events journal for the failing quest.",
457
+ "If the same failure repeats, capture the run_id and provider response text before retrying again.",
458
+ ],
459
+ details=details,
460
+ problem="A recent quest run failed.",
461
+ why="Doctor found a recent runtime failure event but could not match it to a known deterministic error pattern.",
462
+ fix=[
463
+ "Inspect the failing run's stderr and provider response text.",
464
+ "If the error is deterministic, avoid burning the retry budget until the request shape or config is fixed.",
465
+ ],
466
+ evidence=[
467
+ *( [f"quest: {quest_id}"] if quest_id else [] ),
468
+ *( [f"run: {run_id}"] if run_id else [] ),
469
+ *( [f"summary: {summary}"] if summary else [] ),
470
+ ],
471
+ )
472
+
473
+ return _make_check(
474
+ check_id="recent_runtime_failures",
475
+ label="Recent runtime failures",
476
+ ok=True,
477
+ summary=diagnosis.problem,
478
+ warnings=[summary] if summary and summary != diagnosis.problem else [],
479
+ guidance=list(diagnosis.guidance),
480
+ details=details,
481
+ problem=diagnosis.problem,
482
+ why=diagnosis.why,
483
+ fix=list(diagnosis.guidance),
484
+ evidence=[
485
+ *( [f"quest: {quest_id}"] if quest_id else [] ),
486
+ *( [f"run: {run_id}"] if run_id else [] ),
487
+ *( [f"matched: {diagnosis.matched_text}"] if diagnosis.matched_text else [] ),
488
+ ],
298
489
  )
299
490
 
300
491
 
@@ -491,6 +682,7 @@ def run_doctor(home: Path, *, repo_root: Path) -> dict[str, Any]:
491
682
  _check_config_validation(config_manager),
492
683
  _check_runner_support(config_manager),
493
684
  _check_codex(config_manager),
685
+ _check_recent_runtime_failures(home),
494
686
  _check_latex_runtime(home),
495
687
  _check_bundles(repo_root),
496
688
  _check_ui_port(home, config_manager),
@@ -519,6 +711,18 @@ def render_doctor_report(report: dict[str, Any]) -> str:
519
711
  status = str(item.get("status") or "ok").upper()
520
712
  icon = {"OK": "[ok]", "WARN": "[warn]", "ERROR": "[fail]"}.get(status, "[info]")
521
713
  lines.append(f"{icon} {item.get('label')}: {item.get('summary')}")
714
+ problem = str(item.get("problem") or "").strip()
715
+ why = str(item.get("why") or "").strip()
716
+ fix_lines = [str(line) for line in item.get("fix") or [] if str(line).strip()]
717
+ evidence_lines = [str(line) for line in item.get("evidence") or [] if str(line).strip()]
718
+ if problem:
719
+ lines.append(f" problem: {problem}")
720
+ if why:
721
+ lines.append(f" why: {why}")
722
+ for line in fix_lines:
723
+ lines.append(f" fix: {line}")
724
+ for line in evidence_lines:
725
+ lines.append(f" evidence: {line}")
522
726
  for warning in item.get("warnings") or []:
523
727
  lines.append(f" warning: {warning}")
524
728
  for error in item.get("errors") or []:
@@ -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
  ]
@@ -113,6 +113,67 @@ def list_branch_canvas(repo: Path, *, quest_id: str) -> dict[str, Any]:
113
113
  }
114
114
 
115
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
+
116
177
  def compare_refs(repo: Path, *, base: str, head: str) -> dict[str, Any]:
117
178
  _require_ref(repo, base)
118
179
  _require_ref(repo, head)
@@ -775,6 +836,74 @@ def _git_log(repo: Path, *, revspec: str, limit: int = 30) -> list[dict[str, Any
775
836
  return commits
776
837
 
777
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
+
778
907
  def _normalize_patch_lines(patch: str) -> list[str]:
779
908
  lines = [line.rstrip("\n") for line in patch.splitlines()]
780
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),
@@ -526,6 +526,45 @@ def build_artifact_server(context: McpContext) -> FastMCP:
526
526
  allow_empty=allow_empty,
527
527
  )
528
528
 
529
+ @server.tool(
530
+ name="git",
531
+ description=(
532
+ "Run git-oriented workspace operations for Copilot mode and general quest maintenance. "
533
+ "Use action=status|commit|branch|checkout|log|show|diff|graph."
534
+ ),
535
+ )
536
+ def git(
537
+ action: str,
538
+ message: str | None = None,
539
+ ref: str | None = None,
540
+ base: str | None = None,
541
+ head: str | None = None,
542
+ sha: str | None = None,
543
+ path: str | None = None,
544
+ branch: str | None = None,
545
+ create_from: str | None = None,
546
+ limit: int = 30,
547
+ allow_empty: bool = False,
548
+ checkout_new_branch: bool = False,
549
+ comment: str | dict[str, Any] | None = None,
550
+ ) -> dict[str, Any]:
551
+ return service.git_action(
552
+ context.require_quest_root(),
553
+ action=action,
554
+ workspace_root=context.worktree_root,
555
+ message=message,
556
+ ref=ref,
557
+ base=base,
558
+ head=head,
559
+ sha=sha,
560
+ path=path,
561
+ branch=branch,
562
+ create_from=create_from,
563
+ limit=limit,
564
+ allow_empty=allow_empty,
565
+ checkout_new_branch=checkout_new_branch,
566
+ )
567
+
529
568
  @server.tool(name="prepare_branch", description="Prepare an idea or run branch and optional worktree.")
530
569
  def prepare_branch(
531
570
  run_id: str | None = None,