@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
@@ -5,10 +5,10 @@ from collections import deque
5
5
  from contextlib import contextmanager
6
6
  from datetime import UTC, datetime, timedelta
7
7
  import hashlib
8
- import subprocess
9
8
  import json
10
9
  import mimetypes
11
10
  import re
11
+ import shutil
12
12
  import threading
13
13
  import time
14
14
  from pathlib import Path, PurePosixPath
@@ -24,10 +24,10 @@ from ..artifact.metrics import build_baseline_compare_payload, build_metrics_tim
24
24
  from ..config import ConfigManager
25
25
  from ..connector_runtime import conversation_identity_key, normalize_conversation_id, parse_conversation_id
26
26
  from ..file_lock import advisory_file_lock
27
- from ..gitops import current_branch, export_git_graph, head_commit, init_repo, list_branch_canvas
27
+ from ..gitops import current_branch, export_git_graph, head_commit, init_repo, list_branch_canvas, list_commit_canvas
28
28
  from ..home import repo_root
29
29
  from ..registries import BaselineRegistry
30
- from ..shared import append_jsonl, ensure_dir, generate_id, iter_jsonl, read_json, read_jsonl, read_jsonl_tail, read_text, read_yaml, resolve_within, run_command, sha256_text, slugify, utc_now, write_json, write_text, write_yaml
30
+ from ..shared import append_jsonl, ensure_dir, generate_id, iter_jsonl, read_json, read_jsonl, read_jsonl_tail, read_text, read_yaml, resolve_within, run_command, run_command_bytes, sha256_text, slugify, utc_now, write_json, write_text, write_yaml
31
31
  from ..skills import SkillInstaller
32
32
  from ..web_search import extract_web_search_payload
33
33
  from .layout import (
@@ -172,6 +172,127 @@ def _iter_jsonl_records_safely(
172
172
  yield payload
173
173
 
174
174
 
175
+ def _parse_jsonl_record_line_safely(
176
+ raw_line: bytes,
177
+ *,
178
+ oversized_line_bytes: int = _EVENTS_OVERSIZED_LINE_BYTES,
179
+ ) -> dict[str, Any] | None:
180
+ raw = bytes(raw_line).strip()
181
+ if not raw:
182
+ return None
183
+ line_bytes = len(raw)
184
+ if line_bytes > oversized_line_bytes:
185
+ return _oversized_event_placeholder(
186
+ prefix=raw[:_OVERSIZED_EVENT_PREFIX_BYTES],
187
+ line_bytes=line_bytes,
188
+ )
189
+ try:
190
+ payload = json.loads(raw)
191
+ except json.JSONDecodeError:
192
+ return None
193
+ return payload if isinstance(payload, dict) else None
194
+
195
+
196
+ def _tail_jsonl_records_safely(
197
+ path: Path,
198
+ *,
199
+ limit: int,
200
+ oversized_line_bytes: int = _EVENTS_OVERSIZED_LINE_BYTES,
201
+ ) -> tuple[list[tuple[int, dict[str, Any]]], int]:
202
+ normalized_limit = max(int(limit or 0), 0)
203
+ if normalized_limit <= 0 or not path.exists():
204
+ return [], 0
205
+ total = _count_jsonl_lines_fast(path)
206
+ if total <= 0:
207
+ return [], 0
208
+
209
+ raw_tail = _read_jsonl_tail_lines_fast(path, normalized_limit)
210
+ if not raw_tail:
211
+ return [], total
212
+
213
+ cursor_start = max(total - len(raw_tail) + 1, 1)
214
+ parsed: list[tuple[int, dict[str, Any]]] = []
215
+ for cursor, raw_line in enumerate(raw_tail, start=cursor_start):
216
+ payload = _parse_jsonl_record_line_safely(
217
+ raw_line,
218
+ oversized_line_bytes=oversized_line_bytes,
219
+ )
220
+ if isinstance(payload, dict):
221
+ parsed.append((cursor, payload))
222
+ return parsed, total
223
+
224
+
225
+ def _count_jsonl_lines_fast(path: Path, *, chunk_size: int = 1024 * 1024) -> int:
226
+ if not path.exists():
227
+ return 0
228
+ total = 0
229
+ last_byte = b""
230
+ with path.open("rb") as handle:
231
+ while True:
232
+ chunk = handle.read(chunk_size)
233
+ if not chunk:
234
+ break
235
+ total += chunk.count(b"\n")
236
+ last_byte = chunk[-1:]
237
+ if total == 0 and last_byte:
238
+ return 1
239
+ if last_byte not in {b"", b"\n"}:
240
+ total += 1
241
+ return total
242
+
243
+
244
+ def _read_jsonl_tail_lines_fast(path: Path, limit: int, *, chunk_size: int = 1024 * 1024) -> list[bytes]:
245
+ normalized_limit = max(int(limit or 0), 0)
246
+ if normalized_limit <= 0 or not path.exists():
247
+ return []
248
+
249
+ size = path.stat().st_size
250
+ if size <= 0:
251
+ return []
252
+
253
+ lines: deque[bytes] = deque()
254
+ remainder = b""
255
+ with path.open("rb") as handle:
256
+ position = size
257
+ while position > 0 and len(lines) < normalized_limit:
258
+ read_size = min(chunk_size, position)
259
+ position -= read_size
260
+ handle.seek(position)
261
+ chunk = handle.read(read_size)
262
+ payload = chunk + remainder
263
+ parts = payload.split(b"\n")
264
+ remainder = parts[0]
265
+ for raw_line in reversed(parts[1:]):
266
+ stripped = raw_line.rstrip(b"\r")
267
+ if not stripped.strip():
268
+ continue
269
+ lines.appendleft(stripped)
270
+ if len(lines) >= normalized_limit:
271
+ break
272
+ if len(lines) < normalized_limit and remainder.strip():
273
+ lines.appendleft(remainder.rstrip(b"\r"))
274
+ return list(lines)[-normalized_limit:]
275
+
276
+
277
+ def _iter_jsonl_records_from_offset_safely(
278
+ path: Path,
279
+ *,
280
+ start_offset: int,
281
+ oversized_line_bytes: int = _EVENTS_OVERSIZED_LINE_BYTES,
282
+ ):
283
+ if not path.exists():
284
+ return
285
+ with path.open("rb") as handle:
286
+ handle.seek(max(int(start_offset or 0), 0))
287
+ for raw_line in handle:
288
+ payload = _parse_jsonl_record_line_safely(
289
+ raw_line,
290
+ oversized_line_bytes=oversized_line_bytes,
291
+ )
292
+ if isinstance(payload, dict):
293
+ yield payload
294
+
295
+
175
296
  class QuestService:
176
297
  def __init__(self, home: Path, skill_installer: SkillInstaller | None = None) -> None:
177
298
  self.home = home
@@ -182,6 +303,7 @@ class QuestService:
182
303
  self._file_cache: dict[str, dict[str, Any]] = {}
183
304
  self._jsonl_cache_lock = threading.Lock()
184
305
  self._jsonl_cache: dict[str, dict[str, Any]] = {}
306
+ self._jsonl_tail_cache: dict[str, dict[str, Any]] = {}
185
307
  self._snapshot_cache_lock = threading.Lock()
186
308
  self._snapshot_cache: dict[str, dict[str, Any]] = {}
187
309
  self._codex_history_cache_lock = threading.Lock()
@@ -200,6 +322,12 @@ class QuestService:
200
322
  def _quest_root(self, quest_id: str) -> Path:
201
323
  return self.quests_root / quest_id
202
324
 
325
+ def _require_initialized_quest_root(self, quest_id: str) -> Path:
326
+ quest_root = self._quest_root(quest_id)
327
+ if not quest_root.exists() or not self._quest_yaml_path(quest_root).exists():
328
+ raise FileNotFoundError(f"Unknown quest `{quest_id}`.")
329
+ return quest_root
330
+
203
331
  def _normalized_binding_sources(self, sources: list[Any] | None) -> list[str]:
204
332
  local_present = False
205
333
  external_source: str | None = None
@@ -288,6 +416,13 @@ class QuestService:
288
416
  return quest_root / ".ds" / "lab_canvas_state.json"
289
417
 
290
418
  def _default_research_state(self, quest_root: Path) -> dict[str, Any]:
419
+ quest_yaml = self.read_quest_yaml(quest_root)
420
+ startup_contract = (
421
+ dict(quest_yaml.get("startup_contract") or {})
422
+ if isinstance(quest_yaml.get("startup_contract"), dict)
423
+ else {}
424
+ )
425
+ workspace_mode = str(startup_contract.get("workspace_mode") or "").strip().lower() or "quest"
291
426
  return {
292
427
  "version": 1,
293
428
  "active_idea_id": None,
@@ -304,7 +439,7 @@ class QuestService:
304
439
  "paper_parent_worktree_root": None,
305
440
  "paper_parent_run_id": None,
306
441
  "next_pending_slice_id": None,
307
- "workspace_mode": "quest",
442
+ "workspace_mode": workspace_mode,
308
443
  "last_flow_type": None,
309
444
  "updated_at": utc_now(),
310
445
  }
@@ -354,7 +489,7 @@ class QuestService:
354
489
  continue
355
490
  current[key] = str(value) if isinstance(value, Path) else value
356
491
  payload = self.write_research_state(quest_root, current)
357
- self.schedule_projection_refresh(quest_root, kinds=("details", "canvas"))
492
+ self.schedule_projection_refresh(quest_root, kinds=("details", "canvas", "git_canvas"))
358
493
  return payload
359
494
 
360
495
  def read_lab_canvas_state(self, quest_root: Path) -> dict[str, Any]:
@@ -971,6 +1106,8 @@ class QuestService:
971
1106
  return self._details_projection_state(quest_root)
972
1107
  if kind == "canvas":
973
1108
  return self._canvas_projection_state(quest_root)
1109
+ if kind == "git_canvas":
1110
+ return self._canvas_projection_state(quest_root)
974
1111
  raise ValueError(f"Unsupported projection kind `{kind}`.")
975
1112
 
976
1113
  def _projection_source_signature(self, quest_root: Path, kind: str) -> str:
@@ -1434,6 +1571,17 @@ class QuestService:
1434
1571
  update_progress(2, "Computing branch canvas")
1435
1572
  return list_branch_canvas(quest_root, quest_id=quest_root.name)
1436
1573
 
1574
+ def _build_git_canvas_projection_payload(
1575
+ self,
1576
+ quest_root: Path,
1577
+ *,
1578
+ source_signature: str,
1579
+ update_progress: Any,
1580
+ ) -> dict[str, Any]:
1581
+ update_progress(1, "Scanning commit history")
1582
+ update_progress(2, "Computing commit canvas")
1583
+ return list_commit_canvas(quest_root, quest_id=quest_root.name)
1584
+
1437
1585
  def _build_projection_payload(
1438
1586
  self,
1439
1587
  quest_root: Path,
@@ -1454,6 +1602,12 @@ class QuestService:
1454
1602
  source_signature=source_signature,
1455
1603
  update_progress=update_progress,
1456
1604
  )
1605
+ if kind == "git_canvas":
1606
+ return self._build_git_canvas_projection_payload(
1607
+ quest_root,
1608
+ source_signature=source_signature,
1609
+ update_progress=update_progress,
1610
+ )
1457
1611
  raise ValueError(f"Unsupported projection kind `{kind}`.")
1458
1612
 
1459
1613
  def _placeholder_workflow_payload(self, quest_id: str, quest_root: Path) -> dict[str, Any]:
@@ -1486,6 +1640,17 @@ class QuestService:
1486
1640
  },
1487
1641
  }
1488
1642
 
1643
+ def _placeholder_git_canvas_payload(self, quest_id: str, quest_root: Path) -> dict[str, Any]:
1644
+ research_state = self.read_research_state(quest_root)
1645
+ return {
1646
+ "quest_id": quest_id,
1647
+ "workspace_mode": str(research_state.get("workspace_mode") or "copilot").strip() or "copilot",
1648
+ "head": head_commit(quest_root),
1649
+ "current_ref": current_branch(quest_root),
1650
+ "nodes": [],
1651
+ "edges": [],
1652
+ }
1653
+
1489
1654
  def _projected_payload(self, quest_id: str, kind: str) -> dict[str, Any]:
1490
1655
  quest_root = self._quest_root(quest_id)
1491
1656
  source_signature = self._projection_source_signature(quest_root, kind)
@@ -1510,11 +1675,12 @@ class QuestService:
1510
1675
  else None
1511
1676
  )
1512
1677
  if payload is None:
1513
- payload = (
1514
- self._placeholder_workflow_payload(quest_id, quest_root)
1515
- if kind == "details"
1516
- else self._placeholder_canvas_payload(quest_id, quest_root)
1517
- )
1678
+ if kind == "details":
1679
+ payload = self._placeholder_workflow_payload(quest_id, quest_root)
1680
+ elif kind == "git_canvas":
1681
+ payload = self._placeholder_git_canvas_payload(quest_id, quest_root)
1682
+ else:
1683
+ payload = self._placeholder_canvas_payload(quest_id, quest_root)
1518
1684
  payload["projection_status"] = status
1519
1685
  return payload
1520
1686
 
@@ -1535,8 +1701,8 @@ class QuestService:
1535
1701
  ) -> None:
1536
1702
  resolved_kinds = [
1537
1703
  str(kind).strip()
1538
- for kind in (kinds or ("details", "canvas"))
1539
- if str(kind).strip() in {"details", "canvas"}
1704
+ for kind in (kinds or ("details", "canvas", "git_canvas"))
1705
+ if str(kind).strip() in {"details", "canvas", "git_canvas"}
1540
1706
  ]
1541
1707
  if not resolved_kinds:
1542
1708
  return
@@ -1563,6 +1729,9 @@ class QuestService:
1563
1729
  def git_branch_canvas(self, quest_id: str) -> dict[str, Any]:
1564
1730
  return self._projected_payload(quest_id, "canvas")
1565
1731
 
1732
+ def git_commit_canvas(self, quest_id: str) -> dict[str, Any]:
1733
+ return self._projected_payload(quest_id, "git_canvas")
1734
+
1566
1735
  def _active_baseline_attachment(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
1567
1736
  attachments: list[dict[str, Any]] = []
1568
1737
  seen_paths: set[str] = set()
@@ -2602,7 +2771,7 @@ class QuestService:
2602
2771
  )
2603
2772
  write_text(quest_root / "brief.md", initial_brief(goal))
2604
2773
  write_text(quest_root / "plan.md", initial_plan())
2605
- write_text(quest_root / "status.md", initial_status())
2774
+ write_text(quest_root / "status.md", initial_status(startup_contract))
2606
2775
  write_text(quest_root / "SUMMARY.md", initial_summary())
2607
2776
  write_text(quest_root / ".gitignore", gitignore())
2608
2777
  self._write_active_user_requirements(
@@ -2619,6 +2788,86 @@ class QuestService:
2619
2788
  self._initialize_runtime_files(quest_root)
2620
2789
  return self.snapshot(quest_id)
2621
2790
 
2791
+ def repair_orphaned_quest_scaffold(
2792
+ self,
2793
+ quest_id: str,
2794
+ *,
2795
+ title: str | None = None,
2796
+ goal: str | None = None,
2797
+ runner: str = "codex",
2798
+ ) -> dict[str, Any]:
2799
+ quest_root = self._quest_root(quest_id)
2800
+ if not quest_root.exists():
2801
+ raise FileNotFoundError(f"Unknown quest `{quest_id}`.")
2802
+ quest_yaml_path = self._quest_yaml_path(quest_root)
2803
+ if quest_yaml_path.exists():
2804
+ raise FileExistsError(f"Quest `{quest_id}` already has a scaffold.")
2805
+
2806
+ restored_goal = str(goal or f"Recovered quest {quest_id}").strip() or f"Recovered quest {quest_id}"
2807
+ restored_title = str(title or quest_id).strip() or quest_id
2808
+
2809
+ for relative in QUEST_DIRECTORIES:
2810
+ ensure_dir(quest_root / relative)
2811
+
2812
+ write_yaml(
2813
+ quest_yaml_path,
2814
+ initial_quest_yaml(
2815
+ quest_id,
2816
+ restored_goal,
2817
+ quest_root,
2818
+ runner,
2819
+ title=restored_title,
2820
+ ),
2821
+ )
2822
+ write_text(
2823
+ quest_root / "brief.md",
2824
+ "\n".join(
2825
+ [
2826
+ "# Quest Brief",
2827
+ "",
2828
+ "## Recovery Note",
2829
+ "",
2830
+ "This quest scaffold was recreated because the core quest files were missing.",
2831
+ "Existing runtime traces under `.ds/` were preserved.",
2832
+ "",
2833
+ "## Goal",
2834
+ "",
2835
+ restored_goal,
2836
+ "",
2837
+ ]
2838
+ ),
2839
+ )
2840
+ write_text(
2841
+ quest_root / "plan.md",
2842
+ "\n".join(
2843
+ [
2844
+ "# Plan",
2845
+ "",
2846
+ "- [ ] Inspect preserved runtime traces under `.ds/`",
2847
+ "- [ ] Re-establish the baseline context",
2848
+ "- [ ] Recreate any missing durable files or artifacts",
2849
+ "",
2850
+ ]
2851
+ ),
2852
+ )
2853
+ write_text(
2854
+ quest_root / "status.md",
2855
+ "# Status\n\nRecovered scaffold. Review preserved runtime state before continuing.\n",
2856
+ )
2857
+ write_text(
2858
+ quest_root / "SUMMARY.md",
2859
+ "# Summary\n\nRecovered quest scaffold. Original top-level quest files were missing.\n",
2860
+ )
2861
+ write_text(quest_root / ".gitignore", gitignore())
2862
+ self._write_active_user_requirements(
2863
+ quest_root,
2864
+ latest_requirement=None,
2865
+ )
2866
+ if not (quest_root / ".git").exists():
2867
+ init_repo(quest_root)
2868
+ self._initialize_runtime_files(quest_root)
2869
+ return self.snapshot(quest_id)
2870
+
2622
2871
  def list_quests(self) -> list[dict]:
2623
2872
  items: list[dict] = []
2624
2873
  if not self.quests_root.exists():
@@ -2716,7 +2965,7 @@ class QuestService:
2716
2965
  )
2717
2966
 
2718
2967
  def summary_compact(self, quest_id: str) -> dict[str, Any]:
2719
- quest_root = self._quest_root(quest_id)
2968
+ quest_root = self._require_initialized_quest_root(quest_id)
2720
2969
  cache_key = f"compact:{self._cache_key_for_path(quest_root)}"
2721
2970
  state = self._compact_summary_state(quest_root)
2722
2971
  with self._snapshot_cache_lock:
@@ -2790,6 +3039,7 @@ class QuestService:
2790
3039
  "research_head_worktree_root": research_state.get("research_head_worktree_root"),
2791
3040
  "current_workspace_branch": research_state.get("current_workspace_branch"),
2792
3041
  "current_workspace_root": research_state.get("current_workspace_root"),
3042
+ "workspace_mode": research_state.get("workspace_mode") or "quest",
2793
3043
  "active_idea_id": research_state.get("active_idea_id"),
2794
3044
  "active_baseline_id": active_baseline_id,
2795
3045
  "active_baseline_variant_id": active_baseline_variant_id,
@@ -2876,8 +3126,8 @@ class QuestService:
2876
3126
  }
2877
3127
  return items
2878
3128
 
2879
- @staticmethod
2880
3129
  def _read_jsonl_cursor_slice(
3130
+ self,
2881
3131
  path: Path,
2882
3132
  *,
2883
3133
  after: int = 0,
@@ -2886,7 +3136,10 @@ class QuestService:
2886
3136
  tail: bool = False,
2887
3137
  ) -> tuple[list[tuple[int, dict[str, Any]]], int, bool]:
2888
3138
  normalized_limit = max(int(limit or 0), 0)
3139
+ cache_key = self._cache_key_for_path(path)
2889
3140
  if not path.exists():
3141
+ with self._jsonl_cache_lock:
3142
+ self._jsonl_tail_cache.pop(cache_key, None)
2890
3143
  return [], 0, False
2891
3144
  if normalized_limit <= 0:
2892
3145
  total = sum(1 for _ in _iter_jsonl_records_safely(path))
@@ -2905,11 +3158,71 @@ class QuestService:
2905
3158
  return list(window), total, has_more
2906
3159
 
2907
3160
  if tail:
2908
- window = deque(maxlen=normalized_limit)
2909
- total = 0
2910
- for payload in _iter_jsonl_records_safely(path):
2911
- total += 1
2912
- window.append((total, payload))
3161
+ state = self._path_state(path)
3162
+ cached_tail: dict[str, Any] | None = None
3163
+ with self._jsonl_cache_lock:
3164
+ candidate = self._jsonl_tail_cache.get(cache_key)
3165
+ if isinstance(candidate, dict):
3166
+ cached_tail = dict(candidate)
3167
+
3168
+ if cached_tail and cached_tail.get("state") == state:
3169
+ cached_limit = int(cached_tail.get("limit") or 0)
3170
+ cached_records = list(cached_tail.get("records") or [])
3171
+ cached_total = int(cached_tail.get("total") or 0)
3172
+ if cached_limit >= normalized_limit and cached_records:
3173
+ window = cached_records[-normalized_limit:]
3174
+ has_more = cached_total > len(window)
3175
+ return window, cached_total, has_more
3176
+
3177
+ if (
3178
+ cached_tail
3179
+ and state is not None
3180
+ and cached_tail.get("state")
3181
+ and tuple(cached_tail.get("state"))[0] == state[0]
3182
+ and state[2] >= tuple(cached_tail.get("state"))[2]
3183
+ ):
3184
+ cached_state = tuple(cached_tail.get("state"))
3185
+ cached_limit = int(cached_tail.get("limit") or 0)
3186
+ cached_total = int(cached_tail.get("total") or 0)
3187
+ max_limit = max(normalized_limit, cached_limit)
3188
+ window = deque(
3189
+ list(cached_tail.get("records") or []),
3190
+ maxlen=max_limit,
3191
+ )
3192
+ appended_records = list(
3193
+ _iter_jsonl_records_from_offset_safely(
3194
+ path,
3195
+ start_offset=int(cached_state[2]),
3196
+ )
3197
+ )
3198
+ if appended_records:
3199
+ next_cursor = cached_total + 1
3200
+ for payload in appended_records:
3201
+ window.append((next_cursor, payload))
3202
+ next_cursor += 1
3203
+ total = cached_total + len(appended_records)
3204
+ else:
3205
+ total = cached_total
3206
+ stored_records = list(window)
3207
+ with self._jsonl_cache_lock:
3208
+ self._jsonl_tail_cache[cache_key] = {
3209
+ "state": state,
3210
+ "limit": max_limit,
3211
+ "total": total,
3212
+ "records": stored_records,
3213
+ }
3214
+ selected = stored_records[-normalized_limit:]
3215
+ has_more = total > len(selected)
3216
+ return selected, total, has_more
3217
+
3218
+ window, total = _tail_jsonl_records_safely(path, limit=normalized_limit)
3219
+ with self._jsonl_cache_lock:
3220
+ self._jsonl_tail_cache[cache_key] = {
3221
+ "state": state,
3222
+ "limit": normalized_limit,
3223
+ "total": total,
3224
+ "records": list(window),
3225
+ }
2913
3226
  has_more = total > len(window)
2914
3227
  return list(window), total, has_more
2915
3228
 
@@ -2945,6 +3258,14 @@ class QuestService:
2945
3258
  except FileNotFoundError:
2946
3259
  return str(path.absolute())
2947
3260
 
3261
+ def jsonl_tail_cache_entry(self, path: Path) -> dict[str, Any] | None:
3262
+ cache_key = self._cache_key_for_path(path)
3263
+ with self._jsonl_cache_lock:
3264
+ candidate = self._jsonl_tail_cache.get(cache_key)
3265
+ if isinstance(candidate, dict):
3266
+ return dict(candidate)
3267
+ return None
3268
+
2948
3269
  def _read_cached_path(
2949
3270
  self,
2950
3271
  path: Path,
@@ -3019,7 +3340,7 @@ class QuestService:
3019
3340
  return self._snapshot(quest_id)
3020
3341
 
3021
3342
  def _snapshot(self, quest_id: str) -> dict:
3022
- quest_root = self._quest_root(quest_id)
3343
+ quest_root = self._require_initialized_quest_root(quest_id)
3023
3344
  cache_key = f"snapshot:{self._cache_key_for_path(quest_root)}"
3024
3345
  state = self._snapshot_state(quest_root)
3025
3346
  with self._snapshot_cache_lock:
@@ -3589,6 +3910,7 @@ class QuestService:
3589
3910
  title: str | None = None,
3590
3911
  active_anchor: str | None = None,
3591
3912
  default_runner: str | None = None,
3913
+ workspace_mode: str | None = None,
3592
3914
  ) -> dict:
3593
3915
  quest_root = self._quest_root(quest_id)
3594
3916
  quest_yaml_path = self._quest_yaml_path(quest_root)
@@ -3597,6 +3919,8 @@ class QuestService:
3597
3919
 
3598
3920
  quest_data = self.read_quest_yaml(quest_root)
3599
3921
  changed = False
3922
+ research_state_updates: dict[str, Any] = {}
3923
+ runtime_state_updates: dict[str, Any] = {}
3600
3924
 
3601
3925
  if title is not None:
3602
3926
  normalized_title = str(title).strip()
@@ -3610,10 +3934,11 @@ class QuestService:
3610
3934
  normalized_anchor = str(active_anchor).strip()
3611
3935
  if not normalized_anchor:
3612
3936
  raise ValueError("`active_anchor` cannot be empty.")
3613
- from ..prompts.builder import STANDARD_SKILLS
3937
+ from ..prompts.builder import current_standard_skills
3614
3938
 
3615
- if normalized_anchor not in STANDARD_SKILLS:
3616
- allowed = ", ".join(STANDARD_SKILLS)
3939
+ available_stage_skills = current_standard_skills(repo_root())
3940
+ if normalized_anchor not in available_stage_skills:
3941
+ allowed = ", ".join(available_stage_skills)
3617
3942
  raise ValueError(f"Unsupported active anchor `{normalized_anchor}`. Allowed values: {allowed}.")
3618
3943
  if quest_data.get("active_anchor") != normalized_anchor:
3619
3944
  quest_data["active_anchor"] = normalized_anchor
@@ -3633,9 +3958,35 @@ class QuestService:
3633
3958
  quest_data["default_runner"] = normalized_runner
3634
3959
  changed = True
3635
3960
 
3961
+ if workspace_mode is not None:
3962
+ normalized_workspace_mode = str(workspace_mode).strip().lower()
3963
+ if normalized_workspace_mode not in {"copilot", "autonomous"}:
3964
+ raise ValueError("Unsupported workspace mode. Allowed values: copilot, autonomous.")
3965
+ startup_contract = (
3966
+ dict(quest_data.get("startup_contract") or {})
3967
+ if isinstance(quest_data.get("startup_contract"), dict)
3968
+ else {}
3969
+ )
3970
+ if str(startup_contract.get("workspace_mode") or "").strip().lower() != normalized_workspace_mode:
3971
+ startup_contract["workspace_mode"] = normalized_workspace_mode
3972
+ quest_data["startup_contract"] = startup_contract
3973
+ changed = True
3974
+ if str(self.read_research_state(quest_root).get("workspace_mode") or "").strip().lower() != normalized_workspace_mode:
3975
+ research_state_updates["workspace_mode"] = normalized_workspace_mode
3976
+ runtime_state_updates["continuation_policy"] = (
3977
+ "wait_for_user_or_resume" if normalized_workspace_mode == "copilot" else "auto"
3978
+ )
3979
+ runtime_state_updates["continuation_reason"] = (
3980
+ "copilot_mode" if normalized_workspace_mode == "copilot" else "autonomous_mode"
3981
+ )
3982
+
3636
3983
  if changed:
3637
3984
  quest_data["updated_at"] = utc_now()
3638
3985
  write_yaml(quest_yaml_path, quest_data)
3986
+ if research_state_updates:
3987
+ self.update_research_state(quest_root, **research_state_updates)
3988
+ if runtime_state_updates:
3989
+ self.update_runtime_state(quest_root=quest_root, **runtime_state_updates)
3639
3990
 
3640
3991
  return self.snapshot(quest_id)
3641
3992
 
@@ -3670,10 +4021,11 @@ class QuestService:
3670
4021
  normalized_anchor = str(active_anchor or "").strip()
3671
4022
  if not normalized_anchor:
3672
4023
  raise ValueError("`active_anchor` cannot be empty.")
3673
- from ..prompts.builder import STANDARD_SKILLS
4024
+ from ..prompts.builder import current_standard_skills
3674
4025
 
3675
- if normalized_anchor not in STANDARD_SKILLS:
3676
- allowed = ", ".join(STANDARD_SKILLS)
4026
+ available_stage_skills = current_standard_skills(repo_root())
4027
+ if normalized_anchor not in available_stage_skills:
4028
+ allowed = ", ".join(available_stage_skills)
3677
4029
  raise ValueError(f"Unsupported active anchor `{normalized_anchor}`. Allowed values: {allowed}.")
3678
4030
  if quest_data.get("active_anchor") != normalized_anchor:
3679
4031
  quest_data["active_anchor"] = normalized_anchor
@@ -4071,7 +4423,7 @@ class QuestService:
4071
4423
  return payload
4072
4424
 
4073
4425
  def list_documents(self, quest_id: str) -> list[dict]:
4074
- quest_root = self._quest_root(quest_id)
4426
+ quest_root = self._require_initialized_quest_root(quest_id)
4075
4427
  workspace_root = self.active_workspace_root(quest_root)
4076
4428
  documents = []
4077
4429
  for relative in ("brief.md", "plan.md", "status.md", "SUMMARY.md"):
@@ -4125,7 +4477,7 @@ class QuestService:
4125
4477
  if revision:
4126
4478
  return self._revision_explorer(quest_id, revision=revision, mode=mode or "ref")
4127
4479
 
4128
- quest_root = self._quest_root(quest_id)
4480
+ quest_root = self._require_initialized_quest_root(quest_id)
4129
4481
  workspace_root = self.active_workspace_root(quest_root)
4130
4482
  git_status = self._git_status_map(workspace_root)
4131
4483
 
@@ -4154,7 +4506,7 @@ class QuestService:
4154
4506
  def search_files(self, quest_id: str, term: str, limit: int = 50) -> dict[str, Any]:
4155
4507
  query = term.strip()
4156
4508
  normalized_query = query.casefold()
4157
- workspace_root = self.active_workspace_root(self._quest_root(quest_id))
4509
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
4158
4510
  resolved_limit = max(1, min(limit, 200))
4159
4511
  if not normalized_query:
4160
4512
  return {
@@ -4249,7 +4601,7 @@ class QuestService:
4249
4601
  }
4250
4602
 
4251
4603
  def open_document(self, quest_id: str, document_id: str) -> dict:
4252
- quest_root = self._quest_root(quest_id)
4604
+ quest_root = self._require_initialized_quest_root(quest_id)
4253
4605
  workspace_root = self.active_workspace_root(quest_root)
4254
4606
  if document_id.startswith("git::"):
4255
4607
  revision, relative = self._parse_git_document_id(document_id)
@@ -4285,23 +4637,7 @@ class QuestService:
4285
4637
  },
4286
4638
  }
4287
4639
 
4288
- resolution_root = (
4289
- quest_root
4290
- if document_id.startswith(("questpath::", "memory::"))
4291
- else workspace_root
4292
- )
4293
- try:
4294
- path, writable, scope, source_kind = self._resolve_document(resolution_root, document_id)
4295
- except FileNotFoundError:
4296
- legacy_relative = None
4297
- if document_id.startswith("path::"):
4298
- legacy_relative = document_id.split("::", 1)[1].lstrip("/")
4299
- if legacy_relative and legacy_relative.startswith("literature/arxiv/"):
4300
- path, writable, scope, source_kind = self._resolve_document(
4301
- quest_root, f"questpath::{legacy_relative}"
4302
- )
4303
- else:
4304
- raise
4640
+ path, writable, scope, source_kind = self.resolve_document(quest_id, document_id)
4305
4641
  renderer_hint, mime_type = self._renderer_hint_for(path)
4306
4642
  is_text = self._is_text_document(path, mime_type, renderer_hint)
4307
4643
  content = read_text(path) if is_text else ""
@@ -4329,6 +4665,24 @@ class QuestService:
4329
4665
  },
4330
4666
  }
4331
4667
 
4668
+ def resolve_document(self, quest_id: str, document_id: str) -> tuple[Path, bool, str, str]:
4669
+ quest_root = self._require_initialized_quest_root(quest_id)
4670
+ workspace_root = self.active_workspace_root(quest_root)
4671
+ resolution_root = self._document_resolution_root(
4672
+ quest_root=quest_root,
4673
+ workspace_root=workspace_root,
4674
+ document_id=document_id,
4675
+ )
4676
+ try:
4677
+ return self._resolve_document(resolution_root, document_id)
4678
+ except FileNotFoundError:
4679
+ legacy_relative = None
4680
+ if document_id.startswith("path::"):
4681
+ legacy_relative = document_id.split("::", 1)[1].lstrip("/")
4682
+ if legacy_relative and legacy_relative.startswith("literature/arxiv/"):
4683
+ return self._resolve_document(quest_root, f"questpath::{legacy_relative}")
4684
+ raise
4685
+
4332
4686
  def save_document(self, quest_id: str, document_id: str, content: str, previous_revision: str | None = None) -> dict:
4333
4687
  current = self.open_document(quest_id, document_id)
4334
4688
  if not current.get("writable", False):
@@ -4505,6 +4859,291 @@ class QuestService:
4505
4859
  "saved_at": utc_now(),
4506
4860
  }
4507
4861
 
4862
+ @staticmethod
4863
+ def _normalize_workspace_relative_path(
4864
+ relative: str | None,
4865
+ *,
4866
+ field_name: str,
4867
+ allow_root: bool = True,
4868
+ ) -> str | None:
4869
+ if relative is None:
4870
+ if allow_root:
4871
+ return None
4872
+ raise ValueError(f"`{field_name}` is required.")
4873
+ raw = str(relative).strip().replace("\\", "/")
4874
+ if not raw:
4875
+ if allow_root:
4876
+ return None
4877
+ raise ValueError(f"`{field_name}` is required.")
4878
+ normalized = raw.lstrip("/").rstrip("/")
4879
+ if normalized in {"", "."}:
4880
+ if allow_root:
4881
+ return None
4882
+ raise ValueError(f"`{field_name}` must point to a workspace entry.")
4883
+ return normalized
4884
+
4885
+ @staticmethod
4886
+ def _normalize_workspace_entry_name(name: str | None, *, field_name: str) -> str:
4887
+ raw = str(name or "").strip().replace("\\", "/")
4888
+ if not raw:
4889
+ raise ValueError(f"`{field_name}` is required.")
4890
+ if "/" in raw:
4891
+ raise ValueError(f"`{field_name}` must be a single path segment.")
4892
+ candidate = Path(raw).name
4893
+ if candidate != raw or candidate in {"", ".", ".."}:
4894
+ raise ValueError(f"`{field_name}` must be a valid file or folder name.")
4895
+ if candidate == ".git":
4896
+ raise ValueError("`.git` cannot be created or renamed from the explorer.")
4897
+ return candidate
4898
+
4899
+ @staticmethod
4900
+ def _normalize_workspace_path_list(paths: Any, *, field_name: str) -> list[str]:
4901
+ if not isinstance(paths, list) or not paths:
4902
+ raise ValueError(f"`{field_name}` must be a non-empty list.")
4903
+ normalized: list[str] = []
4904
+ seen: set[str] = set()
4905
+ for raw in paths:
4906
+ item = QuestService._normalize_workspace_relative_path(
4907
+ raw,
4908
+ field_name=field_name,
4909
+ allow_root=False,
4910
+ )
4911
+ if not item or item in seen:
4912
+ continue
4913
+ seen.add(item)
4914
+ normalized.append(item)
4915
+ if not normalized:
4916
+ raise ValueError(f"`{field_name}` must include at least one valid path.")
4917
+ return normalized
4918
+
4919
+ @staticmethod
4920
+ def _filter_nested_workspace_paths(paths: list[str]) -> list[str]:
4921
+ kept: list[str] = []
4922
+ for path in paths:
4923
+ if any(path == parent or path.startswith(f"{parent}/") for parent in kept):
4924
+ continue
4925
+ kept.append(path)
4926
+ return kept
4927
+
4928
+ def _workspace_entry_payload(self, workspace_root: Path, path: Path) -> dict:
4929
+ if path.is_dir():
4930
+ return self._directory_node(
4931
+ workspace_root,
4932
+ path=path,
4933
+ children=[],
4934
+ git_status={},
4935
+ changed_paths={},
4936
+ )
4937
+ payload = self._file_node(
4938
+ workspace_root,
4939
+ path=path,
4940
+ git_status={},
4941
+ changed_paths={},
4942
+ )
4943
+ if payload is None:
4944
+ raise FileNotFoundError(f"Unknown workspace entry `{path}`.")
4945
+ return payload
4946
+
4947
+ def create_workspace_folder(
4948
+ self,
4949
+ quest_id: str,
4950
+ *,
4951
+ name: str | None,
4952
+ parent_path: str | None = None,
4953
+ ) -> dict:
4954
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
4955
+ normalized_parent = self._normalize_workspace_relative_path(
4956
+ parent_path,
4957
+ field_name="parent_path",
4958
+ allow_root=True,
4959
+ )
4960
+ folder_name = self._normalize_workspace_entry_name(name, field_name="name")
4961
+ parent = resolve_within(workspace_root, normalized_parent) if normalized_parent else workspace_root
4962
+ if not parent.exists() or not parent.is_dir():
4963
+ raise FileNotFoundError(
4964
+ f"Unknown destination folder `{normalized_parent or '.'}`."
4965
+ )
4966
+ target = resolve_within(parent, folder_name)
4967
+ if target.exists():
4968
+ raise FileExistsError(
4969
+ f"`{target.relative_to(workspace_root).as_posix()}` already exists."
4970
+ )
4971
+ ensure_dir(target)
4972
+ return {
4973
+ "ok": True,
4974
+ "quest_id": quest_id,
4975
+ "parent_path": normalized_parent,
4976
+ "item": self._workspace_entry_payload(workspace_root, target),
4977
+ "saved_at": utc_now(),
4978
+ }
4979
+
4980
+ def upload_workspace_file(
4981
+ self,
4982
+ quest_id: str,
4983
+ *,
4984
+ file_name: str | None,
4985
+ content: bytes,
4986
+ mime_type: str | None = None,
4987
+ parent_path: str | None = None,
4988
+ ) -> dict:
4989
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
4990
+ normalized_parent = self._normalize_workspace_relative_path(
4991
+ parent_path,
4992
+ field_name="parent_path",
4993
+ allow_root=True,
4994
+ )
4995
+ safe_name = self._normalize_workspace_entry_name(file_name, field_name="file_name")
4996
+ parent = resolve_within(workspace_root, normalized_parent) if normalized_parent else workspace_root
4997
+ if not parent.exists() or not parent.is_dir():
4998
+ raise FileNotFoundError(
4999
+ f"Unknown destination folder `{normalized_parent or '.'}`."
5000
+ )
5001
+ target = resolve_within(parent, safe_name)
5002
+ if target.exists():
5003
+ raise FileExistsError(
5004
+ f"`{target.relative_to(workspace_root).as_posix()}` already exists."
5005
+ )
5006
+ ensure_dir(target.parent)
5007
+ target.write_bytes(content)
5008
+ payload = self._workspace_entry_payload(workspace_root, target)
5009
+ guessed_mime = mimetypes.guess_type(target.name)[0] or mime_type or "application/octet-stream"
5010
+ payload["mime_type"] = guessed_mime
5011
+ return {
5012
+ "ok": True,
5013
+ "quest_id": quest_id,
5014
+ "parent_path": normalized_parent,
5015
+ "item": payload,
5016
+ "saved_at": utc_now(),
5017
+ }
5018
+
5019
+ def rename_workspace_entry(
5020
+ self,
5021
+ quest_id: str,
5022
+ *,
5023
+ path: str | None,
5024
+ new_name: str | None,
5025
+ ) -> dict:
5026
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
5027
+ normalized_path = self._normalize_workspace_relative_path(
5028
+ path,
5029
+ field_name="path",
5030
+ allow_root=False,
5031
+ )
5032
+ source = resolve_within(workspace_root, normalized_path)
5033
+ if not source.exists():
5034
+ raise FileNotFoundError(f"Unknown workspace entry `{normalized_path}`.")
5035
+ safe_name = self._normalize_workspace_entry_name(new_name, field_name="new_name")
5036
+ target = resolve_within(source.parent, safe_name)
5037
+ if target.exists() and target != source:
5038
+ raise FileExistsError(
5039
+ f"`{target.relative_to(workspace_root).as_posix()}` already exists."
5040
+ )
5041
+ if target != source:
5042
+ source.rename(target)
5043
+ payload = self._workspace_entry_payload(workspace_root, target)
5044
+ return {
5045
+ "ok": True,
5046
+ "quest_id": quest_id,
5047
+ "previous_path": normalized_path,
5048
+ "item": payload,
5049
+ "saved_at": utc_now(),
5050
+ }
5051
+
5052
+ def move_workspace_entries(
5053
+ self,
5054
+ quest_id: str,
5055
+ *,
5056
+ paths: Any,
5057
+ target_parent_path: str | None = None,
5058
+ ) -> dict:
5059
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
5060
+ normalized_paths = self._filter_nested_workspace_paths(
5061
+ self._normalize_workspace_path_list(paths, field_name="paths")
5062
+ )
5063
+ normalized_target_parent = self._normalize_workspace_relative_path(
5064
+ target_parent_path,
5065
+ field_name="target_parent_path",
5066
+ allow_root=True,
5067
+ )
5068
+ target_parent = (
5069
+ resolve_within(workspace_root, normalized_target_parent)
5070
+ if normalized_target_parent
5071
+ else workspace_root
5072
+ )
5073
+ if not target_parent.exists() or not target_parent.is_dir():
5074
+ raise FileNotFoundError(
5075
+ f"Unknown destination folder `{normalized_target_parent or '.'}`."
5076
+ )
5077
+
5078
+ moves: list[tuple[str, Path, Path]] = []
5079
+ destination_keys: set[str] = set()
5080
+ target_parent_resolved = target_parent.resolve()
5081
+ for normalized_path in normalized_paths:
5082
+ source = resolve_within(workspace_root, normalized_path)
5083
+ if not source.exists():
5084
+ raise FileNotFoundError(f"Unknown workspace entry `{normalized_path}`.")
5085
+ source_resolved = source.resolve()
5086
+ if source_resolved == target_parent_resolved or source_resolved in target_parent_resolved.parents:
5087
+ raise ValueError(
5088
+ f"`{normalized_path}` cannot be moved into itself or one of its descendants."
5089
+ )
5090
+ destination = resolve_within(target_parent, source.name)
5091
+ if destination.exists() and destination.resolve() != source_resolved:
5092
+ raise FileExistsError(
5093
+ f"`{destination.relative_to(workspace_root).as_posix()}` already exists."
5094
+ )
5095
+ destination_key = str(destination.resolve())
5096
+ if destination_key in destination_keys and destination != source:
5097
+ raise FileExistsError(
5098
+ f"`{destination.relative_to(workspace_root).as_posix()}` would conflict with another moved entry."
5099
+ )
5100
+ destination_keys.add(destination_key)
5101
+ moves.append((normalized_path, source, destination))
5102
+
5103
+ items: list[dict] = []
5104
+ for _normalized_path, source, destination in moves:
5105
+ if destination != source:
5106
+ source.rename(destination)
5107
+ items.append(self._workspace_entry_payload(workspace_root, destination))
5108
+ return {
5109
+ "ok": True,
5110
+ "quest_id": quest_id,
5111
+ "target_parent_path": normalized_target_parent,
5112
+ "items": items,
5113
+ "saved_at": utc_now(),
5114
+ }
5115
+
5116
+ def delete_workspace_entries(
5117
+ self,
5118
+ quest_id: str,
5119
+ *,
5120
+ paths: Any,
5121
+ ) -> dict:
5122
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
5123
+ normalized_paths = self._filter_nested_workspace_paths(
5124
+ self._normalize_workspace_path_list(paths, field_name="paths")
5125
+ )
5126
+ sources: list[Path] = []
5127
+ items: list[dict] = []
5128
+ for normalized_path in normalized_paths:
5129
+ source = resolve_within(workspace_root, normalized_path)
5130
+ if not source.exists():
5131
+ raise FileNotFoundError(f"Unknown workspace entry `{normalized_path}`.")
5132
+ sources.append(source)
5133
+ items.append(self._workspace_entry_payload(workspace_root, source))
5134
+
5135
+ for source in sorted(sources, key=lambda item: len(item.parts), reverse=True):
5136
+ if source.is_dir():
5137
+ shutil.rmtree(source)
5138
+ else:
5139
+ source.unlink()
5140
+ return {
5141
+ "ok": True,
5142
+ "quest_id": quest_id,
5143
+ "items": items,
5144
+ "saved_at": utc_now(),
5145
+ }
5146
+
4508
5147
  def _revision_explorer(self, quest_id: str, *, revision: str, mode: str) -> dict:
4509
5148
  quest_root = self._quest_root(quest_id)
4510
5149
  if not self._git_revision_exists(quest_root, revision):
@@ -4697,6 +5336,8 @@ class QuestService:
4697
5336
  }
4698
5337
 
4699
5338
  def _initialize_runtime_files(self, quest_root: Path) -> None:
5339
+ if not self._quest_yaml_path(quest_root).exists():
5340
+ raise FileNotFoundError(f"Unknown quest `{quest_root.name}`.")
4700
5341
  queue_path = self._message_queue_path(quest_root)
4701
5342
  if not queue_path.exists():
4702
5343
  write_json(queue_path, self._default_message_queue())
@@ -4828,10 +5469,11 @@ class QuestService:
4828
5469
  if continuation_anchor is not _UNSET:
4829
5470
  normalized_anchor = str(continuation_anchor or "").strip() or None
4830
5471
  if normalized_anchor is not None:
4831
- from ..prompts.builder import STANDARD_SKILLS
5472
+ from ..prompts.builder import current_standard_skills
4832
5473
 
4833
- if normalized_anchor not in STANDARD_SKILLS:
4834
- allowed = ", ".join(STANDARD_SKILLS)
5474
+ available_stage_skills = current_standard_skills(repo_root())
5475
+ if normalized_anchor not in available_stage_skills:
5476
+ allowed = ", ".join(available_stage_skills)
4835
5477
  raise ValueError(
4836
5478
  f"Unsupported continuation anchor `{normalized_anchor}`. Allowed values: {allowed}."
4837
5479
  )
@@ -5132,6 +5774,7 @@ class QuestService:
5132
5774
  connector_hints: dict[str, Any] | None = None,
5133
5775
  created_at: str | None = None,
5134
5776
  counts_as_visible: bool = True,
5777
+ deliver_to_bound_conversations: bool | None = None,
5135
5778
  ) -> dict[str, Any]:
5136
5779
  timestamp = created_at or utc_now()
5137
5780
  payload = {
@@ -5148,6 +5791,11 @@ class QuestService:
5148
5791
  "reply_mode": reply_mode,
5149
5792
  "surface_actions": [dict(item) for item in (surface_actions or []) if isinstance(item, dict)],
5150
5793
  "connector_hints": dict(connector_hints) if isinstance(connector_hints, dict) else {},
5794
+ "deliver_to_bound_conversations": (
5795
+ bool(deliver_to_bound_conversations)
5796
+ if deliver_to_bound_conversations is not None
5797
+ else None
5798
+ ),
5151
5799
  "created_at": timestamp,
5152
5800
  }
5153
5801
  append_jsonl(self._interaction_journal_path(quest_root), payload)
@@ -5397,6 +6045,12 @@ class QuestService:
5397
6045
  "queued_message_count_after_delivery": len(queue_payload.get("pending") or []),
5398
6046
  }
5399
6047
 
6048
+ @staticmethod
6049
+ def _document_resolution_root(quest_root: Path, workspace_root: Path, document_id: str) -> Path:
6050
+ if document_id.startswith(("questpath::", "memory::")):
6051
+ return quest_root
6052
+ return workspace_root
6053
+
5400
6054
  @staticmethod
5401
6055
  def _resolve_document(quest_root: Path, document_id: str) -> tuple[Path, bool, str, str]:
5402
6056
  if document_id.startswith("memory::"):
@@ -5583,12 +6237,10 @@ class QuestService:
5583
6237
 
5584
6238
  @staticmethod
5585
6239
  def _read_git_bytes(quest_root: Path, revision: str, relative: str) -> bytes:
5586
- result = subprocess.run(
6240
+ result = run_command_bytes(
5587
6241
  ["git", "show", f"{revision}:{relative}"],
5588
- cwd=str(quest_root),
6242
+ cwd=quest_root,
5589
6243
  check=False,
5590
- text=False,
5591
- capture_output=True,
5592
6244
  )
5593
6245
  if result.returncode != 0:
5594
6246
  raise FileNotFoundError(f"File `{relative}` does not exist at `{revision}`.")