@researai/deepscientist 1.5.14 → 1.5.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/README.md +336 -90
  2. package/assets/branding/logo-raster.png +0 -0
  3. package/bin/ds.js +816 -131
  4. package/docs/en/00_QUICK_START.md +36 -15
  5. package/docs/en/01_SETTINGS_REFERENCE.md +53 -4
  6. package/docs/en/02_START_RESEARCH_GUIDE.md +7 -0
  7. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +19 -0
  8. package/docs/en/05_TUI_GUIDE.md +6 -0
  9. package/docs/en/06_RUNTIME_AND_CANVAS.md +4 -3
  10. package/docs/en/09_DOCTOR.md +11 -5
  11. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  12. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +65 -13
  13. package/docs/en/15_CODEX_PROVIDER_SETUP.md +25 -8
  14. package/docs/en/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  15. package/docs/en/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  16. package/docs/en/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  17. package/docs/en/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
  18. package/docs/en/19_LOCAL_BROWSER_AUTH.md +70 -0
  19. package/docs/en/20_WORKSPACE_MODES_GUIDE.md +250 -0
  20. package/docs/en/README.md +24 -0
  21. package/docs/zh/00_QUICK_START.md +36 -15
  22. package/docs/zh/01_SETTINGS_REFERENCE.md +53 -4
  23. package/docs/zh/02_START_RESEARCH_GUIDE.md +7 -0
  24. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +19 -0
  25. package/docs/zh/05_TUI_GUIDE.md +6 -0
  26. package/docs/zh/09_DOCTOR.md +11 -5
  27. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  28. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +65 -13
  29. package/docs/zh/15_CODEX_PROVIDER_SETUP.md +25 -8
  30. package/docs/zh/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  31. package/docs/zh/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  32. package/docs/zh/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  33. package/docs/zh/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
  34. package/docs/zh/19_LOCAL_BROWSER_AUTH.md +68 -0
  35. package/docs/zh/20_WORKSPACE_MODES_GUIDE.md +251 -0
  36. package/docs/zh/README.md +24 -0
  37. package/install.sh +2 -0
  38. package/package.json +1 -1
  39. package/pyproject.toml +1 -1
  40. package/src/deepscientist/__init__.py +1 -1
  41. package/src/deepscientist/acp/envelope.py +6 -0
  42. package/src/deepscientist/artifact/charts.py +567 -0
  43. package/src/deepscientist/artifact/guidance.py +50 -10
  44. package/src/deepscientist/artifact/metrics.py +228 -5
  45. package/src/deepscientist/artifact/schemas.py +3 -0
  46. package/src/deepscientist/artifact/service.py +4276 -308
  47. package/src/deepscientist/bash_exec/models.py +23 -0
  48. package/src/deepscientist/bash_exec/monitor.py +147 -67
  49. package/src/deepscientist/bash_exec/runtime.py +218 -156
  50. package/src/deepscientist/bash_exec/service.py +309 -69
  51. package/src/deepscientist/bash_exec/shells.py +87 -0
  52. package/src/deepscientist/bridges/connectors.py +51 -2
  53. package/src/deepscientist/cli.py +115 -19
  54. package/src/deepscientist/codex_cli_compat.py +232 -0
  55. package/src/deepscientist/config/models.py +8 -4
  56. package/src/deepscientist/config/service.py +38 -11
  57. package/src/deepscientist/connector/weixin_support.py +122 -1
  58. package/src/deepscientist/daemon/api/handlers.py +199 -9
  59. package/src/deepscientist/daemon/api/router.py +5 -0
  60. package/src/deepscientist/daemon/app.py +1458 -289
  61. package/src/deepscientist/doctor.py +51 -0
  62. package/src/deepscientist/file_lock.py +48 -0
  63. package/src/deepscientist/gitops/__init__.py +10 -1
  64. package/src/deepscientist/gitops/diff.py +296 -1
  65. package/src/deepscientist/gitops/service.py +4 -1
  66. package/src/deepscientist/mcp/server.py +212 -5
  67. package/src/deepscientist/process_control.py +161 -0
  68. package/src/deepscientist/prompts/builder.py +501 -453
  69. package/src/deepscientist/quest/layout.py +15 -2
  70. package/src/deepscientist/quest/service.py +2539 -195
  71. package/src/deepscientist/quest/stage_views.py +177 -1
  72. package/src/deepscientist/runners/base.py +2 -0
  73. package/src/deepscientist/runners/codex.py +169 -31
  74. package/src/deepscientist/runners/runtime_overrides.py +17 -1
  75. package/src/deepscientist/skills/__init__.py +2 -2
  76. package/src/deepscientist/skills/installer.py +196 -5
  77. package/src/deepscientist/skills/registry.py +66 -0
  78. package/src/prompts/connectors/qq.md +18 -8
  79. package/src/prompts/connectors/weixin.md +16 -6
  80. package/src/prompts/contracts/shared_interaction.md +24 -4
  81. package/src/prompts/system.md +921 -72
  82. package/src/prompts/system_copilot.md +43 -0
  83. package/src/skills/analysis-campaign/SKILL.md +32 -2
  84. package/src/skills/analysis-campaign/references/artifact-orchestration.md +1 -1
  85. package/src/skills/analysis-campaign/references/writing-facing-slice-examples.md +65 -0
  86. package/src/skills/baseline/SKILL.md +10 -0
  87. package/src/skills/decision/SKILL.md +27 -2
  88. package/src/skills/experiment/SKILL.md +16 -2
  89. package/src/skills/figure-polish/SKILL.md +1 -0
  90. package/src/skills/finalize/SKILL.md +19 -0
  91. package/src/skills/idea/SKILL.md +79 -0
  92. package/src/skills/idea/references/idea-generation-playbook.md +100 -0
  93. package/src/skills/idea/references/outline-seeding-example.md +60 -0
  94. package/src/skills/intake-audit/SKILL.md +9 -1
  95. package/src/skills/mentor/SKILL.md +217 -0
  96. package/src/skills/mentor/references/correction-rules.md +210 -0
  97. package/src/skills/mentor/references/knowledge-profile.md +91 -0
  98. package/src/skills/mentor/references/persona-profile.md +138 -0
  99. package/src/skills/mentor/references/taste-profile.md +128 -0
  100. package/src/skills/mentor/references/thought-style-profile.md +138 -0
  101. package/src/skills/mentor/references/work-profile.md +289 -0
  102. package/src/skills/mentor/references/workflow-profile.md +240 -0
  103. package/src/skills/optimize/SKILL.md +1645 -0
  104. package/src/skills/rebuttal/SKILL.md +3 -1
  105. package/src/skills/review/SKILL.md +3 -1
  106. package/src/skills/scout/SKILL.md +8 -0
  107. package/src/skills/write/SKILL.md +81 -12
  108. package/src/skills/write/references/outline-evidence-contract-example.md +107 -0
  109. package/src/tui/dist/app/AppContainer.js +22 -11
  110. package/src/tui/dist/index.js +4 -1
  111. package/src/tui/dist/lib/api.js +33 -3
  112. package/src/tui/package.json +1 -1
  113. package/src/ui/dist/assets/AiManusChatView-COFACy7V.js +204 -0
  114. package/src/ui/dist/assets/AnalysisPlugin-DnSm0GZn.js +1 -0
  115. package/src/ui/dist/assets/CliPlugin-CvwCmDQ5.js +109 -0
  116. package/src/ui/dist/assets/CodeEditorPlugin-cOqSa0xq.js +2 -0
  117. package/src/ui/dist/assets/CodeViewerPlugin-itb0tltR.js +270 -0
  118. package/src/ui/dist/assets/DocViewerPlugin-DqKkiCI6.js +7 -0
  119. package/src/ui/dist/assets/GitCommitViewerPlugin-DVgNHBCS.js +1 -0
  120. package/src/ui/dist/assets/GitDiffViewerPlugin-DxL2ezFG.js +6 -0
  121. package/src/ui/dist/assets/GitSnapshotViewer-B_RQm1YZ.js +30 -0
  122. package/src/ui/dist/assets/ImageViewerPlugin-tHqlXY3n.js +26 -0
  123. package/src/ui/dist/assets/LabCopilotPanel-ClMbq5Yu.js +14 -0
  124. package/src/ui/dist/assets/LabPlugin-L_SuE8ow.js +22 -0
  125. package/src/ui/dist/assets/LatexPlugin-B495DTXC.js +25 -0
  126. package/src/ui/dist/assets/MarkdownViewerPlugin-DG28-61B.js +128 -0
  127. package/src/ui/dist/assets/MarketplacePlugin-BiOGT-Kj.js +13 -0
  128. package/src/ui/dist/assets/{NotebookEditor-CccQYZjX.css → NotebookEditor-BHH8rdGj.css} +1 -1
  129. package/src/ui/dist/assets/NotebookEditor-BOr3x3Ej.css +1 -0
  130. package/src/ui/dist/assets/NotebookEditor-C-4Kt1p9.js +81 -0
  131. package/src/ui/dist/assets/NotebookEditor-CVsj8h_T.js +361 -0
  132. package/src/ui/dist/assets/PdfLoader-CASDQmxJ.js +16 -0
  133. package/src/ui/dist/assets/PdfLoader-Cy5jtWrr.css +1 -0
  134. package/src/ui/dist/assets/PdfMarkdownPlugin-BFhwoKsY.js +1 -0
  135. package/src/ui/dist/assets/PdfViewerPlugin-DcOzU9vd.js +17 -0
  136. package/src/ui/dist/assets/PdfViewerPlugin-nwwE-fjJ.css +1 -0
  137. package/src/ui/dist/assets/SearchPlugin-CHj7M58O.js +16 -0
  138. package/src/ui/dist/assets/SearchPlugin-DA4en4hK.css +1 -0
  139. package/src/ui/dist/assets/TextViewerPlugin-CB4DYfWO.js +54 -0
  140. package/src/ui/dist/assets/VNCViewer-CjlbyCB3.js +11 -0
  141. package/src/ui/dist/assets/bot-CFkZY-JP.js +6 -0
  142. package/src/ui/dist/assets/browser-CTB2jwNe.js +8 -0
  143. package/src/ui/dist/assets/chevron-up-Dq5ofbht.js +6 -0
  144. package/src/ui/dist/assets/code-DLC6G24T.js +6 -0
  145. package/src/ui/dist/assets/file-content-Dv4LoZec.js +1 -0
  146. package/src/ui/dist/assets/file-diff-panel-Denq-lC3.js +1 -0
  147. package/src/ui/dist/assets/file-jump-queue-DA-SdG__.js +1 -0
  148. package/src/ui/dist/assets/file-socket-Cu4Qln7Y.js +1 -0
  149. package/src/ui/dist/assets/git-commit-horizontal-BUh6G52n.js +6 -0
  150. package/src/ui/dist/assets/image-B9HUUddG.js +6 -0
  151. package/src/ui/dist/assets/index-B2B1sg-M.js +1 -0
  152. package/src/ui/dist/assets/index-Cgla8biy.css +33 -0
  153. package/src/ui/dist/assets/index-DRyx7vAc.js +1 -0
  154. package/src/ui/dist/assets/index-Gbl53BNp.js +2496 -0
  155. package/src/ui/dist/assets/index-wQ7RIIRd.js +11 -0
  156. package/src/ui/dist/assets/monaco-CiHMMNH_.js +1 -0
  157. package/src/ui/dist/assets/pdf-effect-queue-ZtnHFCAi.js +6 -0
  158. package/src/ui/dist/assets/plugin-monaco-C8UgLomw.js +19 -0
  159. package/src/ui/dist/assets/plugin-notebook-HbW2K-1c.js +169 -0
  160. package/src/ui/dist/assets/plugin-pdf-CR8hgQBV.js +357 -0
  161. package/src/ui/dist/assets/plugin-terminal-MXFIPun8.js +227 -0
  162. package/src/ui/dist/assets/popover-DL6h35vr.js +1 -0
  163. package/src/ui/dist/assets/project-sync-CsX08Qno.js +1 -0
  164. package/src/ui/dist/assets/select-DvmXt1yY.js +11 -0
  165. package/src/ui/dist/assets/sigma-7jpXazui.js +6 -0
  166. package/src/ui/dist/assets/trash-xA7kFt8i.js +11 -0
  167. package/src/ui/dist/assets/useCliAccess-DsMwDjOp.js +1 -0
  168. package/src/ui/dist/assets/useFileDiffOverlay-FuhcnKiw.js +1 -0
  169. package/src/ui/dist/assets/wrap-text-CwMn-iqb.js +11 -0
  170. package/src/ui/dist/assets/zoom-out-R-GWEhzS.js +11 -0
  171. package/src/ui/dist/index.html +5 -2
  172. package/src/ui/dist/assets/AiManusChatView-DaF9Nge_.js +0 -26597
  173. package/src/ui/dist/assets/AnalysisPlugin-BSVx6dXE.js +0 -123
  174. package/src/ui/dist/assets/CliPlugin-C9gzJX41.js +0 -5905
  175. package/src/ui/dist/assets/CodeEditorPlugin-DU9G0Tox.js +0 -427
  176. package/src/ui/dist/assets/CodeViewerPlugin-DoX_fI9l.js +0 -905
  177. package/src/ui/dist/assets/DocViewerPlugin-C4FWIXuU.js +0 -278
  178. package/src/ui/dist/assets/GitDiffViewerPlugin-BgfFMgtf.js +0 -2661
  179. package/src/ui/dist/assets/ImageViewerPlugin-tcPkfY_x.js +0 -500
  180. package/src/ui/dist/assets/LabCopilotPanel-_dKV60Bf.js +0 -4104
  181. package/src/ui/dist/assets/LabPlugin-Bje0ayoC.js +0 -2677
  182. package/src/ui/dist/assets/LatexPlugin-CVsBzAln.js +0 -1792
  183. package/src/ui/dist/assets/MarkdownViewerPlugin-xjmrqv_8.js +0 -308
  184. package/src/ui/dist/assets/MarketplacePlugin-mMM2A8wP.js +0 -413
  185. package/src/ui/dist/assets/NotebookEditor-3kVDSOBo.js +0 -4214
  186. package/src/ui/dist/assets/NotebookEditor-C3VQ7ylN.css +0 -1405
  187. package/src/ui/dist/assets/NotebookEditor-SoJ8X-MO.js +0 -84873
  188. package/src/ui/dist/assets/PdfLoader-C-Y707R3.css +0 -49
  189. package/src/ui/dist/assets/PdfLoader-DElVuHl9.js +0 -25468
  190. package/src/ui/dist/assets/PdfMarkdownPlugin-Bq88XT4G.js +0 -409
  191. package/src/ui/dist/assets/PdfViewerPlugin-CsCXMo9S.js +0 -3095
  192. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +0 -3627
  193. package/src/ui/dist/assets/SearchPlugin-DDMrGDkh.css +0 -379
  194. package/src/ui/dist/assets/SearchPlugin-oUPvy19k.js +0 -741
  195. package/src/ui/dist/assets/TextViewerPlugin-CRkT9yNy.js +0 -472
  196. package/src/ui/dist/assets/VNCViewer-BgbuvWhR.js +0 -18821
  197. package/src/ui/dist/assets/awareness-C0NPR2Dj.js +0 -292
  198. package/src/ui/dist/assets/bot-v_RASACv.js +0 -21
  199. package/src/ui/dist/assets/browser-BAcuE0Xj.js +0 -2895
  200. package/src/ui/dist/assets/code-5hC9d0VH.js +0 -17
  201. package/src/ui/dist/assets/file-content-D1PxfOrp.js +0 -377
  202. package/src/ui/dist/assets/file-diff-panel-DG1oT_Hj.js +0 -92
  203. package/src/ui/dist/assets/file-jump-queue-r5XKgJEV.js +0 -16
  204. package/src/ui/dist/assets/file-socket-BmdFYQlk.js +0 -58
  205. package/src/ui/dist/assets/function-B5QZkkHC.js +0 -1895
  206. package/src/ui/dist/assets/image-Dqe2X2tW.js +0 -18
  207. package/src/ui/dist/assets/index-BQG-1s2o.css +0 -12553
  208. package/src/ui/dist/assets/index-DVsMKK_y.js +0 -25
  209. package/src/ui/dist/assets/index-Duvz8Ip0.js +0 -159
  210. package/src/ui/dist/assets/index-Nt9hS4ck.js +0 -244829
  211. package/src/ui/dist/assets/index-RDlNXXx1.js +0 -120
  212. package/src/ui/dist/assets/monaco-DIXge1CP.js +0 -623
  213. package/src/ui/dist/assets/pdf-effect-queue-BBTTQaO-.js +0 -47
  214. package/src/ui/dist/assets/pdf_viewer-e0g1is2C.js +0 -8206
  215. package/src/ui/dist/assets/popover-BWlolyxo.js +0 -476
  216. package/src/ui/dist/assets/project-sync-BM5PkFH4.js +0 -297
  217. package/src/ui/dist/assets/select-D4dAtrA8.js +0 -1690
  218. package/src/ui/dist/assets/sigma-CKbE5jJT.js +0 -22
  219. package/src/ui/dist/assets/square-check-big-CZNGMgiB.js +0 -17
  220. package/src/ui/dist/assets/trash-DaB37xAz.js +0 -32
  221. package/src/ui/dist/assets/useCliAccess-C2OmAcWe.js +0 -957
  222. package/src/ui/dist/assets/useFileDiffOverlay-Dowd1Ij4.js +0 -53
  223. package/src/ui/dist/assets/wrap-text-BGjAhAUq.js +0 -35
  224. package/src/ui/dist/assets/yjs-DncrqiZ8.js +0 -11243
  225. package/src/ui/dist/assets/zoom-out-dMZQMXzc.js +0 -34
@@ -10,19 +10,21 @@ import json
10
10
  import mimetypes
11
11
  import re
12
12
  import threading
13
+ import time
13
14
  from pathlib import Path, PurePosixPath
14
15
  from typing import Any
15
16
  from urllib.parse import quote
16
17
 
17
18
  try:
18
- import fcntl
19
- except ImportError: # pragma: no cover
19
+ import fcntl # pragma: no cover - exercised on POSIX
20
+ except ImportError: # pragma: no cover - exercised on Windows
20
21
  fcntl = None
21
22
 
22
- from ..artifact.metrics import build_metrics_timeline, extract_latest_metric
23
+ from ..artifact.metrics import build_baseline_compare_payload, build_metrics_timeline, extract_latest_metric
23
24
  from ..config import ConfigManager
24
25
  from ..connector_runtime import conversation_identity_key, normalize_conversation_id, parse_conversation_id
25
- from ..gitops import current_branch, export_git_graph, head_commit, init_repo
26
+ from ..file_lock import advisory_file_lock
27
+ from ..gitops import current_branch, export_git_graph, head_commit, init_repo, list_branch_canvas, list_commit_canvas
26
28
  from ..home import repo_root
27
29
  from ..registries import BaselineRegistry
28
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
@@ -50,9 +52,13 @@ _CODEX_HISTORY_TAIL_LIMIT = 400
50
52
  _JSONL_STREAM_CHUNK_BYTES = 64 * 1024
51
53
  _EVENTS_OVERSIZED_LINE_BYTES = 8 * 1024 * 1024
52
54
  _OVERSIZED_EVENT_PREFIX_BYTES = 4096
55
+ _PROJECTION_SCHEMA_VERSION = 1
56
+ _PROJECTION_BUILD_TOTAL_STEPS = 3
57
+ _PROJECTION_REFRESH_THROTTLE_SECONDS = 1.0
53
58
  _EVENT_TYPE_BYTES_RE = re.compile(rb'"(?:type|event_type)"\s*:\s*"([^"]+)"')
54
59
  _EVENT_TOOL_NAME_BYTES_RE = re.compile(rb'"tool_name"\s*:\s*"([^"]+)"')
55
60
  _EVENT_RUN_ID_BYTES_RE = re.compile(rb'"run_id"\s*:\s*"([^"]+)"')
61
+ CONTINUATION_POLICIES = {"auto", "when_external_progress", "wait_for_user_or_resume", "none"}
56
62
 
57
63
 
58
64
  def _oversized_event_placeholder(*, prefix: bytes, line_bytes: int) -> dict[str, Any]:
@@ -166,6 +172,127 @@ def _iter_jsonl_records_safely(
166
172
  yield payload
167
173
 
168
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
+
169
296
  class QuestService:
170
297
  def __init__(self, home: Path, skill_installer: SkillInstaller | None = None) -> None:
171
298
  self.home = home
@@ -176,12 +303,21 @@ class QuestService:
176
303
  self._file_cache: dict[str, dict[str, Any]] = {}
177
304
  self._jsonl_cache_lock = threading.Lock()
178
305
  self._jsonl_cache: dict[str, dict[str, Any]] = {}
306
+ self._jsonl_tail_cache: dict[str, dict[str, Any]] = {}
179
307
  self._snapshot_cache_lock = threading.Lock()
180
308
  self._snapshot_cache: dict[str, dict[str, Any]] = {}
181
309
  self._codex_history_cache_lock = threading.Lock()
182
310
  self._codex_history_cache: dict[str, dict[str, Any]] = {}
183
311
  self._runtime_state_locks_lock = threading.Lock()
184
312
  self._runtime_state_locks: dict[str, threading.Lock] = {}
313
+ self._artifact_projection_locks_lock = threading.Lock()
314
+ self._artifact_projection_locks: dict[str, threading.Lock] = {}
315
+ self._quest_projection_locks_lock = threading.Lock()
316
+ self._quest_projection_locks: dict[str, threading.Lock] = {}
317
+ self._quest_projection_builds_lock = threading.Lock()
318
+ self._quest_projection_builds: dict[str, threading.Thread] = {}
319
+ self._quest_projection_refresh_lock = threading.Lock()
320
+ self._quest_projection_refresh_at: dict[str, float] = {}
185
321
 
186
322
  def _quest_root(self, quest_id: str) -> Path:
187
323
  return self.quests_root / quest_id
@@ -274,6 +410,13 @@ class QuestService:
274
410
  return quest_root / ".ds" / "lab_canvas_state.json"
275
411
 
276
412
  def _default_research_state(self, quest_root: Path) -> dict[str, Any]:
413
+ quest_yaml = self.read_quest_yaml(quest_root)
414
+ startup_contract = (
415
+ dict(quest_yaml.get("startup_contract") or {})
416
+ if isinstance(quest_yaml.get("startup_contract"), dict)
417
+ else {}
418
+ )
419
+ workspace_mode = str(startup_contract.get("workspace_mode") or "").strip().lower() or "quest"
277
420
  return {
278
421
  "version": 1,
279
422
  "active_idea_id": None,
@@ -290,7 +433,7 @@ class QuestService:
290
433
  "paper_parent_worktree_root": None,
291
434
  "paper_parent_run_id": None,
292
435
  "next_pending_slice_id": None,
293
- "workspace_mode": "quest",
436
+ "workspace_mode": workspace_mode,
294
437
  "last_flow_type": None,
295
438
  "updated_at": utc_now(),
296
439
  }
@@ -339,7 +482,9 @@ class QuestService:
339
482
  if value is _UNSET:
340
483
  continue
341
484
  current[key] = str(value) if isinstance(value, Path) else value
342
- return self.write_research_state(quest_root, current)
485
+ payload = self.write_research_state(quest_root, current)
486
+ self.schedule_projection_refresh(quest_root, kinds=("details", "canvas", "git_canvas"))
487
+ return payload
343
488
 
344
489
  def read_lab_canvas_state(self, quest_root: Path) -> dict[str, Any]:
345
490
  self._initialize_runtime_files(quest_root)
@@ -443,7 +588,288 @@ class QuestService:
443
588
  str(path),
444
589
  )
445
590
 
446
- def _collect_artifacts(self, quest_root: Path) -> list[dict[str, Any]]:
591
+ @staticmethod
592
+ def _artifact_projection_path(quest_root: Path) -> Path:
593
+ return quest_root / ".ds" / "cache" / "artifact_projection.v2.json"
594
+
595
+ @staticmethod
596
+ def _artifact_projection_lock_path(quest_root: Path) -> Path:
597
+ return quest_root / ".ds" / "artifact_projection.lock"
598
+
599
+ @staticmethod
600
+ def _metrics_timeline_cache_path(quest_root: Path) -> Path:
601
+ return quest_root / ".ds" / "cache" / "metrics_timeline.v1.json"
602
+
603
+ @staticmethod
604
+ def _metrics_timeline_cache_lock_path(quest_root: Path) -> Path:
605
+ return quest_root / ".ds" / "cache" / "metrics_timeline.lock"
606
+
607
+ @staticmethod
608
+ def _baseline_compare_cache_path(quest_root: Path) -> Path:
609
+ return quest_root / ".ds" / "cache" / "baseline_compare.v1.json"
610
+
611
+ @staticmethod
612
+ def _baseline_compare_cache_lock_path(quest_root: Path) -> Path:
613
+ return quest_root / ".ds" / "cache" / "baseline_compare.lock"
614
+
615
+ @staticmethod
616
+ def _json_compatible_state(value: Any) -> Any:
617
+ if isinstance(value, tuple):
618
+ return [QuestService._json_compatible_state(item) for item in value]
619
+ if isinstance(value, list):
620
+ return [QuestService._json_compatible_state(item) for item in value]
621
+ if isinstance(value, dict):
622
+ return {
623
+ str(key): QuestService._json_compatible_state(item)
624
+ for key, item in value.items()
625
+ }
626
+ return value
627
+
628
+ @contextmanager
629
+ def _artifact_projection_lock(self, quest_root: Path):
630
+ lock_key = str(quest_root.resolve())
631
+ with self._artifact_projection_locks_lock:
632
+ thread_lock = self._artifact_projection_locks.setdefault(lock_key, threading.Lock())
633
+ with thread_lock:
634
+ with advisory_file_lock(self._artifact_projection_lock_path(quest_root)):
635
+ yield
636
+
637
+ def _artifact_index_collection_state(self, quest_root: Path) -> list[list[Any]]:
638
+ states: list[list[Any]] = []
639
+ for root in self._artifact_roots(quest_root):
640
+ artifacts_root = root / "artifacts"
641
+ if not artifacts_root.exists():
642
+ continue
643
+ try:
644
+ label = str(root.relative_to(quest_root))
645
+ except ValueError:
646
+ label = str(root)
647
+ states.append(
648
+ [
649
+ label,
650
+ self._json_compatible_state(self._path_state(artifacts_root / "_index.jsonl")),
651
+ ]
652
+ )
653
+ return states
654
+
655
+ def _metrics_timeline_attachment_state(self, quest_root: Path, workspace_root: Path) -> list[list[Any]]:
656
+ states: list[list[Any]] = []
657
+ seen_paths: set[str] = set()
658
+ for root in (workspace_root, quest_root):
659
+ attachment_root = root / "baselines" / "imported"
660
+ if not attachment_root.exists():
661
+ continue
662
+ for path in sorted(attachment_root.glob("*/attachment.yaml")):
663
+ key = str(path.resolve())
664
+ if key in seen_paths:
665
+ continue
666
+ seen_paths.add(key)
667
+ try:
668
+ label = str(path.relative_to(quest_root))
669
+ except ValueError:
670
+ label = str(path)
671
+ states.append([label, self._json_compatible_state(self._path_state(path))])
672
+ return states
673
+
674
+ def _metrics_timeline_state(self, quest_root: Path, workspace_root: Path) -> list[Any]:
675
+ return [
676
+ str(workspace_root.resolve()),
677
+ self._artifact_index_collection_state(quest_root),
678
+ self._metrics_timeline_attachment_state(quest_root, workspace_root),
679
+ ]
680
+
681
+ def _baseline_compare_state(self, quest_root: Path, workspace_root: Path) -> list[Any]:
682
+ return [
683
+ str(workspace_root.resolve()),
684
+ self._artifact_index_collection_state(quest_root),
685
+ self._metrics_timeline_attachment_state(quest_root, workspace_root),
686
+ self._json_compatible_state(self._path_state(self._quest_yaml_path(quest_root))),
687
+ ]
688
+
689
+ def _baseline_compare_entries(self, quest_root: Path, workspace_root: Path) -> list[dict[str, Any]]:
690
+ entries: list[dict[str, Any]] = []
691
+ for item in self._collect_artifacts_raw(quest_root):
692
+ if str(item.get("kind") or "").strip() != "baselines":
693
+ continue
694
+ payload = item.get("payload") or {}
695
+ if not isinstance(payload, dict):
696
+ continue
697
+ status = str(payload.get("status") or "").strip().lower()
698
+ if status not in {"confirmed", "published", "quest_confirmed"}:
699
+ continue
700
+ entries.append(dict(payload))
701
+ attachment = self._active_baseline_attachment(quest_root, workspace_root)
702
+ attachment_entry = dict(attachment.get("entry") or {}) if isinstance(attachment, dict) else None
703
+ if attachment_entry:
704
+ entries.append(attachment_entry)
705
+ return entries
706
+
707
+ def _artifact_projection_state(self, quest_root: Path) -> tuple[str, Any]:
708
+ index_state = self._artifact_index_collection_state(quest_root)
709
+ if index_state and all(item[1] is not None for item in index_state):
710
+ return "index", index_state
711
+ if not index_state:
712
+ return "index", []
713
+ return "raw", self._json_compatible_state(self._artifact_collection_state(quest_root))
714
+
715
+ def _projection_artifact_item(
716
+ self,
717
+ *,
718
+ record: dict[str, Any],
719
+ artifact_path: Path,
720
+ workspace_root: Path,
721
+ ) -> dict[str, Any]:
722
+ return {
723
+ "kind": artifact_path.parent.name,
724
+ "path": str(artifact_path),
725
+ "payload": copy.deepcopy(record),
726
+ "workspace_root": str(workspace_root),
727
+ }
728
+
729
+ def _write_artifact_projection_locked(
730
+ self,
731
+ quest_root: Path,
732
+ *,
733
+ state_kind: str,
734
+ state: Any,
735
+ artifacts: list[dict[str, Any]],
736
+ ) -> list[dict[str, Any]]:
737
+ projection_path = self._artifact_projection_path(quest_root)
738
+ ensure_dir(projection_path.parent)
739
+ payload = {
740
+ "schema_version": 2,
741
+ "generated_at": utc_now(),
742
+ "state_kind": state_kind,
743
+ "state": self._json_compatible_state(state),
744
+ "artifacts": copy.deepcopy(artifacts),
745
+ }
746
+ write_json(projection_path, payload)
747
+ return copy.deepcopy(artifacts)
748
+
749
+ def refresh_artifact_projection(
750
+ self,
751
+ quest_root: Path,
752
+ *,
753
+ state_kind: str | None = None,
754
+ state: Any | None = None,
755
+ ) -> list[dict[str, Any]]:
756
+ resolved_state_kind, resolved_state = (
757
+ (state_kind, state)
758
+ if state_kind is not None and state is not None
759
+ else self._artifact_projection_state(quest_root)
760
+ )
761
+ artifacts = self._collect_artifacts_raw(quest_root)
762
+ return self._write_artifact_projection_locked(
763
+ quest_root,
764
+ state_kind=resolved_state_kind,
765
+ state=resolved_state,
766
+ artifacts=artifacts,
767
+ )
768
+
769
+ def update_artifact_projection(
770
+ self,
771
+ quest_root: Path,
772
+ *,
773
+ record: dict[str, Any],
774
+ artifact_path: Path,
775
+ workspace_root: Path,
776
+ previous_state_kind: str | None = None,
777
+ previous_state: Any | None = None,
778
+ current_state_kind: str | None = None,
779
+ current_state: Any | None = None,
780
+ ) -> list[dict[str, Any]]:
781
+ resolved_previous_kind = previous_state_kind
782
+ resolved_previous_state = self._json_compatible_state(previous_state) if previous_state is not None else None
783
+ resolved_current_kind, resolved_current_state = (
784
+ (current_state_kind, self._json_compatible_state(current_state))
785
+ if current_state_kind is not None and current_state is not None
786
+ else self._artifact_projection_state(quest_root)
787
+ )
788
+ projection_path = self._artifact_projection_path(quest_root)
789
+ with self._artifact_projection_lock(quest_root):
790
+ payload = read_json(projection_path, {})
791
+ projected_artifacts = payload.get("artifacts") if isinstance(payload.get("artifacts"), list) else None
792
+ can_incrementally_update = (
793
+ isinstance(payload, dict)
794
+ and int(payload.get("schema_version") or 0) == 2
795
+ and isinstance(projected_artifacts, list)
796
+ and resolved_previous_kind is not None
797
+ and payload.get("state_kind") == resolved_previous_kind
798
+ and self._json_compatible_state(payload.get("state")) == resolved_previous_state
799
+ )
800
+ if not can_incrementally_update:
801
+ return self.refresh_artifact_projection(
802
+ quest_root,
803
+ state_kind=resolved_current_kind,
804
+ state=resolved_current_state,
805
+ )
806
+
807
+ artifacts: list[dict[str, Any]] = [
808
+ dict(item)
809
+ for item in projected_artifacts
810
+ if isinstance(item, dict)
811
+ ]
812
+ next_item = self._projection_artifact_item(
813
+ record=record,
814
+ artifact_path=artifact_path,
815
+ workspace_root=workspace_root,
816
+ )
817
+ next_identity = self._artifact_item_identity(
818
+ artifact_path,
819
+ record,
820
+ kind=str(next_item.get("kind") or ""),
821
+ )
822
+ try:
823
+ next_mtime_ns = artifact_path.stat().st_mtime_ns
824
+ except OSError:
825
+ next_mtime_ns = 0
826
+ replaced = False
827
+ for index, existing in enumerate(artifacts):
828
+ existing_payload = existing.get("payload") if isinstance(existing.get("payload"), dict) else {}
829
+ existing_path = Path(str(existing.get("path") or artifact_path))
830
+ if (
831
+ self._artifact_item_identity(
832
+ existing_path,
833
+ existing_payload,
834
+ kind=str(existing.get("kind") or existing_path.parent.name or ""),
835
+ )
836
+ != next_identity
837
+ ):
838
+ continue
839
+ try:
840
+ existing_mtime_ns = existing_path.stat().st_mtime_ns
841
+ except OSError:
842
+ existing_mtime_ns = 0
843
+ if self._artifact_item_rank(
844
+ record,
845
+ path=artifact_path,
846
+ mtime_ns=next_mtime_ns,
847
+ ) >= self._artifact_item_rank(
848
+ existing_payload,
849
+ path=existing_path,
850
+ mtime_ns=existing_mtime_ns,
851
+ ):
852
+ artifacts[index] = next_item
853
+ replaced = True
854
+ break
855
+ if not replaced:
856
+ artifacts.append(next_item)
857
+ artifacts.sort(
858
+ key=lambda item: str(
859
+ ((item.get("payload") or {}).get("updated_at"))
860
+ or ((item.get("payload") or {}).get("created_at"))
861
+ or item.get("path")
862
+ or ""
863
+ )
864
+ )
865
+ return self._write_artifact_projection_locked(
866
+ quest_root,
867
+ state_kind=resolved_current_kind,
868
+ state=resolved_current_state,
869
+ artifacts=artifacts,
870
+ )
871
+
872
+ def _collect_artifacts_raw(self, quest_root: Path) -> list[dict[str, Any]]:
447
873
  artifacts_by_identity: dict[str, dict[str, Any]] = {}
448
874
  for root in self._artifact_roots(quest_root):
449
875
  artifacts_root = root / "artifacts"
@@ -494,33 +920,1689 @@ class QuestService:
494
920
  )
495
921
  return artifacts
496
922
 
497
- def _active_baseline_attachment(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
498
- attachments: list[dict[str, Any]] = []
499
- seen_paths: set[str] = set()
500
- for root in (workspace_root, quest_root):
501
- attachment_root = root / "baselines" / "imported"
502
- if not attachment_root.exists():
923
+ def _collect_artifacts(self, quest_root: Path) -> list[dict[str, Any]]:
924
+ state_kind, state = self._artifact_projection_state(quest_root)
925
+ projection_path = self._artifact_projection_path(quest_root)
926
+ cached_projection = self._read_cached_json(projection_path, {})
927
+ if (
928
+ isinstance(cached_projection, dict)
929
+ and int(cached_projection.get("schema_version") or 0) == 2
930
+ and cached_projection.get("state_kind") == state_kind
931
+ and self._json_compatible_state(cached_projection.get("state")) == self._json_compatible_state(state)
932
+ and isinstance(cached_projection.get("artifacts"), list)
933
+ ):
934
+ return [
935
+ dict(item)
936
+ for item in cached_projection.get("artifacts") or []
937
+ if isinstance(item, dict)
938
+ ]
939
+
940
+ with self._artifact_projection_lock(quest_root):
941
+ cached_projection = self._read_cached_json(projection_path, {})
942
+ if (
943
+ isinstance(cached_projection, dict)
944
+ and int(cached_projection.get("schema_version") or 0) == 2
945
+ and cached_projection.get("state_kind") == state_kind
946
+ and self._json_compatible_state(cached_projection.get("state")) == self._json_compatible_state(state)
947
+ and isinstance(cached_projection.get("artifacts"), list)
948
+ ):
949
+ return [
950
+ dict(item)
951
+ for item in cached_projection.get("artifacts") or []
952
+ if isinstance(item, dict)
953
+ ]
954
+ return self.refresh_artifact_projection(
955
+ quest_root,
956
+ state_kind=state_kind,
957
+ state=state,
958
+ )
959
+
960
+ def _collect_run_artifacts_raw(
961
+ self,
962
+ quest_root: Path,
963
+ *,
964
+ run_kind: str | None = None,
965
+ ) -> list[dict[str, Any]]:
966
+ artifacts_by_identity: dict[str, dict[str, Any]] = {}
967
+ normalized_run_kind = str(run_kind or "").strip()
968
+ for root in self._artifact_roots(quest_root):
969
+ runs_root = root / "artifacts" / "runs"
970
+ if not runs_root.exists():
971
+ continue
972
+ for path in sorted(runs_root.glob("*.json")):
973
+ item = self._read_cached_json(path, {})
974
+ payload = item if isinstance(item, dict) else {}
975
+ if normalized_run_kind and str(payload.get("run_kind") or "").strip() != normalized_run_kind:
976
+ continue
977
+ try:
978
+ mtime_ns = path.stat().st_mtime_ns
979
+ except OSError:
980
+ mtime_ns = 0
981
+ artifact = {
982
+ "kind": "run",
983
+ "path": str(path),
984
+ "payload": item,
985
+ "workspace_root": str(root),
986
+ }
987
+ identity = self._artifact_item_identity(path, payload, kind="run")
988
+ existing = artifacts_by_identity.get(identity)
989
+ existing_payload = existing.get("payload") if isinstance((existing or {}).get("payload"), dict) else {}
990
+ existing_path = Path(str((existing or {}).get("path") or path))
991
+ try:
992
+ existing_mtime_ns = existing_path.stat().st_mtime_ns if existing else 0
993
+ except OSError:
994
+ existing_mtime_ns = 0
995
+ if existing is None or self._artifact_item_rank(
996
+ payload,
997
+ path=path,
998
+ mtime_ns=mtime_ns,
999
+ ) >= self._artifact_item_rank(
1000
+ existing_payload,
1001
+ path=existing_path,
1002
+ mtime_ns=existing_mtime_ns,
1003
+ ):
1004
+ artifacts_by_identity[identity] = artifact
1005
+ artifacts = list(artifacts_by_identity.values())
1006
+ artifacts.sort(
1007
+ key=lambda item: str(
1008
+ ((item.get("payload") or {}).get("updated_at"))
1009
+ or ((item.get("payload") or {}).get("created_at"))
1010
+ or item.get("path")
1011
+ or ""
1012
+ )
1013
+ )
1014
+ return artifacts
1015
+
1016
+ @staticmethod
1017
+ def _projection_id(kind: str) -> str:
1018
+ return f"{kind}.v1"
1019
+
1020
+ @staticmethod
1021
+ def _projection_directory(quest_root: Path) -> Path:
1022
+ return quest_root / ".ds" / "projections"
1023
+
1024
+ @classmethod
1025
+ def _projection_manifest_path(cls, quest_root: Path) -> Path:
1026
+ return cls._projection_directory(quest_root) / "manifest.json"
1027
+
1028
+ @classmethod
1029
+ def _projection_payload_path(cls, quest_root: Path, kind: str) -> Path:
1030
+ return cls._projection_directory(quest_root) / f"{cls._projection_id(kind)}.json"
1031
+
1032
+ @classmethod
1033
+ def _projection_lock_path(cls, quest_root: Path, kind: str) -> Path:
1034
+ return cls._projection_directory(quest_root) / f"{cls._projection_id(kind)}.lock"
1035
+
1036
+ def _projection_build_key(self, quest_root: Path, kind: str) -> str:
1037
+ return f"{quest_root.resolve()}::{kind}"
1038
+
1039
+ def _codex_history_events_state(self, quest_root: Path) -> tuple[tuple[str, tuple[int, int, int] | None], ...]:
1040
+ return self._glob_states(quest_root / ".ds" / "codex_history", "*/events.jsonl")
1041
+
1042
+ def _details_projection_state(self, quest_root: Path) -> tuple[Any, ...]:
1043
+ workspace_root = self.active_workspace_root(quest_root)
1044
+ core_paths = [
1045
+ self._quest_yaml_path(quest_root),
1046
+ quest_root / "status.md",
1047
+ quest_root / ".ds" / "runtime_state.json",
1048
+ quest_root / ".ds" / "research_state.json",
1049
+ quest_root / ".ds" / "interaction_state.json",
1050
+ quest_root / ".ds" / "bindings.json",
1051
+ quest_root / ".ds" / "bash_exec" / "summary.json",
1052
+ self._artifact_projection_path(quest_root),
1053
+ workspace_root / "brief.md",
1054
+ workspace_root / "plan.md",
1055
+ workspace_root / "status.md",
1056
+ workspace_root / "SUMMARY.md",
1057
+ ]
1058
+ return (
1059
+ str(workspace_root.resolve()),
1060
+ self._path_states(core_paths),
1061
+ self._codex_meta_state(quest_root),
1062
+ self._codex_history_events_state(quest_root),
1063
+ )
1064
+
1065
+ def _git_branch_projection_state(self, quest_root: Path) -> dict[str, Any]:
1066
+ result = run_command(
1067
+ [
1068
+ "git",
1069
+ "for-each-ref",
1070
+ "--sort=refname",
1071
+ "--format=%(refname:short)%09%(objectname)%09%(committerdate:iso-strict)",
1072
+ "refs/heads",
1073
+ ],
1074
+ cwd=quest_root,
1075
+ check=False,
1076
+ )
1077
+ refs = [line.strip() for line in str(result.stdout or "").splitlines() if line.strip()]
1078
+ if result.returncode != 0:
1079
+ refs = [f"error:{result.returncode}:{str(result.stderr or '').strip()}"]
1080
+ return {
1081
+ "current_ref": current_branch(quest_root),
1082
+ "head": head_commit(quest_root),
1083
+ "refs": refs,
1084
+ }
1085
+
1086
+ def _canvas_projection_state(self, quest_root: Path) -> tuple[Any, ...]:
1087
+ return (
1088
+ self._path_states(
1089
+ [
1090
+ self._quest_yaml_path(quest_root),
1091
+ quest_root / ".ds" / "research_state.json",
1092
+ self._artifact_projection_path(quest_root),
1093
+ ]
1094
+ ),
1095
+ self._git_branch_projection_state(quest_root),
1096
+ )
1097
+
1098
+ def _projection_state_for_kind(self, quest_root: Path, kind: str) -> Any:
1099
+ if kind == "details":
1100
+ return self._details_projection_state(quest_root)
1101
+ if kind == "canvas":
1102
+ return self._canvas_projection_state(quest_root)
1103
+ if kind == "git_canvas":
1104
+ return self._canvas_projection_state(quest_root)
1105
+ raise ValueError(f"Unsupported projection kind `{kind}`.")
1106
+
1107
+ def _projection_source_signature(self, quest_root: Path, kind: str) -> str:
1108
+ state = {
1109
+ "projection_id": self._projection_id(kind),
1110
+ "state": self._json_compatible_state(self._projection_state_for_kind(quest_root, kind)),
1111
+ }
1112
+ return sha256_text(json.dumps(state, ensure_ascii=False, sort_keys=True))
1113
+
1114
+ def _default_projection_status(self, kind: str) -> dict[str, Any]:
1115
+ return {
1116
+ "projection_id": self._projection_id(kind),
1117
+ "state": "missing",
1118
+ "progress_current": 0,
1119
+ "progress_total": 0,
1120
+ "current_step": None,
1121
+ "source_signature": None,
1122
+ "generated_at": None,
1123
+ "last_success_at": None,
1124
+ "error": None,
1125
+ }
1126
+
1127
+ def _normalize_projection_status(self, kind: str, raw: Any) -> dict[str, Any]:
1128
+ normalized = self._default_projection_status(kind)
1129
+ if isinstance(raw, dict):
1130
+ normalized.update(
1131
+ {
1132
+ "state": str(raw.get("state") or normalized["state"]).strip() or normalized["state"],
1133
+ "progress_current": max(0, int(raw.get("progress_current") or 0)),
1134
+ "progress_total": max(0, int(raw.get("progress_total") or 0)),
1135
+ "current_step": str(raw.get("current_step") or "").strip() or None,
1136
+ "source_signature": str(raw.get("source_signature") or "").strip() or None,
1137
+ "generated_at": str(raw.get("generated_at") or "").strip() or None,
1138
+ "last_success_at": str(raw.get("last_success_at") or "").strip() or None,
1139
+ "error": str(raw.get("error") or "").strip() or None,
1140
+ }
1141
+ )
1142
+ return normalized
1143
+
1144
+ def _read_projection_manifest(self, quest_root: Path) -> dict[str, Any]:
1145
+ manifest = self._read_cached_json(
1146
+ self._projection_manifest_path(quest_root),
1147
+ {
1148
+ "schema_version": _PROJECTION_SCHEMA_VERSION,
1149
+ "projections": {},
1150
+ },
1151
+ )
1152
+ if not isinstance(manifest, dict):
1153
+ return {
1154
+ "schema_version": _PROJECTION_SCHEMA_VERSION,
1155
+ "projections": {},
1156
+ }
1157
+ return manifest
1158
+
1159
+ def _read_projection_payload_file(self, quest_root: Path, kind: str) -> dict[str, Any] | None:
1160
+ payload = self._read_cached_json(self._projection_payload_path(quest_root, kind), {})
1161
+ if not isinstance(payload, dict):
1162
+ return None
1163
+ if str(payload.get("projection_id") or "").strip() != self._projection_id(kind):
1164
+ return None
1165
+ if not isinstance(payload.get("payload"), dict):
1166
+ return None
1167
+ return payload
1168
+
1169
+ def _write_projection_manifest_locked(
1170
+ self,
1171
+ quest_root: Path,
1172
+ kind: str,
1173
+ status: dict[str, Any],
1174
+ ) -> dict[str, Any]:
1175
+ path = self._projection_manifest_path(quest_root)
1176
+ ensure_dir(path.parent)
1177
+ manifest = read_json(path, {})
1178
+ if not isinstance(manifest, dict):
1179
+ manifest = {}
1180
+ projections = manifest.get("projections") if isinstance(manifest.get("projections"), dict) else {}
1181
+ next_status = self._normalize_projection_status(kind, status)
1182
+ projections = {
1183
+ **projections,
1184
+ kind: next_status,
1185
+ }
1186
+ write_json(
1187
+ path,
1188
+ {
1189
+ "schema_version": _PROJECTION_SCHEMA_VERSION,
1190
+ "updated_at": utc_now(),
1191
+ "projections": projections,
1192
+ },
1193
+ )
1194
+ return next_status
1195
+
1196
+ def _write_projection_payload_locked(
1197
+ self,
1198
+ quest_root: Path,
1199
+ kind: str,
1200
+ *,
1201
+ source_signature: str,
1202
+ payload: dict[str, Any],
1203
+ generated_at: str | None = None,
1204
+ ) -> dict[str, Any]:
1205
+ path = self._projection_payload_path(quest_root, kind)
1206
+ ensure_dir(path.parent)
1207
+ resolved_generated_at = generated_at or utc_now()
1208
+ wrapper = {
1209
+ "schema_version": _PROJECTION_SCHEMA_VERSION,
1210
+ "projection_id": self._projection_id(kind),
1211
+ "generated_at": resolved_generated_at,
1212
+ "source_signature": source_signature,
1213
+ "payload": copy.deepcopy(payload),
1214
+ }
1215
+ write_json(path, wrapper)
1216
+ return copy.deepcopy(payload)
1217
+
1218
+ @contextmanager
1219
+ def _projection_lock(self, quest_root: Path, kind: str):
1220
+ lock_key = self._projection_build_key(quest_root, kind)
1221
+ with self._quest_projection_locks_lock:
1222
+ thread_lock = self._quest_projection_locks.setdefault(lock_key, threading.Lock())
1223
+ with thread_lock:
1224
+ with advisory_file_lock(self._projection_lock_path(quest_root, kind)):
1225
+ yield
1226
+
1227
+ def _projection_build_active(self, quest_root: Path, kind: str) -> bool:
1228
+ build_key = self._projection_build_key(quest_root, kind)
1229
+ with self._quest_projection_builds_lock:
1230
+ thread = self._quest_projection_builds.get(build_key)
1231
+ if thread is not None and not thread.is_alive():
1232
+ self._quest_projection_builds.pop(build_key, None)
1233
+ thread = None
1234
+ return thread is not None
1235
+
1236
+ def _present_projection_status(
1237
+ self,
1238
+ quest_root: Path,
1239
+ kind: str,
1240
+ *,
1241
+ source_signature: str,
1242
+ payload_wrapper: dict[str, Any] | None,
1243
+ ) -> dict[str, Any]:
1244
+ manifest = self._read_projection_manifest(quest_root)
1245
+ projections = manifest.get("projections") if isinstance(manifest.get("projections"), dict) else {}
1246
+ status = self._normalize_projection_status(kind, projections.get(kind))
1247
+ payload_signature = (
1248
+ str(payload_wrapper.get("source_signature") or "").strip()
1249
+ if isinstance(payload_wrapper, dict)
1250
+ else None
1251
+ ) or None
1252
+ payload_generated_at = (
1253
+ str(payload_wrapper.get("generated_at") or "").strip()
1254
+ if isinstance(payload_wrapper, dict)
1255
+ else None
1256
+ ) or None
1257
+ payload_ready = (
1258
+ isinstance(payload_wrapper, dict)
1259
+ and isinstance(payload_wrapper.get("payload"), dict)
1260
+ and payload_signature == source_signature
1261
+ )
1262
+ if payload_ready:
1263
+ status.update(
1264
+ {
1265
+ "state": "ready",
1266
+ "source_signature": source_signature,
1267
+ "generated_at": payload_generated_at,
1268
+ "last_success_at": payload_generated_at or status.get("last_success_at"),
1269
+ "progress_current": _PROJECTION_BUILD_TOTAL_STEPS,
1270
+ "progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
1271
+ "current_step": None,
1272
+ "error": None,
1273
+ }
1274
+ )
1275
+ return status
1276
+ if self._projection_build_active(quest_root, kind):
1277
+ status["state"] = "building" if status.get("state") != "queued" else "queued"
1278
+ status["progress_total"] = max(int(status.get("progress_total") or 0), _PROJECTION_BUILD_TOTAL_STEPS)
1279
+ status["current_step"] = status.get("current_step") or "Building projection"
1280
+ return status
1281
+ if isinstance(payload_wrapper, dict) and isinstance(payload_wrapper.get("payload"), dict):
1282
+ status.update(
1283
+ {
1284
+ "state": "stale",
1285
+ "generated_at": payload_generated_at,
1286
+ "last_success_at": payload_generated_at or status.get("last_success_at"),
1287
+ "progress_current": 0,
1288
+ "progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
1289
+ "current_step": "Queued for refresh",
1290
+ }
1291
+ )
1292
+ return status
1293
+ if status.get("state") == "failed":
1294
+ status["progress_total"] = max(int(status.get("progress_total") or 0), _PROJECTION_BUILD_TOTAL_STEPS)
1295
+ return status
1296
+ return self._default_projection_status(kind)
1297
+
1298
+ def _queue_projection_build(self, quest_root: Path, kind: str, *, source_signature: str) -> None:
1299
+ if self._projection_build_active(quest_root, kind):
1300
+ return
1301
+
1302
+ with self._projection_lock(quest_root, kind):
1303
+ payload_wrapper = self._read_projection_payload_file(quest_root, kind)
1304
+ if (
1305
+ isinstance(payload_wrapper, dict)
1306
+ and str(payload_wrapper.get("source_signature") or "").strip() == source_signature
1307
+ and isinstance(payload_wrapper.get("payload"), dict)
1308
+ ):
1309
+ ready_status = self._default_projection_status(kind)
1310
+ ready_status.update(
1311
+ {
1312
+ "state": "ready",
1313
+ "source_signature": source_signature,
1314
+ "generated_at": str(payload_wrapper.get("generated_at") or "").strip() or None,
1315
+ "last_success_at": str(payload_wrapper.get("generated_at") or "").strip() or None,
1316
+ "progress_current": _PROJECTION_BUILD_TOTAL_STEPS,
1317
+ "progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
1318
+ }
1319
+ )
1320
+ self._write_projection_manifest_locked(quest_root, kind, ready_status)
1321
+ return
1322
+ queued_status = self._default_projection_status(kind)
1323
+ queued_status.update(
1324
+ {
1325
+ "state": "queued",
1326
+ "source_signature": source_signature,
1327
+ "progress_current": 0,
1328
+ "progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
1329
+ "current_step": "Queued for background rebuild",
1330
+ "error": None,
1331
+ }
1332
+ )
1333
+ self._write_projection_manifest_locked(quest_root, kind, queued_status)
1334
+
1335
+ build_key = self._projection_build_key(quest_root, kind)
1336
+
1337
+ def _update_progress(current: int, step: str | None) -> None:
1338
+ with self._projection_lock(quest_root, kind):
1339
+ manifest = self._read_projection_manifest(quest_root)
1340
+ projections = manifest.get("projections") if isinstance(manifest.get("projections"), dict) else {}
1341
+ status = self._normalize_projection_status(kind, projections.get(kind))
1342
+ status.update(
1343
+ {
1344
+ "state": "building",
1345
+ "source_signature": source_signature,
1346
+ "progress_current": max(0, min(current, _PROJECTION_BUILD_TOTAL_STEPS)),
1347
+ "progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
1348
+ "current_step": step,
1349
+ "error": None,
1350
+ }
1351
+ )
1352
+ self._write_projection_manifest_locked(quest_root, kind, status)
1353
+
1354
+ def _worker() -> None:
1355
+ try:
1356
+ _update_progress(0, "Preparing projection inputs")
1357
+ payload = self._build_projection_payload(
1358
+ quest_root,
1359
+ kind,
1360
+ source_signature=source_signature,
1361
+ update_progress=_update_progress,
1362
+ )
1363
+ _update_progress(_PROJECTION_BUILD_TOTAL_STEPS, "Writing projection")
1364
+ generated_at = utc_now()
1365
+ with self._projection_lock(quest_root, kind):
1366
+ self._write_projection_payload_locked(
1367
+ quest_root,
1368
+ kind,
1369
+ source_signature=source_signature,
1370
+ payload=payload,
1371
+ generated_at=generated_at,
1372
+ )
1373
+ ready_status = self._default_projection_status(kind)
1374
+ ready_status.update(
1375
+ {
1376
+ "state": "ready",
1377
+ "source_signature": source_signature,
1378
+ "generated_at": generated_at,
1379
+ "last_success_at": generated_at,
1380
+ "progress_current": _PROJECTION_BUILD_TOTAL_STEPS,
1381
+ "progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
1382
+ "current_step": None,
1383
+ "error": None,
1384
+ }
1385
+ )
1386
+ self._write_projection_manifest_locked(quest_root, kind, ready_status)
1387
+ except Exception as exc:
1388
+ with self._projection_lock(quest_root, kind):
1389
+ failed_status = self._default_projection_status(kind)
1390
+ failed_status.update(
1391
+ {
1392
+ "state": "failed",
1393
+ "source_signature": source_signature,
1394
+ "progress_current": 0,
1395
+ "progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
1396
+ "current_step": None,
1397
+ "error": str(exc),
1398
+ }
1399
+ )
1400
+ self._write_projection_manifest_locked(quest_root, kind, failed_status)
1401
+ finally:
1402
+ with self._quest_projection_builds_lock:
1403
+ active = self._quest_projection_builds.get(build_key)
1404
+ if active is threading.current_thread():
1405
+ self._quest_projection_builds.pop(build_key, None)
1406
+
1407
+ worker = threading.Thread(
1408
+ target=_worker,
1409
+ daemon=True,
1410
+ name=f"ds-projection-{quest_root.name}-{kind}",
1411
+ )
1412
+ with self._quest_projection_builds_lock:
1413
+ self._quest_projection_builds[build_key] = worker
1414
+ worker.start()
1415
+
1416
+ def _recent_codex_runs(self, quest_root: Path, *, limit: int = 5) -> list[dict[str, Any]]:
1417
+ history_root = quest_root / ".ds" / "codex_history"
1418
+ if not history_root.exists():
1419
+ return []
1420
+ runs: list[dict[str, Any]] = []
1421
+ for meta_path in sorted(history_root.glob("*/meta.json")):
1422
+ payload = self._read_cached_json(meta_path, {})
1423
+ if not isinstance(payload, dict) or not payload:
1424
+ continue
1425
+ record = dict(payload)
1426
+ record.setdefault("history_root", str(meta_path.parent))
1427
+ runs.append(record)
1428
+ runs.sort(
1429
+ key=lambda item: str(
1430
+ item.get("updated_at")
1431
+ or item.get("completed_at")
1432
+ or item.get("created_at")
1433
+ or item.get("run_id")
1434
+ or ""
1435
+ )
1436
+ )
1437
+ return runs[-limit:]
1438
+
1439
+ def _build_workflow_payload(
1440
+ self,
1441
+ quest_id: str,
1442
+ quest_root: Path,
1443
+ workspace_root: Path,
1444
+ *,
1445
+ recent_runs: list[dict[str, Any]],
1446
+ recent_artifacts: list[dict[str, Any]],
1447
+ ) -> dict[str, Any]:
1448
+ entries: list[dict[str, Any]] = []
1449
+ changed_files: list[dict[str, Any]] = []
1450
+ seen_files: set[str] = set()
1451
+
1452
+ def add_file(path: str | None, *, source: str, document_id: str | None = None, writable: bool | None = None) -> None:
1453
+ if not path:
1454
+ return
1455
+ normalized = str(path)
1456
+ if normalized in seen_files:
1457
+ return
1458
+ seen_files.add(normalized)
1459
+ resolved_document_id = document_id or self._path_to_document_id(
1460
+ normalized,
1461
+ quest_root=quest_root,
1462
+ workspace_root=workspace_root,
1463
+ )
1464
+ changed_files.append(
1465
+ {
1466
+ "path": normalized,
1467
+ "source": source,
1468
+ "document_id": resolved_document_id,
1469
+ "writable": writable,
1470
+ }
1471
+ )
1472
+
1473
+ for relative in ("brief.md", "plan.md", "status.md", "SUMMARY.md"):
1474
+ add_file(
1475
+ str(workspace_root / relative),
1476
+ source="document",
1477
+ document_id=relative,
1478
+ writable=True,
1479
+ )
1480
+
1481
+ for run in recent_runs:
1482
+ run_id = str(run.get("run_id") or "run")
1483
+ entries.append(
1484
+ {
1485
+ "id": f"run:{run_id}",
1486
+ "kind": "run",
1487
+ "run_id": run_id,
1488
+ "skill_id": run.get("skill_id"),
1489
+ "title": run_id,
1490
+ "summary": run.get("summary") or "Run completed.",
1491
+ "status": "completed" if run.get("exit_code", 0) == 0 else "failed",
1492
+ "created_at": run.get("completed_at") or run.get("created_at") or run.get("updated_at"),
1493
+ "paths": [item for item in [run.get("history_root"), run.get("run_root"), run.get("output_path")] if item],
1494
+ }
1495
+ )
1496
+ for path in (run.get("history_root"), run.get("run_root"), run.get("output_path")):
1497
+ add_file(path, source="run")
1498
+ history_root = run.get("history_root")
1499
+ if history_root:
1500
+ entries.extend(
1501
+ self._parse_codex_history_cached(
1502
+ Path(str(history_root)),
1503
+ quest_id=quest_id,
1504
+ run_id=run_id,
1505
+ skill_id=run.get("skill_id"),
1506
+ )
1507
+ )
1508
+
1509
+ for artifact in recent_artifacts:
1510
+ payload = artifact.get("payload") if isinstance(artifact.get("payload"), dict) else {}
1511
+ artifact_path = artifact.get("path")
1512
+ entries.append(
1513
+ {
1514
+ "id": f"artifact:{payload.get('artifact_id') or artifact_path}",
1515
+ "kind": "artifact",
1516
+ "title": str(payload.get("artifact_id") or artifact.get("kind") or "artifact"),
1517
+ "summary": payload.get("summary") or payload.get("message") or payload.get("reason") or "Artifact updated.",
1518
+ "status": payload.get("status"),
1519
+ "reason": payload.get("reason"),
1520
+ "created_at": payload.get("updated_at") or payload.get("created_at"),
1521
+ "paths": list((payload.get("paths") or {}).values()) + ([str(artifact_path)] if artifact_path else []),
1522
+ }
1523
+ )
1524
+ add_file(str(artifact_path) if artifact_path else None, source="artifact")
1525
+ for path in (payload.get("paths") or {}).values():
1526
+ add_file(str(path), source="artifact_path")
1527
+
1528
+ entries.sort(key=lambda item: str(item.get("created_at") or item.get("id") or ""))
1529
+ return {
1530
+ "quest_id": quest_id,
1531
+ "quest_root": str(quest_root.resolve()),
1532
+ "entries": entries[-80:],
1533
+ "changed_files": changed_files[-30:],
1534
+ }
1535
+
1536
+ def _build_details_projection_payload(
1537
+ self,
1538
+ quest_root: Path,
1539
+ *,
1540
+ source_signature: str,
1541
+ update_progress: Any,
1542
+ ) -> dict[str, Any]:
1543
+ quest_id = quest_root.name
1544
+ workspace_root = self.active_workspace_root(quest_root)
1545
+ update_progress(1, "Loading recent workflow sources")
1546
+ recent_artifacts = self._collect_artifacts(quest_root)[-8:]
1547
+ recent_runs = self._recent_codex_runs(quest_root, limit=5)
1548
+ update_progress(2, "Materializing workflow timeline")
1549
+ return self._build_workflow_payload(
1550
+ quest_id,
1551
+ quest_root,
1552
+ workspace_root,
1553
+ recent_runs=recent_runs,
1554
+ recent_artifacts=recent_artifacts,
1555
+ )
1556
+
1557
+ def _build_canvas_projection_payload(
1558
+ self,
1559
+ quest_root: Path,
1560
+ *,
1561
+ source_signature: str,
1562
+ update_progress: Any,
1563
+ ) -> dict[str, Any]:
1564
+ update_progress(1, "Scanning branch references")
1565
+ update_progress(2, "Computing branch canvas")
1566
+ return list_branch_canvas(quest_root, quest_id=quest_root.name)
1567
+
1568
+ def _build_git_canvas_projection_payload(
1569
+ self,
1570
+ quest_root: Path,
1571
+ *,
1572
+ source_signature: str,
1573
+ update_progress: Any,
1574
+ ) -> dict[str, Any]:
1575
+ update_progress(1, "Scanning commit history")
1576
+ update_progress(2, "Computing commit canvas")
1577
+ return list_commit_canvas(quest_root, quest_id=quest_root.name)
1578
+
1579
+ def _build_projection_payload(
1580
+ self,
1581
+ quest_root: Path,
1582
+ kind: str,
1583
+ *,
1584
+ source_signature: str,
1585
+ update_progress: Any,
1586
+ ) -> dict[str, Any]:
1587
+ if kind == "details":
1588
+ return self._build_details_projection_payload(
1589
+ quest_root,
1590
+ source_signature=source_signature,
1591
+ update_progress=update_progress,
1592
+ )
1593
+ if kind == "canvas":
1594
+ return self._build_canvas_projection_payload(
1595
+ quest_root,
1596
+ source_signature=source_signature,
1597
+ update_progress=update_progress,
1598
+ )
1599
+ if kind == "git_canvas":
1600
+ return self._build_git_canvas_projection_payload(
1601
+ quest_root,
1602
+ source_signature=source_signature,
1603
+ update_progress=update_progress,
1604
+ )
1605
+ raise ValueError(f"Unsupported projection kind `{kind}`.")
1606
+
1607
+ def _placeholder_workflow_payload(self, quest_id: str, quest_root: Path) -> dict[str, Any]:
1608
+ workspace_root = self.active_workspace_root(quest_root)
1609
+ return self._build_workflow_payload(
1610
+ quest_id,
1611
+ quest_root,
1612
+ workspace_root,
1613
+ recent_runs=[],
1614
+ recent_artifacts=[],
1615
+ )
1616
+
1617
+ def _placeholder_canvas_payload(self, quest_id: str, quest_root: Path) -> dict[str, Any]:
1618
+ research_state = self.read_research_state(quest_root)
1619
+ default_ref = (
1620
+ str(research_state.get("research_head_branch") or "").strip()
1621
+ or str(research_state.get("current_workspace_branch") or "").strip()
1622
+ or current_branch(quest_root)
1623
+ )
1624
+ return {
1625
+ "quest_id": quest_id,
1626
+ "default_ref": default_ref,
1627
+ "current_ref": default_ref,
1628
+ "head": head_commit(quest_root),
1629
+ "nodes": [],
1630
+ "edges": [],
1631
+ "views": {
1632
+ "ideas": [],
1633
+ "analysis": [],
1634
+ },
1635
+ }
1636
+
1637
+ def _placeholder_git_canvas_payload(self, quest_id: str, quest_root: Path) -> dict[str, Any]:
1638
+ research_state = self.read_research_state(quest_root)
1639
+ return {
1640
+ "quest_id": quest_id,
1641
+ "workspace_mode": str(research_state.get("workspace_mode") or "copilot").strip() or "copilot",
1642
+ "head": head_commit(quest_root),
1643
+ "current_ref": current_branch(quest_root),
1644
+ "nodes": [],
1645
+ "edges": [],
1646
+ }
1647
+
1648
+ def _projected_payload(self, quest_id: str, kind: str) -> dict[str, Any]:
1649
+ quest_root = self._quest_root(quest_id)
1650
+ source_signature = self._projection_source_signature(quest_root, kind)
1651
+ payload_wrapper = self._read_projection_payload_file(quest_root, kind)
1652
+ payload_ready = (
1653
+ isinstance(payload_wrapper, dict)
1654
+ and str(payload_wrapper.get("source_signature") or "").strip() == source_signature
1655
+ and isinstance(payload_wrapper.get("payload"), dict)
1656
+ )
1657
+ if not payload_ready:
1658
+ self._queue_projection_build(quest_root, kind, source_signature=source_signature)
1659
+ payload_wrapper = self._read_projection_payload_file(quest_root, kind)
1660
+ status = self._present_projection_status(
1661
+ quest_root,
1662
+ kind,
1663
+ source_signature=source_signature,
1664
+ payload_wrapper=payload_wrapper,
1665
+ )
1666
+ payload = (
1667
+ copy.deepcopy(payload_wrapper.get("payload"))
1668
+ if isinstance(payload_wrapper, dict) and isinstance(payload_wrapper.get("payload"), dict)
1669
+ else None
1670
+ )
1671
+ if payload is None:
1672
+ if kind == "details":
1673
+ payload = self._placeholder_workflow_payload(quest_id, quest_root)
1674
+ elif kind == "git_canvas":
1675
+ payload = self._placeholder_git_canvas_payload(quest_id, quest_root)
1676
+ else:
1677
+ payload = self._placeholder_canvas_payload(quest_id, quest_root)
1678
+ payload["projection_status"] = status
1679
+ return payload
1680
+
1681
+ def prime_projection(self, quest_id: str, kind: str) -> None:
1682
+ quest_root = self._quest_root(quest_id)
1683
+ self._queue_projection_build(
1684
+ quest_root,
1685
+ kind,
1686
+ source_signature=self._projection_source_signature(quest_root, kind),
1687
+ )
1688
+
1689
+ def schedule_projection_refresh(
1690
+ self,
1691
+ quest_root: Path,
1692
+ *,
1693
+ kinds: tuple[str, ...] | list[str] | None = None,
1694
+ throttle_seconds: float = _PROJECTION_REFRESH_THROTTLE_SECONDS,
1695
+ ) -> None:
1696
+ resolved_kinds = [
1697
+ str(kind).strip()
1698
+ for kind in (kinds or ("details", "canvas", "git_canvas"))
1699
+ if str(kind).strip() in {"details", "canvas", "git_canvas"}
1700
+ ]
1701
+ if not resolved_kinds:
1702
+ return
1703
+ min_interval = max(0.0, float(throttle_seconds))
1704
+ now = time.monotonic()
1705
+ for kind in resolved_kinds:
1706
+ build_key = self._projection_build_key(quest_root, kind)
1707
+ if self._projection_build_active(quest_root, kind):
1708
+ continue
1709
+ with self._quest_projection_refresh_lock:
1710
+ previous = float(self._quest_projection_refresh_at.get(build_key) or 0.0)
1711
+ if min_interval > 0 and now - previous < min_interval:
1712
+ continue
1713
+ self._quest_projection_refresh_at[build_key] = now
1714
+ try:
1715
+ self._queue_projection_build(
1716
+ quest_root,
1717
+ kind,
1718
+ source_signature=self._projection_source_signature(quest_root, kind),
1719
+ )
1720
+ except Exception:
1721
+ continue
1722
+
1723
+ def git_branch_canvas(self, quest_id: str) -> dict[str, Any]:
1724
+ return self._projected_payload(quest_id, "canvas")
1725
+
1726
+ def git_commit_canvas(self, quest_id: str) -> dict[str, Any]:
1727
+ return self._projected_payload(quest_id, "git_canvas")
1728
+
1729
+ def _active_baseline_attachment(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
1730
+ attachments: list[dict[str, Any]] = []
1731
+ seen_paths: set[str] = set()
1732
+ for root in (workspace_root, quest_root):
1733
+ attachment_root = root / "baselines" / "imported"
1734
+ if not attachment_root.exists():
1735
+ continue
1736
+ for path in sorted(attachment_root.glob("*/attachment.yaml")):
1737
+ key = str(path.resolve())
1738
+ if key in seen_paths:
1739
+ continue
1740
+ seen_paths.add(key)
1741
+ payload = self._read_cached_yaml(path, {})
1742
+ baseline_id = str(payload.get("source_baseline_id") or "").strip() if isinstance(payload, dict) else ""
1743
+ if baseline_id and self.baseline_registry.is_deleted(baseline_id):
1744
+ continue
1745
+ if isinstance(payload, dict) and payload:
1746
+ attachments.append(payload)
1747
+ if not attachments:
1748
+ return None
1749
+ return max(
1750
+ attachments,
1751
+ key=lambda item: (
1752
+ str(item.get("attached_at") or ""),
1753
+ str(item.get("source_baseline_id") or ""),
1754
+ ),
1755
+ )
1756
+
1757
+ @staticmethod
1758
+ def _markdown_excerpt(path: Path, *, max_lines: int = 8) -> str | None:
1759
+ if not path.exists() or not path.is_file():
1760
+ return None
1761
+ text = read_text(path, "")
1762
+ if not text.strip():
1763
+ return None
1764
+ lines = [line.rstrip() for line in text.splitlines() if line.strip()]
1765
+ if not lines:
1766
+ return None
1767
+ excerpt = "\n".join(lines[:max_lines]).strip()
1768
+ return excerpt or None
1769
+
1770
+ def _snapshot_workspace_candidates(self, quest_root: Path, workspace_root: Path) -> list[Path]:
1771
+ candidates: list[Path] = []
1772
+ seen: set[str] = set()
1773
+
1774
+ def add(path: Path | None) -> None:
1775
+ if path is None:
1776
+ return
1777
+ resolved = path.resolve()
1778
+ key = str(resolved)
1779
+ if key in seen or not resolved.exists():
1780
+ return
1781
+ seen.add(key)
1782
+ candidates.append(resolved)
1783
+
1784
+ add(workspace_root)
1785
+ add(quest_root)
1786
+ worktrees_root = quest_root / ".ds" / "worktrees"
1787
+ if worktrees_root.exists():
1788
+ for item in sorted(worktrees_root.iterdir()):
1789
+ if item.is_dir():
1790
+ add(item)
1791
+ return candidates
1792
+
1793
+ @staticmethod
1794
+ def _path_mtime(path: Path) -> float:
1795
+ try:
1796
+ return path.stat().st_mtime
1797
+ except OSError:
1798
+ return 0.0
1799
+
1800
+ def _best_paper_root(self, quest_root: Path, workspace_root: Path) -> Path | None:
1801
+ best_root: Path | None = None
1802
+ best_rank: tuple[int, float] = (-1, -1.0)
1803
+ for candidate in self._snapshot_workspace_candidates(quest_root, workspace_root):
1804
+ paper_root = candidate / "paper"
1805
+ if not paper_root.exists() or not paper_root.is_dir():
1806
+ continue
1807
+ selected_outline = paper_root / "selected_outline.json"
1808
+ bundle_manifest = paper_root / "paper_bundle_manifest.json"
1809
+ draft = paper_root / "draft.md"
1810
+ score = 0
1811
+ if selected_outline.exists():
1812
+ score += 4
1813
+ if bundle_manifest.exists():
1814
+ score += 5
1815
+ if draft.exists():
1816
+ score += 2
1817
+ latest = max(
1818
+ self._path_mtime(selected_outline),
1819
+ self._path_mtime(bundle_manifest),
1820
+ self._path_mtime(draft),
1821
+ self._path_mtime(paper_root),
1822
+ )
1823
+ rank = (score, latest)
1824
+ if rank > best_rank:
1825
+ best_rank = rank
1826
+ best_root = paper_root
1827
+ return best_root
1828
+
1829
+ def _outline_record_from_paper_root(self, paper_root: Path) -> dict[str, Any]:
1830
+ outline_root = paper_root / "outline"
1831
+ manifest_path = outline_root / "manifest.json"
1832
+ if manifest_path.exists():
1833
+ manifest = read_json(manifest_path, {})
1834
+ if isinstance(manifest, dict) and manifest:
1835
+ manifest_sections = [
1836
+ dict(item) for item in (manifest.get("sections") or []) if isinstance(item, dict)
1837
+ ]
1838
+ by_id = {
1839
+ str(item.get("section_id") or "").strip(): dict(item)
1840
+ for item in manifest_sections
1841
+ if str(item.get("section_id") or "").strip()
1842
+ }
1843
+ section_order = [
1844
+ str(item).strip() for item in (manifest.get("section_order") or []) if str(item).strip()
1845
+ ]
1846
+ sections_root = outline_root / "sections"
1847
+ if sections_root.exists():
1848
+ for section_dir in sorted(sections_root.iterdir()):
1849
+ if not section_dir.is_dir():
1850
+ continue
1851
+ section_id = section_dir.name
1852
+ section = dict(by_id.get(section_id) or {})
1853
+ section.setdefault("section_id", section_id)
1854
+ section.setdefault("title", section_id)
1855
+ result_table_payload = read_json(section_dir / "result_table.json", {})
1856
+ rows = result_table_payload.get("rows") if isinstance(result_table_payload, dict) else []
1857
+ section["result_table"] = rows if isinstance(rows, list) else []
1858
+ by_id[section_id] = section
1859
+ ordered_sections: list[dict[str, Any]] = []
1860
+ emitted: set[str] = set()
1861
+ for section_id in section_order:
1862
+ section = by_id.get(section_id)
1863
+ if section is None:
1864
+ continue
1865
+ ordered_sections.append(section)
1866
+ emitted.add(section_id)
1867
+ for section_id, section in by_id.items():
1868
+ if section_id in emitted:
1869
+ continue
1870
+ ordered_sections.append(section)
1871
+ return {
1872
+ "schema_version": 1,
1873
+ "outline_id": manifest.get("outline_id"),
1874
+ "status": manifest.get("status"),
1875
+ "title": manifest.get("title"),
1876
+ "note": manifest.get("note"),
1877
+ "story": manifest.get("story"),
1878
+ "ten_questions": manifest.get("ten_questions") if isinstance(manifest.get("ten_questions"), list) else [],
1879
+ "detailed_outline": manifest.get("detailed_outline") if isinstance(manifest.get("detailed_outline"), dict) else {},
1880
+ "sections": ordered_sections,
1881
+ "evidence_contract": manifest.get("evidence_contract") if isinstance(manifest.get("evidence_contract"), dict) else None,
1882
+ "created_at": manifest.get("created_at"),
1883
+ "updated_at": manifest.get("updated_at"),
1884
+ }
1885
+ selected_outline_path = paper_root / "selected_outline.json"
1886
+ payload = read_json(selected_outline_path, {})
1887
+ return payload if isinstance(payload, dict) else {}
1888
+
1889
+ def _paper_evidence_payload(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
1890
+ best_payload: dict[str, Any] | None = None
1891
+ best_rank: tuple[str, float] = ("", -1.0)
1892
+ for candidate in self._snapshot_workspace_candidates(quest_root, workspace_root):
1893
+ paper_root = candidate / "paper"
1894
+ ledger_json_path = paper_root / "evidence_ledger.json"
1895
+ if not ledger_json_path.exists():
1896
+ continue
1897
+ payload = read_json(ledger_json_path, {})
1898
+ if not isinstance(payload, dict) or not payload:
1899
+ continue
1900
+ items = [dict(item) for item in (payload.get("items") or []) if isinstance(item, dict)]
1901
+ latest = max(
1902
+ self._path_mtime(ledger_json_path),
1903
+ self._path_mtime(paper_root / "evidence_ledger.md"),
1904
+ self._path_mtime(paper_root),
1905
+ )
1906
+ rank = (str(payload.get("updated_at") or payload.get("created_at") or ""), latest)
1907
+ if rank < best_rank:
1908
+ continue
1909
+ best_rank = rank
1910
+ best_payload = {
1911
+ "paper_root": str(paper_root),
1912
+ "workspace_root": str(paper_root.parent),
1913
+ "selected_outline_ref": str(payload.get("selected_outline_ref") or "").strip() or None,
1914
+ "item_count": len(items),
1915
+ "main_text_ready_count": sum(
1916
+ 1
1917
+ for item in items
1918
+ if str(item.get("paper_role") or "").strip() == "main_text"
1919
+ and str(item.get("status") or "").strip().lower() in {"ready", "completed", "analyzed", "written", "recorded", "supported"}
1920
+ ),
1921
+ "appendix_item_count": sum(
1922
+ 1 for item in items if str(item.get("paper_role") or "").strip() == "appendix"
1923
+ ),
1924
+ "unmapped_item_count": sum(
1925
+ 1
1926
+ for item in items
1927
+ if not str(item.get("section_id") or "").strip() or not str(item.get("paper_role") or "").strip()
1928
+ ),
1929
+ "items": items[:40],
1930
+ "paths": {
1931
+ "ledger_json": str(ledger_json_path),
1932
+ "ledger_md": str(paper_root / "evidence_ledger.md") if (paper_root / "evidence_ledger.md").exists() else None,
1933
+ },
1934
+ }
1935
+ return best_payload
1936
+
1937
+ def _paper_contract_payload(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
1938
+ paper_root = self._best_paper_root(quest_root, workspace_root)
1939
+ if paper_root is None:
1940
+ return None
1941
+ selected_outline_path = paper_root / "selected_outline.json"
1942
+ selected_outline = self._outline_record_from_paper_root(paper_root)
1943
+ selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
1944
+ detailed_outline = (
1945
+ dict(selected_outline.get("detailed_outline") or {})
1946
+ if isinstance(selected_outline.get("detailed_outline"), dict)
1947
+ else {}
1948
+ )
1949
+ outline_manifest_path = paper_root / "outline" / "manifest.json"
1950
+ bundle_manifest_path = paper_root / "paper_bundle_manifest.json"
1951
+ bundle_manifest = read_json(bundle_manifest_path, {})
1952
+ bundle_manifest = bundle_manifest if isinstance(bundle_manifest, dict) else {}
1953
+ experiment_matrix_path = paper_root / "paper_experiment_matrix.md"
1954
+ experiment_matrix_json_path = paper_root / "paper_experiment_matrix.json"
1955
+ claim_map_path = paper_root / "claim_evidence_map.json"
1956
+ paper_line_state_path = paper_root / "paper_line_state.json"
1957
+ evidence_ledger = self._paper_evidence_payload(quest_root, workspace_root)
1958
+ checklist_path = paper_root / "review" / "submission_checklist.json"
1959
+ draft_path = paper_root / "draft.md"
1960
+ status_path = paper_root.parent / "status.md"
1961
+ summary_path = paper_root.parent / "SUMMARY.md"
1962
+
1963
+ raw_sections = selected_outline.get("sections") if isinstance(selected_outline.get("sections"), list) else []
1964
+ sections = []
1965
+ if raw_sections:
1966
+ for index, raw in enumerate(raw_sections, start=1):
1967
+ if not isinstance(raw, dict):
1968
+ continue
1969
+ title = str(raw.get("title") or raw.get("section_id") or "").strip()
1970
+ if not title:
1971
+ title = f"Section {index}"
1972
+ sections.append(
1973
+ {
1974
+ "section_id": str(raw.get("section_id") or slugify(title, f"section-{index}")).strip() or slugify(title, f"section-{index}"),
1975
+ "title": title,
1976
+ "paper_role": str(raw.get("paper_role") or "").strip() or None,
1977
+ "status": str(raw.get("status") or "").strip() or None,
1978
+ "claims": raw.get("claims") if isinstance(raw.get("claims"), list) else [],
1979
+ "required_items": raw.get("required_items") if isinstance(raw.get("required_items"), list) else [],
1980
+ "optional_items": raw.get("optional_items") if isinstance(raw.get("optional_items"), list) else [],
1981
+ "result_table": raw.get("result_table") if isinstance(raw.get("result_table"), list) else [],
1982
+ }
1983
+ )
1984
+ else:
1985
+ for item in detailed_outline.get("experimental_designs") or []:
1986
+ text = str(item or "").strip()
1987
+ if not text:
1988
+ continue
1989
+ sections.append(
1990
+ {
1991
+ "section_id": slugify(text, "section"),
1992
+ "title": text,
1993
+ "paper_role": "main_text",
1994
+ "status": "recorded",
1995
+ "claims": [],
1996
+ "required_items": [],
1997
+ "optional_items": [],
1998
+ "result_table": [],
1999
+ }
2000
+ )
2001
+
2002
+ return {
2003
+ "paper_root": str(paper_root),
2004
+ "workspace_root": str(paper_root.parent),
2005
+ "paper_branch": str(bundle_manifest.get("paper_branch") or "").strip() or current_branch(paper_root.parent),
2006
+ "source_branch": str(bundle_manifest.get("source_branch") or "").strip() or None,
2007
+ "selected_outline_ref": str(selected_outline.get("outline_id") or bundle_manifest.get("selected_outline_ref") or "").strip() or None,
2008
+ "title": str(selected_outline.get("title") or bundle_manifest.get("title") or "").strip() or None,
2009
+ "story": str(selected_outline.get("story") or "").strip() or None,
2010
+ "research_questions": detailed_outline.get("research_questions") if isinstance(detailed_outline.get("research_questions"), list) else [],
2011
+ "experimental_designs": detailed_outline.get("experimental_designs") if isinstance(detailed_outline.get("experimental_designs"), list) else [],
2012
+ "contributions": detailed_outline.get("contributions") if isinstance(detailed_outline.get("contributions"), list) else [],
2013
+ "evidence_contract": selected_outline.get("evidence_contract") if isinstance(selected_outline.get("evidence_contract"), dict) else None,
2014
+ "sections": sections,
2015
+ "evidence_summary": {
2016
+ "item_count": int((evidence_ledger or {}).get("item_count") or 0),
2017
+ "main_text_ready_count": int((evidence_ledger or {}).get("main_text_ready_count") or 0),
2018
+ "appendix_item_count": int((evidence_ledger or {}).get("appendix_item_count") or 0),
2019
+ "unmapped_item_count": int((evidence_ledger or {}).get("unmapped_item_count") or 0),
2020
+ },
2021
+ "summary": str(bundle_manifest.get("summary") or "").strip() or self._markdown_excerpt(summary_path),
2022
+ "paths": {
2023
+ "selected_outline": str(selected_outline_path) if selected_outline_path.exists() else None,
2024
+ "outline_manifest": str(outline_manifest_path) if outline_manifest_path.exists() else None,
2025
+ "experiment_matrix": str(experiment_matrix_path) if experiment_matrix_path.exists() else None,
2026
+ "experiment_matrix_json": str(experiment_matrix_json_path) if experiment_matrix_json_path.exists() else None,
2027
+ "bundle_manifest": str(bundle_manifest_path) if bundle_manifest_path.exists() else None,
2028
+ "claim_evidence_map": str(claim_map_path) if claim_map_path.exists() else None,
2029
+ "paper_line_state": str(paper_line_state_path) if paper_line_state_path.exists() else None,
2030
+ "evidence_ledger_json": str(((evidence_ledger or {}).get("paths") or {}).get("ledger_json")) if ((evidence_ledger or {}).get("paths") or {}).get("ledger_json") else None,
2031
+ "evidence_ledger_md": str(((evidence_ledger or {}).get("paths") or {}).get("ledger_md")) if ((evidence_ledger or {}).get("paths") or {}).get("ledger_md") else None,
2032
+ "submission_checklist": str(checklist_path) if checklist_path.exists() else None,
2033
+ "draft": str(draft_path) if draft_path.exists() else None,
2034
+ "status": str(status_path) if status_path.exists() else None,
2035
+ "summary": str(summary_path) if summary_path.exists() else None,
2036
+ },
2037
+ "bundle_manifest": bundle_manifest or None,
2038
+ "outline_payload": selected_outline or None,
2039
+ }
2040
+
2041
+ def _paper_lines_payload(self, quest_root: Path, workspace_root: Path) -> tuple[list[dict[str, Any]], str | None]:
2042
+ lines_by_id: dict[str, dict[str, Any]] = {}
2043
+ active_ref: str | None = None
2044
+ for candidate in self._snapshot_workspace_candidates(quest_root, workspace_root):
2045
+ paper_root = candidate / "paper"
2046
+ if not paper_root.exists() or not paper_root.is_dir():
2047
+ continue
2048
+ state_path = paper_root / "paper_line_state.json"
2049
+ payload = read_json(state_path, {}) if state_path.exists() else {}
2050
+ if not isinstance(payload, dict) or not payload:
2051
+ contract = self._paper_contract_payload(quest_root, candidate)
2052
+ if not contract:
2053
+ continue
2054
+ bundle_manifest = (
2055
+ dict(contract.get("bundle_manifest") or {})
2056
+ if isinstance(contract.get("bundle_manifest"), dict)
2057
+ else {}
2058
+ )
2059
+ payload = {
2060
+ "paper_line_id": slugify(
2061
+ "::".join(
2062
+ [
2063
+ str(contract.get("paper_branch") or "paper").strip() or "paper",
2064
+ str(contract.get("selected_outline_ref") or "outline").strip() or "outline",
2065
+ str(bundle_manifest.get("source_run_id") or "run").strip() or "run",
2066
+ ]
2067
+ ),
2068
+ "paper-line",
2069
+ ),
2070
+ "paper_branch": contract.get("paper_branch"),
2071
+ "paper_root": str(paper_root),
2072
+ "workspace_root": str(candidate),
2073
+ "source_branch": contract.get("source_branch"),
2074
+ "source_run_id": bundle_manifest.get("source_run_id"),
2075
+ "source_idea_id": bundle_manifest.get("source_idea_id"),
2076
+ "selected_outline_ref": contract.get("selected_outline_ref"),
2077
+ "title": contract.get("title"),
2078
+ "required_count": sum(len(item.get("required_items") or []) for item in (contract.get("sections") or [])),
2079
+ "ready_required_count": int((contract.get("evidence_summary") or {}).get("main_text_ready_count") or 0),
2080
+ "section_count": len(contract.get("sections") or []),
2081
+ "ready_section_count": 0,
2082
+ "unmapped_count": int((contract.get("evidence_summary") or {}).get("unmapped_item_count") or 0),
2083
+ "open_supplementary_count": 0,
2084
+ "draft_status": "present" if (paper_root / "draft.md").exists() else "missing",
2085
+ "bundle_status": "present" if (paper_root / "paper_bundle_manifest.json").exists() else "missing",
2086
+ "updated_at": "",
2087
+ }
2088
+ paper_line_id = str(payload.get("paper_line_id") or "").strip()
2089
+ if not paper_line_id:
2090
+ continue
2091
+ payload["paths"] = {
2092
+ "paper_line_state": str(state_path) if state_path.exists() else None,
2093
+ "paper_root": str(paper_root),
2094
+ }
2095
+ current = lines_by_id.get(paper_line_id)
2096
+ if current is None or str(payload.get("updated_at") or "") >= str(current.get("updated_at") or ""):
2097
+ lines_by_id[paper_line_id] = payload
2098
+ if str(candidate) == str(workspace_root):
2099
+ active_ref = paper_line_id
2100
+ lines = sorted(lines_by_id.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
2101
+ if not active_ref and lines:
2102
+ active_ref = str(lines[0].get("paper_line_id") or "").strip() or None
2103
+ return lines, active_ref
2104
+
2105
+ def _analysis_inventory_payload(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
2106
+ manifest_by_id: dict[str, dict[str, Any]] = {}
2107
+ campaigns_root = quest_root / ".ds" / "analysis_campaigns"
2108
+ if campaigns_root.exists():
2109
+ for path in sorted(campaigns_root.glob("*.json")):
2110
+ payload = read_json(path, {})
2111
+ if not isinstance(payload, dict) or not payload:
2112
+ continue
2113
+ campaign_id = str(payload.get("campaign_id") or path.stem).strip() or path.stem
2114
+ manifest_by_id[campaign_id] = payload
2115
+ campaigns_by_id: dict[str, dict[str, Any]] = {}
2116
+ for candidate in self._snapshot_workspace_candidates(quest_root, workspace_root):
2117
+ analysis_root = candidate / "experiments" / "analysis-results"
2118
+ if not analysis_root.exists() or not analysis_root.is_dir():
2119
+ continue
2120
+ for campaign_dir in sorted(analysis_root.iterdir()):
2121
+ if not campaign_dir.is_dir():
2122
+ continue
2123
+ campaign_id = campaign_dir.name
2124
+ todo_manifest_path = campaign_dir / "todo_manifest.json"
2125
+ campaign_md_path = campaign_dir / "campaign.md"
2126
+ summary_md_path = campaign_dir / "SUMMARY.md"
2127
+ todo_manifest = read_json(todo_manifest_path, {})
2128
+ todo_manifest = todo_manifest if isinstance(todo_manifest, dict) else {}
2129
+ campaign_manifest = dict(manifest_by_id.get(campaign_id) or {})
2130
+ todo_items = todo_manifest.get("todo_items") if isinstance(todo_manifest.get("todo_items"), list) else []
2131
+ manifest_slices = {
2132
+ str(item.get("slice_id") or "").strip(): dict(item)
2133
+ for item in (campaign_manifest.get("slices") or [])
2134
+ if isinstance(item, dict) and str(item.get("slice_id") or "").strip()
2135
+ }
2136
+ slice_files = []
2137
+ for path in sorted(campaign_dir.glob("*.md")):
2138
+ if path.name in {"campaign.md", "SUMMARY.md"}:
2139
+ continue
2140
+ slice_files.append(path)
2141
+ slices: list[dict[str, Any]] = []
2142
+ for index, path in enumerate(slice_files):
2143
+ matched_todo = todo_items[index] if index < len(todo_items) and isinstance(todo_items[index], dict) else {}
2144
+ slice_id = str(matched_todo.get("slice_id") or path.stem).strip() or path.stem
2145
+ title = str(matched_todo.get("title") or path.stem).strip() or path.stem
2146
+ manifest_slice = dict(manifest_slices.get(slice_id) or {})
2147
+ slices.append(
2148
+ {
2149
+ "slice_id": slice_id,
2150
+ "title": title,
2151
+ "status": str(manifest_slice.get("status") or matched_todo.get("status") or "completed").strip() or "completed",
2152
+ "tier": str(matched_todo.get("tier") or "").strip() or None,
2153
+ "exp_id": str(matched_todo.get("exp_id") or "").strip() or None,
2154
+ "paper_role": str(matched_todo.get("paper_placement") or matched_todo.get("paper_role") or "").strip() or None,
2155
+ "section_id": str(matched_todo.get("section_id") or "").strip() or None,
2156
+ "item_id": str(matched_todo.get("item_id") or "").strip() or None,
2157
+ "claim_links": matched_todo.get("claim_links") if isinstance(matched_todo.get("claim_links"), list) else [],
2158
+ "research_question": str(matched_todo.get("research_question") or "").strip() or None,
2159
+ "experimental_design": str(matched_todo.get("experimental_design") or "").strip() or None,
2160
+ "branch": str(manifest_slice.get("branch") or "").strip() or None,
2161
+ "worktree_root": str(manifest_slice.get("worktree_root") or "").strip() or None,
2162
+ "mapped": bool(
2163
+ str(matched_todo.get("section_id") or "").strip()
2164
+ and str(matched_todo.get("item_id") or "").strip()
2165
+ and str(matched_todo.get("paper_placement") or matched_todo.get("paper_role") or "").strip()
2166
+ ),
2167
+ "result_path": str(path),
2168
+ "result_excerpt": self._markdown_excerpt(path, max_lines=6),
2169
+ }
2170
+ )
2171
+ record = {
2172
+ "campaign_id": campaign_id,
2173
+ "title": str((todo_manifest.get("campaign_origin") or {}).get("reason") or campaign_id).strip() or campaign_id,
2174
+ "active_idea_id": str(campaign_manifest.get("active_idea_id") or "").strip() or None,
2175
+ "parent_run_id": str(campaign_manifest.get("parent_run_id") or "").strip() or None,
2176
+ "parent_branch": str(campaign_manifest.get("parent_branch") or "").strip() or None,
2177
+ "paper_line_id": str(campaign_manifest.get("paper_line_id") or "").strip() or None,
2178
+ "paper_line_branch": str(campaign_manifest.get("paper_line_branch") or "").strip() or None,
2179
+ "paper_line_root": str(campaign_manifest.get("paper_line_root") or "").strip() or None,
2180
+ "selected_outline_ref": str(campaign_manifest.get("selected_outline_ref") or todo_manifest.get("selected_outline_ref") or "").strip() or None,
2181
+ "todo_manifest_path": str(todo_manifest_path) if todo_manifest_path.exists() else None,
2182
+ "campaign_path": str(campaign_md_path) if campaign_md_path.exists() else None,
2183
+ "summary_path": str(summary_md_path) if summary_md_path.exists() else None,
2184
+ "summary_excerpt": self._markdown_excerpt(summary_md_path, max_lines=10),
2185
+ "updated_at": str(campaign_manifest.get("updated_at") or "").strip() or None,
2186
+ "slice_count": len(slices),
2187
+ "completed_slice_count": sum(1 for item in slices if str(item.get("status") or "") == "completed"),
2188
+ "mapped_slice_count": sum(1 for item in slices if bool(item.get("mapped"))),
2189
+ "pending_slice_count": sum(1 for item in slices if str(item.get("status") or "") != "completed"),
2190
+ "slices": slices,
2191
+ "_rank": (
2192
+ len(slices),
2193
+ max(
2194
+ self._path_mtime(summary_md_path),
2195
+ self._path_mtime(campaign_md_path),
2196
+ self._path_mtime(todo_manifest_path),
2197
+ self._path_mtime(campaigns_root / f"{campaign_id}.json"),
2198
+ self._path_mtime(campaign_dir),
2199
+ ),
2200
+ ),
2201
+ }
2202
+ current = campaigns_by_id.get(campaign_id)
2203
+ if current is None or record["_rank"] >= current["_rank"]:
2204
+ campaigns_by_id[campaign_id] = record
2205
+
2206
+ if not campaigns_by_id:
2207
+ return None
2208
+ campaigns = []
2209
+ total_slices = 0
2210
+ total_completed = 0
2211
+ total_mapped = 0
2212
+ for item in sorted(
2213
+ campaigns_by_id.values(),
2214
+ key=lambda payload: (payload["_rank"][1], payload["campaign_id"]),
2215
+ reverse=True,
2216
+ ):
2217
+ total_slices += int(item.get("slice_count") or 0)
2218
+ total_completed += int(item.get("completed_slice_count") or 0)
2219
+ total_mapped += int(item.get("mapped_slice_count") or 0)
2220
+ campaigns.append({key: value for key, value in item.items() if key != "_rank"})
2221
+ return {
2222
+ "campaign_count": len(campaigns),
2223
+ "slice_count": total_slices,
2224
+ "completed_slice_count": total_completed,
2225
+ "mapped_slice_count": total_mapped,
2226
+ "campaigns": campaigns,
2227
+ }
2228
+
2229
+ def _idea_lines_payload(
2230
+ self,
2231
+ quest_root: Path,
2232
+ *,
2233
+ paper_lines: list[dict[str, Any]],
2234
+ analysis_inventory: dict[str, Any] | None,
2235
+ ) -> tuple[list[dict[str, Any]], str | None]:
2236
+ artifacts = self._collect_artifacts(quest_root)
2237
+ research_state = self.read_research_state(quest_root)
2238
+ active_idea_id = str(research_state.get("active_idea_id") or "").strip() or None
2239
+ active_ref: str | None = None
2240
+ lines_by_id: dict[str, dict[str, Any]] = {}
2241
+
2242
+ def ensure_line(idea_id: str) -> dict[str, Any]:
2243
+ current = lines_by_id.get(idea_id)
2244
+ if current is None:
2245
+ current = {
2246
+ "idea_line_id": idea_id,
2247
+ "idea_id": idea_id,
2248
+ "idea_branch": None,
2249
+ "idea_title": None,
2250
+ "lineage_intent": None,
2251
+ "parent_branch": None,
2252
+ "latest_main_run_id": None,
2253
+ "latest_main_run_branch": None,
2254
+ "paper_line_id": None,
2255
+ "paper_branch": None,
2256
+ "selected_outline_ref": None,
2257
+ "analysis_campaign_count": 0,
2258
+ "analysis_slice_count": 0,
2259
+ "completed_analysis_slice_count": 0,
2260
+ "mapped_analysis_slice_count": 0,
2261
+ "required_count": 0,
2262
+ "ready_required_count": 0,
2263
+ "unmapped_count": 0,
2264
+ "open_supplementary_count": 0,
2265
+ "draft_status": None,
2266
+ "bundle_status": None,
2267
+ "updated_at": "",
2268
+ "paths": {
2269
+ "idea_md": None,
2270
+ "idea_draft": None,
2271
+ "paper_line_state": None,
2272
+ },
2273
+ }
2274
+ lines_by_id[idea_id] = current
2275
+ return current
2276
+
2277
+ def updated_rank(value: object) -> str:
2278
+ return str(value or "").strip()
2279
+
2280
+ for artifact in artifacts:
2281
+ kind = str(artifact.get("kind") or "").strip()
2282
+ payload = artifact.get("payload") if isinstance(artifact.get("payload"), dict) else {}
2283
+ if not payload:
503
2284
  continue
504
- for path in sorted(attachment_root.glob("*/attachment.yaml")):
505
- key = str(path.resolve())
506
- if key in seen_paths:
507
- continue
508
- seen_paths.add(key)
509
- payload = self._read_cached_yaml(path, {})
510
- baseline_id = str(payload.get("source_baseline_id") or "").strip() if isinstance(payload, dict) else ""
511
- if baseline_id and self.baseline_registry.is_deleted(baseline_id):
2285
+ idea_id = str(payload.get("idea_id") or "").strip()
2286
+ if not idea_id:
2287
+ continue
2288
+ entry = ensure_line(idea_id)
2289
+ if kind == "ideas":
2290
+ current_rank = updated_rank(entry.get("updated_at"))
2291
+ candidate_rank = updated_rank(payload.get("updated_at") or payload.get("created_at"))
2292
+ if candidate_rank >= current_rank:
2293
+ details = dict(payload.get("details") or {}) if isinstance(payload.get("details"), dict) else {}
2294
+ paths = dict(payload.get("paths") or {}) if isinstance(payload.get("paths"), dict) else {}
2295
+ entry["idea_branch"] = str(payload.get("branch") or "").strip() or entry.get("idea_branch")
2296
+ entry["idea_title"] = str(details.get("title") or payload.get("title") or "").strip() or entry.get("idea_title")
2297
+ entry["lineage_intent"] = str(payload.get("lineage_intent") or details.get("lineage_intent") or "").strip() or entry.get("lineage_intent")
2298
+ entry["parent_branch"] = str(payload.get("parent_branch") or details.get("parent_branch") or "").strip() or entry.get("parent_branch")
2299
+ entry["updated_at"] = candidate_rank or entry.get("updated_at")
2300
+ entry["paths"] = {
2301
+ **dict(entry.get("paths") or {}),
2302
+ "idea_md": str(paths.get("idea_md") or "").strip() or dict(entry.get("paths") or {}).get("idea_md"),
2303
+ "idea_draft": str(paths.get("idea_draft_md") or details.get("idea_draft_path") or "").strip()
2304
+ or dict(entry.get("paths") or {}).get("idea_draft"),
2305
+ }
2306
+ elif kind == "runs":
2307
+ branch = str(payload.get("branch") or "").strip()
2308
+ run_id = str(payload.get("run_id") or "").strip()
2309
+ run_kind = str(payload.get("run_kind") or "").strip().lower()
2310
+ if not run_id or branch.startswith("analysis/") or branch.startswith("paper/") or run_kind.startswith("analysis"):
512
2311
  continue
513
- if isinstance(payload, dict) and payload:
514
- attachments.append(payload)
515
- if not attachments:
516
- return None
517
- return max(
518
- attachments,
2312
+ current_rank = updated_rank(entry.get("latest_main_run_updated_at"))
2313
+ candidate_rank = updated_rank(payload.get("updated_at") or payload.get("created_at"))
2314
+ if candidate_rank >= current_rank:
2315
+ entry["latest_main_run_id"] = run_id
2316
+ entry["latest_main_run_branch"] = branch or entry.get("latest_main_run_branch")
2317
+ entry["latest_main_run_updated_at"] = candidate_rank
2318
+ entry["updated_at"] = max(updated_rank(entry.get("updated_at")), candidate_rank)
2319
+
2320
+ for line in paper_lines:
2321
+ idea_id = str(line.get("source_idea_id") or "").strip()
2322
+ if not idea_id:
2323
+ continue
2324
+ entry = ensure_line(idea_id)
2325
+ current_rank = updated_rank(entry.get("paper_line_updated_at"))
2326
+ candidate_rank = updated_rank(line.get("updated_at"))
2327
+ if candidate_rank >= current_rank:
2328
+ entry["paper_line_id"] = str(line.get("paper_line_id") or "").strip() or entry.get("paper_line_id")
2329
+ entry["paper_branch"] = str(line.get("paper_branch") or "").strip() or entry.get("paper_branch")
2330
+ entry["selected_outline_ref"] = str(line.get("selected_outline_ref") or "").strip() or entry.get("selected_outline_ref")
2331
+ entry["required_count"] = int(line.get("required_count") or 0)
2332
+ entry["ready_required_count"] = int(line.get("ready_required_count") or 0)
2333
+ entry["unmapped_count"] = int(line.get("unmapped_count") or 0)
2334
+ entry["open_supplementary_count"] = int(line.get("open_supplementary_count") or 0)
2335
+ entry["draft_status"] = str(line.get("draft_status") or "").strip() or None
2336
+ entry["bundle_status"] = str(line.get("bundle_status") or "").strip() or None
2337
+ entry["paper_line_updated_at"] = candidate_rank
2338
+ entry["updated_at"] = max(updated_rank(entry.get("updated_at")), candidate_rank)
2339
+ entry["paths"] = {
2340
+ **dict(entry.get("paths") or {}),
2341
+ "paper_line_state": str(((line.get("paths") or {}) if isinstance(line.get("paths"), dict) else {}).get("paper_line_state") or "").strip()
2342
+ or dict(entry.get("paths") or {}).get("paper_line_state"),
2343
+ }
2344
+
2345
+ campaigns = list((analysis_inventory or {}).get("campaigns") or []) if isinstance(analysis_inventory, dict) else []
2346
+ for campaign in campaigns:
2347
+ if not isinstance(campaign, dict):
2348
+ continue
2349
+ matched_idea_id = str(campaign.get("active_idea_id") or "").strip()
2350
+ if not matched_idea_id:
2351
+ matched_run_id = str(campaign.get("parent_run_id") or "").strip()
2352
+ matched_branch = str(campaign.get("parent_branch") or "").strip()
2353
+ for candidate in lines_by_id.values():
2354
+ if matched_run_id and matched_run_id == str(candidate.get("latest_main_run_id") or "").strip():
2355
+ matched_idea_id = str(candidate.get("idea_id") or "").strip()
2356
+ break
2357
+ if matched_branch and matched_branch in {
2358
+ str(candidate.get("idea_branch") or "").strip(),
2359
+ str(candidate.get("latest_main_run_branch") or "").strip(),
2360
+ }:
2361
+ matched_idea_id = str(candidate.get("idea_id") or "").strip()
2362
+ break
2363
+ if not matched_idea_id:
2364
+ continue
2365
+ entry = ensure_line(matched_idea_id)
2366
+ entry["analysis_campaign_count"] = int(entry.get("analysis_campaign_count") or 0) + 1
2367
+ entry["analysis_slice_count"] = int(entry.get("analysis_slice_count") or 0) + int(campaign.get("slice_count") or 0)
2368
+ entry["completed_analysis_slice_count"] = int(entry.get("completed_analysis_slice_count") or 0) + int(
2369
+ campaign.get("completed_slice_count") or 0
2370
+ )
2371
+ entry["mapped_analysis_slice_count"] = int(entry.get("mapped_analysis_slice_count") or 0) + int(
2372
+ campaign.get("mapped_slice_count") or 0
2373
+ )
2374
+ if not entry.get("paper_line_id") and str(campaign.get("paper_line_id") or "").strip():
2375
+ entry["paper_line_id"] = str(campaign.get("paper_line_id") or "").strip()
2376
+ entry["paper_branch"] = str(campaign.get("paper_line_branch") or "").strip() or entry.get("paper_branch")
2377
+ entry["selected_outline_ref"] = str(campaign.get("selected_outline_ref") or "").strip() or entry.get("selected_outline_ref")
2378
+ entry["updated_at"] = max(
2379
+ updated_rank(entry.get("updated_at")),
2380
+ updated_rank(campaign.get("updated_at")),
2381
+ )
2382
+
2383
+ lines = sorted(
2384
+ lines_by_id.values(),
519
2385
  key=lambda item: (
520
- str(item.get("attached_at") or ""),
521
- str(item.get("source_baseline_id") or ""),
2386
+ 0 if str(item.get("idea_id") or "").strip() == active_idea_id else 1,
2387
+ str(item.get("updated_at") or ""),
2388
+ str(item.get("idea_line_id") or ""),
2389
+ ),
2390
+ )
2391
+ for item in lines:
2392
+ if not item.get("open_supplementary_count"):
2393
+ pending = max(
2394
+ 0,
2395
+ int(item.get("analysis_slice_count") or 0) - int(item.get("completed_analysis_slice_count") or 0),
2396
+ )
2397
+ item["open_supplementary_count"] = pending
2398
+ item.pop("latest_main_run_updated_at", None)
2399
+ item.pop("paper_line_updated_at", None)
2400
+ if active_idea_id and active_idea_id in lines_by_id:
2401
+ active_ref = active_idea_id
2402
+ elif lines:
2403
+ active_ref = str(lines[0].get("idea_line_id") or "").strip() or None
2404
+ return lines, active_ref
2405
+
2406
+ def _paper_contract_health_payload(
2407
+ self,
2408
+ *,
2409
+ paper_contract: dict[str, Any] | None,
2410
+ paper_evidence: dict[str, Any] | None,
2411
+ analysis_inventory: dict[str, Any] | None,
2412
+ paper_lines: list[dict[str, Any]],
2413
+ active_paper_line_ref: str | None,
2414
+ ) -> dict[str, Any] | None:
2415
+ if not isinstance(paper_contract, dict) or not paper_contract:
2416
+ return None
2417
+ evidence_items = [
2418
+ dict(item) for item in ((paper_evidence or {}).get("items") or []) if isinstance(item, dict)
2419
+ ]
2420
+ ledger_by_item = {
2421
+ str(item.get("item_id") or "").strip(): item
2422
+ for item in evidence_items
2423
+ if str(item.get("item_id") or "").strip()
2424
+ }
2425
+ unresolved_required_items: list[dict[str, Any]] = []
2426
+ ready_section_count = 0
2427
+ for section in paper_contract.get("sections") or []:
2428
+ if not isinstance(section, dict):
2429
+ continue
2430
+ required_items = [str(item).strip() for item in (section.get("required_items") or []) if str(item).strip()]
2431
+ section_ready = True
2432
+ for item_id in required_items:
2433
+ ledger_item = ledger_by_item.get(item_id)
2434
+ status = str((ledger_item or {}).get("status") or "").strip().lower()
2435
+ if status not in {"ready", "completed", "analyzed", "written", "recorded", "supported"}:
2436
+ unresolved_required_items.append(
2437
+ {
2438
+ "section_id": str(section.get("section_id") or "").strip() or None,
2439
+ "section_title": str(section.get("title") or "").strip() or None,
2440
+ "item_id": item_id,
2441
+ "status": str((ledger_item or {}).get("status") or "").strip() or None,
2442
+ }
2443
+ )
2444
+ section_ready = False
2445
+ if required_items and section_ready:
2446
+ ready_section_count += 1
2447
+
2448
+ selected_outline_ref = str(paper_contract.get("selected_outline_ref") or "").strip() or None
2449
+ active_line = next(
2450
+ (
2451
+ dict(item)
2452
+ for item in paper_lines
2453
+ if isinstance(item, dict)
2454
+ and str(item.get("paper_line_id") or "").strip()
2455
+ and str(item.get("paper_line_id") or "").strip() == str(active_paper_line_ref or "").strip()
522
2456
  ),
2457
+ dict(paper_lines[0]) if paper_lines else {},
2458
+ )
2459
+ active_line_id = str(active_line.get("paper_line_id") or "").strip() or None
2460
+ active_line_branch = str(active_line.get("paper_branch") or "").strip() or None
2461
+
2462
+ campaigns = [dict(item) for item in ((analysis_inventory or {}).get("campaigns") or []) if isinstance(item, dict)]
2463
+ relevant_campaigns: list[dict[str, Any]] = []
2464
+ for campaign in campaigns:
2465
+ campaign_outline = str(campaign.get("selected_outline_ref") or "").strip() or None
2466
+ campaign_line_id = str(campaign.get("paper_line_id") or "").strip() or None
2467
+ campaign_line_branch = str(campaign.get("paper_line_branch") or "").strip() or None
2468
+ if active_line_id and campaign_line_id == active_line_id:
2469
+ relevant_campaigns.append(campaign)
2470
+ continue
2471
+ if active_line_branch and campaign_line_branch == active_line_branch:
2472
+ relevant_campaigns.append(campaign)
2473
+ continue
2474
+ if selected_outline_ref and campaign_outline == selected_outline_ref:
2475
+ relevant_campaigns.append(campaign)
2476
+
2477
+ unmapped_completed_items: list[dict[str, Any]] = []
2478
+ blocking_pending_slices: list[dict[str, Any]] = []
2479
+ for campaign in relevant_campaigns:
2480
+ for slice_item in campaign.get("slices") or []:
2481
+ if not isinstance(slice_item, dict):
2482
+ continue
2483
+ status = str(slice_item.get("status") or "").strip().lower()
2484
+ if status == "completed" and not bool(slice_item.get("mapped")):
2485
+ unmapped_completed_items.append(
2486
+ {
2487
+ "campaign_id": str(campaign.get("campaign_id") or "").strip() or None,
2488
+ "slice_id": str(slice_item.get("slice_id") or "").strip() or None,
2489
+ "item_id": str(slice_item.get("item_id") or "").strip() or None,
2490
+ "section_id": str(slice_item.get("section_id") or "").strip() or None,
2491
+ "title": str(slice_item.get("title") or "").strip() or None,
2492
+ }
2493
+ )
2494
+ if status in {"", "pending"}:
2495
+ paper_role = str(slice_item.get("paper_role") or "").strip().lower()
2496
+ tier = str(slice_item.get("tier") or "").strip().lower()
2497
+ if paper_role == "main_text" or tier == "main_required":
2498
+ blocking_pending_slices.append(
2499
+ {
2500
+ "campaign_id": str(campaign.get("campaign_id") or "").strip() or None,
2501
+ "slice_id": str(slice_item.get("slice_id") or "").strip() or None,
2502
+ "item_id": str(slice_item.get("item_id") or "").strip() or None,
2503
+ "section_id": str(slice_item.get("section_id") or "").strip() or None,
2504
+ "title": str(slice_item.get("title") or "").strip() or None,
2505
+ }
2506
+ )
2507
+
2508
+ contract_ok = not unresolved_required_items and not unmapped_completed_items
2509
+ writing_ready = contract_ok and not blocking_pending_slices
2510
+ draft_path = str((paper_contract.get("paths") or {}).get("draft") or "").strip()
2511
+ draft_status = str(active_line.get("draft_status") or "").strip() or ("present" if draft_path else "missing")
2512
+ bundle_status = str(active_line.get("bundle_status") or "").strip() or (
2513
+ "present" if str((paper_contract.get("paths") or {}).get("bundle_manifest") or "").strip() else "missing"
523
2514
  )
2515
+ bundle_manifest = (
2516
+ dict(paper_contract.get("bundle_manifest") or {})
2517
+ if isinstance(paper_contract.get("bundle_manifest"), dict)
2518
+ else {}
2519
+ )
2520
+ submission_checklist_path = str(((paper_contract.get("paths") or {}).get("submission_checklist") or "")).strip()
2521
+ submission_checklist = read_json(Path(submission_checklist_path), {}) if submission_checklist_path else {}
2522
+ submission_checklist = submission_checklist if isinstance(submission_checklist, dict) else {}
2523
+ overall_status = str(submission_checklist.get("overall_status") or bundle_manifest.get("status") or "").strip().lower()
2524
+ delivered_at = str(
2525
+ bundle_manifest.get("paper_delivered_to_user_at")
2526
+ or bundle_manifest.get("delivered_at")
2527
+ or submission_checklist.get("paper_delivered_to_user_at")
2528
+ or ""
2529
+ ).strip() or None
2530
+ closure_state = "bundle_not_ready"
2531
+ delivery_state = "not_ready"
2532
+ keep_bundle_fixed_by_default = False
2533
+ if bundle_status == "present":
2534
+ closure_state = "delivery_ready"
2535
+ delivery_state = "bundle_ready"
2536
+ if delivered_at or "delivered" in overall_status:
2537
+ delivery_state = "delivered"
2538
+ closure_state = "delivered_continue_research" if "continue" in overall_status else "delivered_parked"
2539
+ keep_bundle_fixed_by_default = True
2540
+
2541
+ if unmapped_completed_items:
2542
+ recommended_next_stage = "write"
2543
+ recommended_action = "sync_paper_contract"
2544
+ elif unresolved_required_items or blocking_pending_slices:
2545
+ recommended_next_stage = "analysis-campaign"
2546
+ recommended_action = "complete_required_supplementary"
2547
+ elif draft_status != "present":
2548
+ recommended_next_stage = "write"
2549
+ recommended_action = "draft_paper"
2550
+ elif bundle_status != "present":
2551
+ recommended_next_stage = "write"
2552
+ recommended_action = "prepare_bundle"
2553
+ else:
2554
+ recommended_next_stage = "finalize"
2555
+ recommended_action = "finalize_paper_line"
2556
+
2557
+ blocking_reasons: list[str] = []
2558
+ if unmapped_completed_items:
2559
+ blocking_reasons.append("completed analysis remains unmapped into the paper contract")
2560
+ if unresolved_required_items:
2561
+ blocking_reasons.append("required outline items are still unresolved")
2562
+ if blocking_pending_slices:
2563
+ blocking_reasons.append("main-text supplementary slices are still pending")
2564
+
2565
+ return {
2566
+ "paper_line_id": active_line_id,
2567
+ "paper_branch": active_line_branch,
2568
+ "selected_outline_ref": selected_outline_ref,
2569
+ "contract_ok": contract_ok,
2570
+ "writing_ready": writing_ready,
2571
+ "finalize_ready": writing_ready and bundle_status == "present",
2572
+ "closure_state": closure_state,
2573
+ "delivery_state": delivery_state,
2574
+ "delivered_at": delivered_at,
2575
+ "keep_bundle_fixed_by_default": keep_bundle_fixed_by_default,
2576
+ "required_count": sum(
2577
+ len(section.get("required_items") or [])
2578
+ for section in (paper_contract.get("sections") or [])
2579
+ if isinstance(section, dict)
2580
+ ),
2581
+ "ready_required_count": max(
2582
+ 0,
2583
+ sum(
2584
+ len(section.get("required_items") or [])
2585
+ for section in (paper_contract.get("sections") or [])
2586
+ if isinstance(section, dict)
2587
+ )
2588
+ - len(unresolved_required_items),
2589
+ ),
2590
+ "section_count": len([section for section in (paper_contract.get("sections") or []) if isinstance(section, dict)]),
2591
+ "ready_section_count": ready_section_count,
2592
+ "ledger_item_count": len(evidence_items),
2593
+ "unresolved_required_count": len(unresolved_required_items),
2594
+ "unmapped_completed_count": len(unmapped_completed_items),
2595
+ "open_supplementary_count": int(active_line.get("open_supplementary_count") or 0),
2596
+ "blocking_open_supplementary_count": len(blocking_pending_slices),
2597
+ "draft_status": draft_status,
2598
+ "bundle_status": bundle_status,
2599
+ "blocking_reasons": blocking_reasons,
2600
+ "recommended_next_stage": recommended_next_stage,
2601
+ "recommended_action": recommended_action,
2602
+ "unresolved_required_items": unresolved_required_items[:12],
2603
+ "unmapped_completed_items": unmapped_completed_items[:12],
2604
+ "blocking_pending_slices": blocking_pending_slices[:12],
2605
+ }
524
2606
 
525
2607
  @staticmethod
526
2608
  def _latest_metric_from_payload(payload: dict[str, Any]) -> dict[str, Any] | None:
@@ -551,14 +2633,8 @@ class QuestService:
551
2633
  def _quest_id_state_lock(self):
552
2634
  lock_path = self._quest_id_lock_path()
553
2635
  ensure_dir(lock_path.parent)
554
- with lock_path.open("a+", encoding="utf-8") as handle:
555
- if fcntl is not None:
556
- fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
557
- try:
558
- yield
559
- finally:
560
- if fcntl is not None:
561
- fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
2636
+ with advisory_file_lock(lock_path):
2637
+ yield
562
2638
 
563
2639
  @contextmanager
564
2640
  def _runtime_state_lock(self, quest_root: Path):
@@ -568,14 +2644,8 @@ class QuestService:
568
2644
  with thread_lock:
569
2645
  lock_path = self._runtime_state_lock_path(quest_root)
570
2646
  ensure_dir(lock_path.parent)
571
- with lock_path.open("a+", encoding="utf-8") as handle:
572
- if fcntl is not None:
573
- fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
574
- try:
575
- yield
576
- finally:
577
- if fcntl is not None:
578
- fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
2647
+ with advisory_file_lock(lock_path):
2648
+ yield
579
2649
 
580
2650
  def _scan_next_numeric_quest_id(self) -> int:
581
2651
  max_numeric_id = 0
@@ -695,7 +2765,7 @@ class QuestService:
695
2765
  )
696
2766
  write_text(quest_root / "brief.md", initial_brief(goal))
697
2767
  write_text(quest_root / "plan.md", initial_plan())
698
- write_text(quest_root / "status.md", initial_status())
2768
+ write_text(quest_root / "status.md", initial_status(startup_contract))
699
2769
  write_text(quest_root / "SUMMARY.md", initial_summary())
700
2770
  write_text(quest_root / ".gitignore", gitignore())
701
2771
  self._write_active_user_requirements(
@@ -878,9 +2948,27 @@ class QuestService:
878
2948
  "requested_baseline_ref": quest_yaml.get("requested_baseline_ref"),
879
2949
  "startup_contract": quest_yaml.get("startup_contract"),
880
2950
  "runner": quest_yaml.get("default_runner", "codex"),
2951
+ "active_workspace_root": str(workspace_root),
2952
+ "research_head_branch": research_state.get("research_head_branch"),
2953
+ "research_head_worktree_root": research_state.get("research_head_worktree_root"),
2954
+ "current_workspace_branch": research_state.get("current_workspace_branch"),
2955
+ "current_workspace_root": research_state.get("current_workspace_root"),
2956
+ "workspace_mode": research_state.get("workspace_mode") or "quest",
2957
+ "active_idea_id": research_state.get("active_idea_id"),
881
2958
  "active_baseline_id": active_baseline_id,
882
2959
  "active_baseline_variant_id": active_baseline_variant_id,
883
2960
  "active_run_id": runtime_state.get("active_run_id"),
2961
+ "continuation_policy": runtime_state.get("continuation_policy") or "auto",
2962
+ "continuation_anchor": runtime_state.get("continuation_anchor"),
2963
+ "continuation_reason": runtime_state.get("continuation_reason"),
2964
+ "continuation_updated_at": runtime_state.get("continuation_updated_at"),
2965
+ "last_resume_source": runtime_state.get("last_resume_source"),
2966
+ "last_resume_at": runtime_state.get("last_resume_at"),
2967
+ "last_recovery_abandoned_run_id": runtime_state.get("last_recovery_abandoned_run_id"),
2968
+ "last_recovery_summary": runtime_state.get("last_recovery_summary"),
2969
+ "last_stage_fingerprint": runtime_state.get("last_stage_fingerprint"),
2970
+ "last_stage_fingerprint_at": runtime_state.get("last_stage_fingerprint_at"),
2971
+ "same_fingerprint_auto_turn_count": int(runtime_state.get("same_fingerprint_auto_turn_count") or 0),
884
2972
  "pending_decisions": pending_decisions,
885
2973
  "waiting_interaction_id": waiting_interaction_id,
886
2974
  "default_reply_interaction_id": default_reply_interaction_id,
@@ -952,8 +3040,8 @@ class QuestService:
952
3040
  }
953
3041
  return items
954
3042
 
955
- @staticmethod
956
3043
  def _read_jsonl_cursor_slice(
3044
+ self,
957
3045
  path: Path,
958
3046
  *,
959
3047
  after: int = 0,
@@ -962,7 +3050,10 @@ class QuestService:
962
3050
  tail: bool = False,
963
3051
  ) -> tuple[list[tuple[int, dict[str, Any]]], int, bool]:
964
3052
  normalized_limit = max(int(limit or 0), 0)
3053
+ cache_key = self._cache_key_for_path(path)
965
3054
  if not path.exists():
3055
+ with self._jsonl_cache_lock:
3056
+ self._jsonl_tail_cache.pop(cache_key, None)
966
3057
  return [], 0, False
967
3058
  if normalized_limit <= 0:
968
3059
  total = sum(1 for _ in _iter_jsonl_records_safely(path))
@@ -981,11 +3072,71 @@ class QuestService:
981
3072
  return list(window), total, has_more
982
3073
 
983
3074
  if tail:
984
- window = deque(maxlen=normalized_limit)
985
- total = 0
986
- for payload in _iter_jsonl_records_safely(path):
987
- total += 1
988
- window.append((total, payload))
3075
+ state = self._path_state(path)
3076
+ cached_tail: dict[str, Any] | None = None
3077
+ with self._jsonl_cache_lock:
3078
+ candidate = self._jsonl_tail_cache.get(cache_key)
3079
+ if isinstance(candidate, dict):
3080
+ cached_tail = dict(candidate)
3081
+
3082
+ if cached_tail and cached_tail.get("state") == state:
3083
+ cached_limit = int(cached_tail.get("limit") or 0)
3084
+ cached_records = list(cached_tail.get("records") or [])
3085
+ cached_total = int(cached_tail.get("total") or 0)
3086
+ if cached_limit >= normalized_limit and cached_records:
3087
+ window = cached_records[-normalized_limit:]
3088
+ has_more = cached_total > len(window)
3089
+ return window, cached_total, has_more
3090
+
3091
+ if (
3092
+ cached_tail
3093
+ and state is not None
3094
+ and cached_tail.get("state")
3095
+ and tuple(cached_tail.get("state"))[0] == state[0]
3096
+ and state[2] >= tuple(cached_tail.get("state"))[2]
3097
+ ):
3098
+ cached_state = tuple(cached_tail.get("state"))
3099
+ cached_limit = int(cached_tail.get("limit") or 0)
3100
+ cached_total = int(cached_tail.get("total") or 0)
3101
+ max_limit = max(normalized_limit, cached_limit)
3102
+ window = deque(
3103
+ list(cached_tail.get("records") or []),
3104
+ maxlen=max_limit,
3105
+ )
3106
+ appended_records = list(
3107
+ _iter_jsonl_records_from_offset_safely(
3108
+ path,
3109
+ start_offset=int(cached_state[2]),
3110
+ )
3111
+ )
3112
+ if appended_records:
3113
+ next_cursor = cached_total + 1
3114
+ for payload in appended_records:
3115
+ window.append((next_cursor, payload))
3116
+ next_cursor += 1
3117
+ total = cached_total + len(appended_records)
3118
+ else:
3119
+ total = cached_total
3120
+ stored_records = list(window)
3121
+ with self._jsonl_cache_lock:
3122
+ self._jsonl_tail_cache[cache_key] = {
3123
+ "state": state,
3124
+ "limit": max_limit,
3125
+ "total": total,
3126
+ "records": stored_records,
3127
+ }
3128
+ selected = stored_records[-normalized_limit:]
3129
+ has_more = total > len(selected)
3130
+ return selected, total, has_more
3131
+
3132
+ window, total = _tail_jsonl_records_safely(path, limit=normalized_limit)
3133
+ with self._jsonl_cache_lock:
3134
+ self._jsonl_tail_cache[cache_key] = {
3135
+ "state": state,
3136
+ "limit": normalized_limit,
3137
+ "total": total,
3138
+ "records": list(window),
3139
+ }
989
3140
  has_more = total > len(window)
990
3141
  return list(window), total, has_more
991
3142
 
@@ -1021,6 +3172,14 @@ class QuestService:
1021
3172
  except FileNotFoundError:
1022
3173
  return str(path.absolute())
1023
3174
 
3175
+ def jsonl_tail_cache_entry(self, path: Path) -> dict[str, Any] | None:
3176
+ cache_key = self._cache_key_for_path(path)
3177
+ with self._jsonl_cache_lock:
3178
+ candidate = self._jsonl_tail_cache.get(cache_key)
3179
+ if isinstance(candidate, dict):
3180
+ return dict(candidate)
3181
+ return None
3182
+
1024
3183
  def _read_cached_path(
1025
3184
  self,
1026
3185
  path: Path,
@@ -1089,7 +3248,7 @@ class QuestService:
1089
3248
  return entries
1090
3249
 
1091
3250
  def snapshot_fast(self, quest_id: str) -> dict:
1092
- return self._snapshot(quest_id)
3251
+ return self.summary_compact(quest_id)
1093
3252
 
1094
3253
  def snapshot(self, quest_id: str) -> dict:
1095
3254
  return self._snapshot(quest_id)
@@ -1206,6 +3365,22 @@ class QuestService:
1206
3365
  bash_service = BashExecService(self.home)
1207
3366
  bash_summary = bash_service.summary(quest_root)
1208
3367
  latest_bash_session = bash_summary.get("latest_session")
3368
+ paper_contract = self._paper_contract_payload(quest_root, workspace_root)
3369
+ paper_evidence = self._paper_evidence_payload(quest_root, workspace_root)
3370
+ analysis_inventory = self._analysis_inventory_payload(quest_root, workspace_root)
3371
+ paper_lines, active_paper_line_ref = self._paper_lines_payload(quest_root, workspace_root)
3372
+ idea_lines, active_idea_line_ref = self._idea_lines_payload(
3373
+ quest_root,
3374
+ paper_lines=paper_lines,
3375
+ analysis_inventory=analysis_inventory,
3376
+ )
3377
+ paper_contract_health = self._paper_contract_health_payload(
3378
+ paper_contract=paper_contract,
3379
+ paper_evidence=paper_evidence,
3380
+ analysis_inventory=analysis_inventory,
3381
+ paper_lines=paper_lines,
3382
+ active_paper_line_ref=active_paper_line_ref,
3383
+ )
1209
3384
  paths = {
1210
3385
  "brief": str(workspace_root / "brief.md"),
1211
3386
  "plan": str(workspace_root / "plan.md"),
@@ -1277,11 +3452,27 @@ class QuestService:
1277
3452
  "paper_parent_branch": research_state.get("paper_parent_branch"),
1278
3453
  "paper_parent_worktree_root": research_state.get("paper_parent_worktree_root"),
1279
3454
  "paper_parent_run_id": research_state.get("paper_parent_run_id"),
3455
+ "idea_lines": idea_lines,
3456
+ "active_idea_line_ref": active_idea_line_ref,
3457
+ "paper_lines": paper_lines,
3458
+ "active_paper_line_ref": active_paper_line_ref,
3459
+ "paper_contract_health": paper_contract_health,
1280
3460
  "next_pending_slice_id": research_state.get("next_pending_slice_id"),
1281
3461
  "workspace_mode": research_state.get("workspace_mode") or "quest",
1282
3462
  "active_baseline_id": active_baseline_id,
1283
3463
  "active_baseline_variant_id": active_baseline_variant_id,
1284
3464
  "active_run_id": runtime_state.get("active_run_id"),
3465
+ "continuation_policy": runtime_state.get("continuation_policy") or "auto",
3466
+ "continuation_anchor": runtime_state.get("continuation_anchor"),
3467
+ "continuation_reason": runtime_state.get("continuation_reason"),
3468
+ "continuation_updated_at": runtime_state.get("continuation_updated_at"),
3469
+ "last_resume_source": runtime_state.get("last_resume_source"),
3470
+ "last_resume_at": runtime_state.get("last_resume_at"),
3471
+ "last_recovery_abandoned_run_id": runtime_state.get("last_recovery_abandoned_run_id"),
3472
+ "last_recovery_summary": runtime_state.get("last_recovery_summary"),
3473
+ "last_stage_fingerprint": runtime_state.get("last_stage_fingerprint"),
3474
+ "last_stage_fingerprint_at": runtime_state.get("last_stage_fingerprint_at"),
3475
+ "same_fingerprint_auto_turn_count": int(runtime_state.get("same_fingerprint_auto_turn_count") or 0),
1285
3476
  "pending_decisions": pending_decisions,
1286
3477
  "active_interactions": active_interactions,
1287
3478
  "recent_reply_threads": recent_reply_threads,
@@ -1320,6 +3511,9 @@ class QuestService:
1320
3511
  "artifact_count": len(artifacts),
1321
3512
  "recent_artifacts": artifacts[-5:],
1322
3513
  "recent_runs": recent_runs[-5:],
3514
+ "paper_contract": paper_contract,
3515
+ "paper_evidence": paper_evidence,
3516
+ "analysis_inventory": analysis_inventory,
1323
3517
  "guidance": guidance,
1324
3518
  }
1325
3519
  with self._snapshot_cache_lock:
@@ -1651,10 +3845,11 @@ class QuestService:
1651
3845
  normalized_anchor = str(active_anchor).strip()
1652
3846
  if not normalized_anchor:
1653
3847
  raise ValueError("`active_anchor` cannot be empty.")
1654
- from ..prompts.builder import STANDARD_SKILLS
3848
+ from ..prompts.builder import current_standard_skills
1655
3849
 
1656
- if normalized_anchor not in STANDARD_SKILLS:
1657
- allowed = ", ".join(STANDARD_SKILLS)
3850
+ available_stage_skills = current_standard_skills(repo_root())
3851
+ if normalized_anchor not in available_stage_skills:
3852
+ allowed = ", ".join(available_stage_skills)
1658
3853
  raise ValueError(f"Unsupported active anchor `{normalized_anchor}`. Allowed values: {allowed}.")
1659
3854
  if quest_data.get("active_anchor") != normalized_anchor:
1660
3855
  quest_data["active_anchor"] = normalized_anchor
@@ -1711,10 +3906,11 @@ class QuestService:
1711
3906
  normalized_anchor = str(active_anchor or "").strip()
1712
3907
  if not normalized_anchor:
1713
3908
  raise ValueError("`active_anchor` cannot be empty.")
1714
- from ..prompts.builder import STANDARD_SKILLS
3909
+ from ..prompts.builder import current_standard_skills
1715
3910
 
1716
- if normalized_anchor not in STANDARD_SKILLS:
1717
- allowed = ", ".join(STANDARD_SKILLS)
3911
+ available_stage_skills = current_standard_skills(repo_root())
3912
+ if normalized_anchor not in available_stage_skills:
3913
+ allowed = ", ".join(available_stage_skills)
1718
3914
  raise ValueError(f"Unsupported active anchor `{normalized_anchor}`. Allowed values: {allowed}.")
1719
3915
  if quest_data.get("active_anchor") != normalized_anchor:
1720
3916
  quest_data["active_anchor"] = normalized_anchor
@@ -1860,97 +4056,7 @@ class QuestService:
1860
4056
  return self._read_cached_jsonl(self._quest_root(quest_id) / ".ds" / "conversations" / "main.jsonl")[-limit:]
1861
4057
 
1862
4058
  def workflow(self, quest_id: str) -> dict:
1863
- quest_root = self._quest_root(quest_id)
1864
- workspace_root = self.active_workspace_root(quest_root)
1865
- snapshot = self.snapshot(quest_id)
1866
- entries: list[dict] = []
1867
- changed_files: list[dict] = []
1868
- seen_files: set[str] = set()
1869
-
1870
- def add_file(path: str | None, *, source: str, document_id: str | None = None, writable: bool | None = None) -> None:
1871
- if not path:
1872
- return
1873
- normalized = str(path)
1874
- if normalized in seen_files:
1875
- return
1876
- seen_files.add(normalized)
1877
- resolved_document_id = document_id or self._path_to_document_id(
1878
- normalized,
1879
- quest_root=quest_root,
1880
- workspace_root=workspace_root,
1881
- )
1882
- changed_files.append(
1883
- {
1884
- "path": normalized,
1885
- "source": source,
1886
- "document_id": resolved_document_id,
1887
- "writable": writable,
1888
- }
1889
- )
1890
-
1891
- for relative in ("brief.md", "plan.md", "status.md", "SUMMARY.md"):
1892
- add_file(
1893
- str(workspace_root / relative),
1894
- source="document",
1895
- document_id=relative,
1896
- writable=True,
1897
- )
1898
-
1899
- recent_runs = snapshot.get("recent_runs") or []
1900
- for run in recent_runs:
1901
- run_id = str(run.get("run_id") or "run")
1902
- entries.append(
1903
- {
1904
- "id": f"run:{run_id}",
1905
- "kind": "run",
1906
- "run_id": run_id,
1907
- "skill_id": run.get("skill_id"),
1908
- "title": run_id,
1909
- "summary": run.get("summary") or "Run completed.",
1910
- "status": "completed" if run.get("exit_code", 0) == 0 else "failed",
1911
- "created_at": run.get("completed_at") or run.get("created_at") or run.get("updated_at"),
1912
- "paths": [item for item in [run.get("history_root"), run.get("run_root"), run.get("output_path")] if item],
1913
- }
1914
- )
1915
- for path in (run.get("history_root"), run.get("run_root"), run.get("output_path")):
1916
- add_file(path, source="run")
1917
- history_root = run.get("history_root")
1918
- if history_root:
1919
- entries.extend(
1920
- self._parse_codex_history_cached(
1921
- Path(str(history_root)),
1922
- quest_id=quest_id,
1923
- run_id=run_id,
1924
- skill_id=run.get("skill_id"),
1925
- )
1926
- )
1927
-
1928
- for artifact in snapshot.get("recent_artifacts") or []:
1929
- payload = artifact.get("payload") or {}
1930
- artifact_path = artifact.get("path")
1931
- entries.append(
1932
- {
1933
- "id": f"artifact:{payload.get('artifact_id') or artifact_path}",
1934
- "kind": "artifact",
1935
- "title": str(payload.get("artifact_id") or artifact.get("kind") or "artifact"),
1936
- "summary": payload.get("summary") or payload.get("message") or payload.get("reason") or "Artifact updated.",
1937
- "status": payload.get("status"),
1938
- "reason": payload.get("reason"),
1939
- "created_at": payload.get("updated_at"),
1940
- "paths": list((payload.get("paths") or {}).values()) + ([str(artifact_path)] if artifact_path else []),
1941
- }
1942
- )
1943
- add_file(str(artifact_path) if artifact_path else None, source="artifact")
1944
- for path in (payload.get("paths") or {}).values():
1945
- add_file(str(path), source="artifact_path")
1946
-
1947
- entries.sort(key=lambda item: str(item.get("created_at") or item.get("id") or ""))
1948
- return {
1949
- "quest_id": quest_id,
1950
- "quest_root": snapshot.get("quest_root"),
1951
- "entries": entries[-80:],
1952
- "changed_files": changed_files[-30:],
1953
- }
4059
+ return self._projected_payload(quest_id, "details")
1954
4060
 
1955
4061
  def events(
1956
4062
  self,
@@ -2077,23 +4183,129 @@ class QuestService:
2077
4183
  def metrics_timeline(self, quest_id: str) -> dict:
2078
4184
  quest_root = self._quest_root(quest_id)
2079
4185
  workspace_root = self.active_workspace_root(quest_root)
2080
- attachment = self._active_baseline_attachment(quest_root, workspace_root)
2081
- baseline_entry = dict(attachment.get("entry") or {}) if isinstance(attachment, dict) else None
2082
- selected_variant_id = (
2083
- str(attachment.get("source_variant_id") or "").strip() or None if isinstance(attachment, dict) else None
2084
- )
2085
- run_records = [
2086
- item.get("payload") or {}
2087
- for item in self._collect_artifacts(quest_root)
2088
- if str((item.get("payload") or {}).get("kind") or "") == "run"
2089
- and str((item.get("payload") or {}).get("run_kind") or "") == "main_experiment"
2090
- ]
2091
- return build_metrics_timeline(
2092
- quest_id=quest_id,
2093
- run_records=[item for item in run_records if isinstance(item, dict)],
2094
- baseline_entry=baseline_entry,
2095
- selected_variant_id=selected_variant_id,
2096
- )
4186
+ state = self._json_compatible_state(self._metrics_timeline_state(quest_root, workspace_root))
4187
+ cache_path = self._metrics_timeline_cache_path(quest_root)
4188
+ cache_schema_version = 2
4189
+ cached = self._read_cached_json(cache_path, {})
4190
+ if (
4191
+ isinstance(cached, dict)
4192
+ and int(cached.get("schema_version") or 0) == cache_schema_version
4193
+ and self._json_compatible_state(cached.get("state")) == state
4194
+ and isinstance(cached.get("payload"), dict)
4195
+ ):
4196
+ return dict(cached.get("payload") or {})
4197
+
4198
+ with advisory_file_lock(self._metrics_timeline_cache_lock_path(quest_root)):
4199
+ cached = read_json(cache_path, {})
4200
+ if (
4201
+ isinstance(cached, dict)
4202
+ and int(cached.get("schema_version") or 0) == cache_schema_version
4203
+ and self._json_compatible_state(cached.get("state")) == state
4204
+ and isinstance(cached.get("payload"), dict)
4205
+ ):
4206
+ return dict(cached.get("payload") or {})
4207
+
4208
+ attachment = self._active_baseline_attachment(quest_root, workspace_root)
4209
+ baseline_entry = dict(attachment.get("entry") or {}) if isinstance(attachment, dict) else None
4210
+ selected_variant_id = (
4211
+ str(attachment.get("source_variant_id") or "").strip() or None if isinstance(attachment, dict) else None
4212
+ )
4213
+ if not baseline_entry:
4214
+ latest_baseline_payload = None
4215
+ for item in reversed(self._collect_artifacts_raw(quest_root)):
4216
+ if str(item.get("kind") or "").strip() != "baselines":
4217
+ continue
4218
+ payload = item.get("payload") or {}
4219
+ if not isinstance(payload, dict):
4220
+ continue
4221
+ if str(payload.get("status") or "").strip().lower() != "confirmed":
4222
+ continue
4223
+ latest_baseline_payload = payload
4224
+ break
4225
+ if isinstance(latest_baseline_payload, dict) and latest_baseline_payload:
4226
+ baseline_entry = dict(latest_baseline_payload)
4227
+ selected_variant_id = (
4228
+ str(latest_baseline_payload.get("baseline_variant_id") or "").strip() or None
4229
+ )
4230
+ run_records = [
4231
+ item.get("payload") or {}
4232
+ for item in self._collect_run_artifacts_raw(quest_root, run_kind="main_experiment")
4233
+ if isinstance(item.get("payload"), dict)
4234
+ ]
4235
+ payload = build_metrics_timeline(
4236
+ quest_id=quest_id,
4237
+ run_records=run_records,
4238
+ baseline_entry=baseline_entry,
4239
+ selected_variant_id=selected_variant_id,
4240
+ )
4241
+ write_json(
4242
+ cache_path,
4243
+ {
4244
+ "schema_version": cache_schema_version,
4245
+ "generated_at": utc_now(),
4246
+ "state": state,
4247
+ "payload": payload,
4248
+ },
4249
+ )
4250
+ return payload
4251
+
4252
+ def baseline_compare(self, quest_id: str) -> dict:
4253
+ quest_root = self._quest_root(quest_id)
4254
+ workspace_root = self.active_workspace_root(quest_root)
4255
+ state = self._json_compatible_state(self._baseline_compare_state(quest_root, workspace_root))
4256
+ cache_path = self._baseline_compare_cache_path(quest_root)
4257
+ cache_schema_version = 1
4258
+ cached = self._read_cached_json(cache_path, {})
4259
+ if (
4260
+ isinstance(cached, dict)
4261
+ and int(cached.get("schema_version") or 0) == cache_schema_version
4262
+ and self._json_compatible_state(cached.get("state")) == state
4263
+ and isinstance(cached.get("payload"), dict)
4264
+ ):
4265
+ return dict(cached.get("payload") or {})
4266
+
4267
+ with advisory_file_lock(self._baseline_compare_cache_lock_path(quest_root)):
4268
+ cached = read_json(cache_path, {})
4269
+ if (
4270
+ isinstance(cached, dict)
4271
+ and int(cached.get("schema_version") or 0) == cache_schema_version
4272
+ and self._json_compatible_state(cached.get("state")) == state
4273
+ and isinstance(cached.get("payload"), dict)
4274
+ ):
4275
+ return dict(cached.get("payload") or {})
4276
+
4277
+ quest_data = self.read_quest_yaml(quest_root)
4278
+ confirmed_ref = (
4279
+ dict(quest_data.get("confirmed_baseline_ref") or {})
4280
+ if isinstance(quest_data.get("confirmed_baseline_ref"), dict)
4281
+ else {}
4282
+ )
4283
+ attachment = self._active_baseline_attachment(quest_root, workspace_root)
4284
+ active_baseline_id = (
4285
+ str(confirmed_ref.get("baseline_id") or "").strip()
4286
+ or (str(attachment.get("source_baseline_id") or "").strip() if isinstance(attachment, dict) else "")
4287
+ or None
4288
+ )
4289
+ active_variant_id = (
4290
+ str(confirmed_ref.get("variant_id") or "").strip()
4291
+ or (str(attachment.get("source_variant_id") or "").strip() if isinstance(attachment, dict) else "")
4292
+ or None
4293
+ )
4294
+ payload = build_baseline_compare_payload(
4295
+ quest_id=quest_id,
4296
+ baseline_entries=self._baseline_compare_entries(quest_root, workspace_root),
4297
+ active_baseline_id=active_baseline_id,
4298
+ active_variant_id=active_variant_id,
4299
+ )
4300
+ write_json(
4301
+ cache_path,
4302
+ {
4303
+ "schema_version": cache_schema_version,
4304
+ "state": state,
4305
+ "payload": payload,
4306
+ },
4307
+ )
4308
+ return payload
2097
4309
 
2098
4310
  def list_documents(self, quest_id: str) -> list[dict]:
2099
4311
  quest_root = self._quest_root(quest_id)
@@ -2310,23 +4522,7 @@ class QuestService:
2310
4522
  },
2311
4523
  }
2312
4524
 
2313
- resolution_root = (
2314
- quest_root
2315
- if document_id.startswith(("questpath::", "memory::"))
2316
- else workspace_root
2317
- )
2318
- try:
2319
- path, writable, scope, source_kind = self._resolve_document(resolution_root, document_id)
2320
- except FileNotFoundError:
2321
- legacy_relative = None
2322
- if document_id.startswith("path::"):
2323
- legacy_relative = document_id.split("::", 1)[1].lstrip("/")
2324
- if legacy_relative and legacy_relative.startswith("literature/arxiv/"):
2325
- path, writable, scope, source_kind = self._resolve_document(
2326
- quest_root, f"questpath::{legacy_relative}"
2327
- )
2328
- else:
2329
- raise
4525
+ path, writable, scope, source_kind = self.resolve_document(quest_id, document_id)
2330
4526
  renderer_hint, mime_type = self._renderer_hint_for(path)
2331
4527
  is_text = self._is_text_document(path, mime_type, renderer_hint)
2332
4528
  content = read_text(path) if is_text else ""
@@ -2354,6 +4550,24 @@ class QuestService:
2354
4550
  },
2355
4551
  }
2356
4552
 
4553
+ def resolve_document(self, quest_id: str, document_id: str) -> tuple[Path, bool, str, str]:
4554
+ quest_root = self._quest_root(quest_id)
4555
+ workspace_root = self.active_workspace_root(quest_root)
4556
+ resolution_root = self._document_resolution_root(
4557
+ quest_root=quest_root,
4558
+ workspace_root=workspace_root,
4559
+ document_id=document_id,
4560
+ )
4561
+ try:
4562
+ return self._resolve_document(resolution_root, document_id)
4563
+ except FileNotFoundError:
4564
+ legacy_relative = None
4565
+ if document_id.startswith("path::"):
4566
+ legacy_relative = document_id.split("::", 1)[1].lstrip("/")
4567
+ if legacy_relative and legacy_relative.startswith("literature/arxiv/"):
4568
+ return self._resolve_document(quest_root, f"questpath::{legacy_relative}")
4569
+ raise
4570
+
2357
4571
  def save_document(self, quest_id: str, document_id: str, content: str, previous_revision: str | None = None) -> dict:
2358
4572
  current = self.open_document(quest_id, document_id)
2359
4573
  if not current.get("writable", False):
@@ -2689,6 +4903,17 @@ class QuestService:
2689
4903
  "last_tool_activity_at": None,
2690
4904
  "last_tool_activity_name": None,
2691
4905
  "tool_calls_since_last_artifact_interact": 0,
4906
+ "continuation_policy": "auto",
4907
+ "continuation_anchor": None,
4908
+ "continuation_reason": None,
4909
+ "continuation_updated_at": None,
4910
+ "last_resume_source": None,
4911
+ "last_resume_at": None,
4912
+ "last_recovery_abandoned_run_id": None,
4913
+ "last_recovery_summary": None,
4914
+ "last_stage_fingerprint": None,
4915
+ "last_stage_fingerprint_at": None,
4916
+ "same_fingerprint_auto_turn_count": 0,
2692
4917
  "pending_user_message_count": pending_count,
2693
4918
  "last_delivered_batch_id": None,
2694
4919
  "last_delivered_at": None,
@@ -2754,6 +4979,20 @@ class QuestService:
2754
4979
  merged = {**defaults, **payload}
2755
4980
  merged["pending_user_message_count"] = int(merged.get("pending_user_message_count") or 0)
2756
4981
  merged["tool_calls_since_last_artifact_interact"] = int(merged.get("tool_calls_since_last_artifact_interact") or 0)
4982
+ merged["continuation_policy"] = self._normalize_continuation_policy(
4983
+ merged.get("continuation_policy"),
4984
+ default=str(defaults.get("continuation_policy") or "auto"),
4985
+ )
4986
+ merged["continuation_anchor"] = str(merged.get("continuation_anchor") or "").strip() or None
4987
+ merged["continuation_reason"] = str(merged.get("continuation_reason") or "").strip() or None
4988
+ merged["continuation_updated_at"] = str(merged.get("continuation_updated_at") or "").strip() or None
4989
+ merged["last_resume_source"] = str(merged.get("last_resume_source") or "").strip() or None
4990
+ merged["last_resume_at"] = str(merged.get("last_resume_at") or "").strip() or None
4991
+ merged["last_recovery_abandoned_run_id"] = str(merged.get("last_recovery_abandoned_run_id") or "").strip() or None
4992
+ merged["last_recovery_summary"] = str(merged.get("last_recovery_summary") or "").strip() or None
4993
+ merged["last_stage_fingerprint"] = str(merged.get("last_stage_fingerprint") or "").strip() or None
4994
+ merged["last_stage_fingerprint_at"] = str(merged.get("last_stage_fingerprint_at") or "").strip() or None
4995
+ merged["same_fingerprint_auto_turn_count"] = int(merged.get("same_fingerprint_auto_turn_count") or 0)
2757
4996
  merged["retry_state"] = dict(merged.get("retry_state") or {}) if isinstance(merged.get("retry_state"), dict) else None
2758
4997
  return merged
2759
4998
 
@@ -2773,6 +5012,17 @@ class QuestService:
2773
5012
  last_tool_activity_at: str | None | object = _UNSET,
2774
5013
  last_tool_activity_name: str | None | object = _UNSET,
2775
5014
  tool_calls_since_last_artifact_interact: int | object = _UNSET,
5015
+ continuation_policy: str | object = _UNSET,
5016
+ continuation_anchor: str | None | object = _UNSET,
5017
+ continuation_reason: str | None | object = _UNSET,
5018
+ continuation_updated_at: str | None | object = _UNSET,
5019
+ last_resume_source: str | None | object = _UNSET,
5020
+ last_resume_at: str | None | object = _UNSET,
5021
+ last_recovery_abandoned_run_id: str | None | object = _UNSET,
5022
+ last_recovery_summary: str | None | object = _UNSET,
5023
+ last_stage_fingerprint: str | None | object = _UNSET,
5024
+ last_stage_fingerprint_at: str | None | object = _UNSET,
5025
+ same_fingerprint_auto_turn_count: int | object = _UNSET,
2776
5026
  pending_user_message_count: int | object = _UNSET,
2777
5027
  last_delivered_batch_id: str | None | object = _UNSET,
2778
5028
  last_delivered_at: str | None | object = _UNSET,
@@ -2810,6 +5060,44 @@ class QuestService:
2810
5060
  state["last_tool_activity_name"] = str(last_tool_activity_name).strip() if last_tool_activity_name else None
2811
5061
  if tool_calls_since_last_artifact_interact is not _UNSET:
2812
5062
  state["tool_calls_since_last_artifact_interact"] = max(0, int(tool_calls_since_last_artifact_interact))
5063
+ continuation_changed = False
5064
+ if continuation_policy is not _UNSET:
5065
+ state["continuation_policy"] = self._normalize_continuation_policy(continuation_policy)
5066
+ continuation_changed = True
5067
+ if continuation_anchor is not _UNSET:
5068
+ normalized_anchor = str(continuation_anchor or "").strip() or None
5069
+ if normalized_anchor is not None:
5070
+ from ..prompts.builder import current_standard_skills
5071
+
5072
+ available_stage_skills = current_standard_skills(repo_root())
5073
+ if normalized_anchor not in available_stage_skills:
5074
+ allowed = ", ".join(available_stage_skills)
5075
+ raise ValueError(
5076
+ f"Unsupported continuation anchor `{normalized_anchor}`. Allowed values: {allowed}."
5077
+ )
5078
+ state["continuation_anchor"] = normalized_anchor
5079
+ continuation_changed = True
5080
+ if continuation_reason is not _UNSET:
5081
+ state["continuation_reason"] = str(continuation_reason or "").strip() or None
5082
+ continuation_changed = True
5083
+ if continuation_updated_at is not _UNSET:
5084
+ state["continuation_updated_at"] = str(continuation_updated_at or "").strip() or None
5085
+ elif continuation_changed:
5086
+ state["continuation_updated_at"] = now
5087
+ if last_resume_source is not _UNSET:
5088
+ state["last_resume_source"] = str(last_resume_source or "").strip() or None
5089
+ if last_resume_at is not _UNSET:
5090
+ state["last_resume_at"] = str(last_resume_at or "").strip() or None
5091
+ if last_recovery_abandoned_run_id is not _UNSET:
5092
+ state["last_recovery_abandoned_run_id"] = str(last_recovery_abandoned_run_id or "").strip() or None
5093
+ if last_recovery_summary is not _UNSET:
5094
+ state["last_recovery_summary"] = str(last_recovery_summary or "").strip() or None
5095
+ if last_stage_fingerprint is not _UNSET:
5096
+ state["last_stage_fingerprint"] = str(last_stage_fingerprint or "").strip() or None
5097
+ if last_stage_fingerprint_at is not _UNSET:
5098
+ state["last_stage_fingerprint_at"] = str(last_stage_fingerprint_at or "").strip() or None
5099
+ if same_fingerprint_auto_turn_count is not _UNSET:
5100
+ state["same_fingerprint_auto_turn_count"] = max(0, int(same_fingerprint_auto_turn_count or 0))
2813
5101
  if pending_user_message_count is not _UNSET:
2814
5102
  state["pending_user_message_count"] = max(0, int(pending_user_message_count))
2815
5103
  if last_delivered_batch_id is not _UNSET:
@@ -2836,8 +5124,29 @@ class QuestService:
2836
5124
  quest_data.pop("active_run_id", None)
2837
5125
  quest_data["updated_at"] = now
2838
5126
  write_yaml(quest_root / "quest.yaml", quest_data)
5127
+ self.schedule_projection_refresh(quest_root, kinds=("details",))
2839
5128
  return state
2840
5129
 
5130
+ @staticmethod
5131
+ def _normalize_continuation_policy(value: object, *, default: str = "auto") -> str:
5132
+ normalized = str(value or "").strip().lower() or default
5133
+ return normalized if normalized in CONTINUATION_POLICIES else default
5134
+
5135
+ def set_continuation_state(
5136
+ self,
5137
+ quest_root: Path,
5138
+ *,
5139
+ policy: str,
5140
+ anchor: str | None = None,
5141
+ reason: str | None = None,
5142
+ ) -> dict[str, Any]:
5143
+ return self.update_runtime_state(
5144
+ quest_root=quest_root,
5145
+ continuation_policy=policy,
5146
+ continuation_anchor=anchor,
5147
+ continuation_reason=reason,
5148
+ )
5149
+
2841
5150
  def _enqueue_user_message(self, quest_root: Path, record: dict[str, Any]) -> dict[str, Any]:
2842
5151
  queue_payload = self._read_message_queue(quest_root)
2843
5152
  source = str(record.get("source") or "local")
@@ -3055,11 +5364,15 @@ class QuestService:
3055
5364
  artifact_id: str | None,
3056
5365
  kind: str,
3057
5366
  message: str,
5367
+ summary_preview: str | None = None,
5368
+ dedupe_key: str | None = None,
3058
5369
  response_phase: str | None = None,
3059
5370
  reply_mode: str | None = None,
3060
5371
  surface_actions: list[dict[str, Any]] | None = None,
3061
5372
  connector_hints: dict[str, Any] | None = None,
3062
5373
  created_at: str | None = None,
5374
+ counts_as_visible: bool = True,
5375
+ deliver_to_bound_conversations: bool | None = None,
3063
5376
  ) -> dict[str, Any]:
3064
5377
  timestamp = created_at or utc_now()
3065
5378
  payload = {
@@ -3070,22 +5383,31 @@ class QuestService:
3070
5383
  "artifact_id": artifact_id,
3071
5384
  "kind": kind,
3072
5385
  "message": message,
5386
+ "summary_preview": str(summary_preview or "").strip() or None,
5387
+ "dedupe_key": str(dedupe_key or "").strip() or None,
3073
5388
  "response_phase": response_phase,
3074
5389
  "reply_mode": reply_mode,
3075
5390
  "surface_actions": [dict(item) for item in (surface_actions or []) if isinstance(item, dict)],
3076
5391
  "connector_hints": dict(connector_hints) if isinstance(connector_hints, dict) else {},
5392
+ "deliver_to_bound_conversations": (
5393
+ bool(deliver_to_bound_conversations)
5394
+ if deliver_to_bound_conversations is not None
5395
+ else None
5396
+ ),
3077
5397
  "created_at": timestamp,
3078
5398
  }
3079
5399
  append_jsonl(self._interaction_journal_path(quest_root), payload)
3080
- self.update_runtime_state(
3081
- quest_root=quest_root,
3082
- active_interaction_id=interaction_id or artifact_id,
3083
- last_artifact_interact_at=timestamp,
3084
- last_tool_activity_at=timestamp,
3085
- last_tool_activity_name="artifact.interact",
3086
- tool_calls_since_last_artifact_interact=0,
3087
- pending_user_message_count=len((self._read_message_queue(quest_root).get("pending") or [])),
3088
- )
5400
+ runtime_updates: dict[str, Any] = {
5401
+ "quest_root": quest_root,
5402
+ "active_interaction_id": interaction_id or artifact_id,
5403
+ "last_tool_activity_at": timestamp,
5404
+ "last_tool_activity_name": "artifact.interact",
5405
+ "tool_calls_since_last_artifact_interact": 0,
5406
+ "pending_user_message_count": len((self._read_message_queue(quest_root).get("pending") or [])),
5407
+ }
5408
+ if counts_as_visible:
5409
+ runtime_updates["last_artifact_interact_at"] = timestamp
5410
+ self.update_runtime_state(**runtime_updates)
3089
5411
  return payload
3090
5412
 
3091
5413
  def record_tool_activity(
@@ -3133,13 +5455,25 @@ class QuestService:
3133
5455
  runtime_state = self._read_runtime_state(quest_root)
3134
5456
  last_artifact_interact_at = str(runtime_state.get("last_artifact_interact_at") or "").strip() or None
3135
5457
  last_tool_activity_at = str(runtime_state.get("last_tool_activity_at") or "").strip() or None
5458
+ tool_count = int(runtime_state.get("tool_calls_since_last_artifact_interact") or 0)
5459
+ silence_seconds = self._seconds_since_iso_timestamp(last_artifact_interact_at)
5460
+ inspection_due = bool(
5461
+ tool_count >= 25
5462
+ or (
5463
+ tool_count > 0
5464
+ and silence_seconds is not None
5465
+ and silence_seconds >= 30 * 60
5466
+ )
5467
+ )
3136
5468
  return {
3137
5469
  "last_artifact_interact_at": last_artifact_interact_at,
3138
- "seconds_since_last_artifact_interact": self._seconds_since_iso_timestamp(last_artifact_interact_at),
3139
- "tool_calls_since_last_artifact_interact": int(runtime_state.get("tool_calls_since_last_artifact_interact") or 0),
5470
+ "seconds_since_last_artifact_interact": silence_seconds,
5471
+ "tool_calls_since_last_artifact_interact": tool_count,
3140
5472
  "last_tool_activity_at": last_tool_activity_at,
3141
5473
  "seconds_since_last_tool_activity": self._seconds_since_iso_timestamp(last_tool_activity_at),
3142
5474
  "last_tool_activity_name": str(runtime_state.get("last_tool_activity_name") or "").strip() or None,
5475
+ "inspection_due": inspection_due,
5476
+ "user_update_due": False,
3143
5477
  }
3144
5478
 
3145
5479
  def latest_artifact_interaction_records(self, quest_root: Path, limit: int = 10) -> list[dict[str, Any]]:
@@ -3309,6 +5643,12 @@ class QuestService:
3309
5643
  "queued_message_count_after_delivery": len(queue_payload.get("pending") or []),
3310
5644
  }
3311
5645
 
5646
+ @staticmethod
5647
+ def _document_resolution_root(quest_root: Path, workspace_root: Path, document_id: str) -> Path:
5648
+ if document_id.startswith(("questpath::", "memory::")):
5649
+ return quest_root
5650
+ return workspace_root
5651
+
3312
5652
  @staticmethod
3313
5653
  def _resolve_document(quest_root: Path, document_id: str) -> tuple[Path, bool, str, str]:
3314
5654
  if document_id.startswith("memory::"):
@@ -3935,9 +6275,11 @@ def _tool_output(event: dict, item: dict) -> str:
3935
6275
  item.get("result"),
3936
6276
  item.get("output"),
3937
6277
  item.get("content"),
6278
+ item.get("error"),
3938
6279
  event.get("result"),
3939
6280
  event.get("output"),
3940
6281
  event.get("content"),
6282
+ event.get("error"),
3941
6283
  item.get("aggregated_output"),
3942
6284
  event.get("aggregated_output"),
3943
6285
  ):
@@ -3951,11 +6293,13 @@ def _tool_output(event: dict, item: dict) -> str:
3951
6293
  item.get("output"),
3952
6294
  item.get("result"),
3953
6295
  item.get("content"),
6296
+ item.get("error"),
3954
6297
  event.get("aggregated_output"),
3955
6298
  event.get("changes"),
3956
6299
  event.get("output"),
3957
6300
  event.get("result"),
3958
6301
  event.get("content"),
6302
+ event.get("error"),
3959
6303
  ):
3960
6304
  text = _compact_text(value, limit=1200)
3961
6305
  if text: