@researai/deepscientist 1.5.14 → 1.5.15

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 (119) hide show
  1. package/README.md +8 -0
  2. package/assets/branding/logo-raster.png +0 -0
  3. package/bin/ds.js +134 -49
  4. package/docs/en/00_QUICK_START.md +2 -2
  5. package/docs/en/01_SETTINGS_REFERENCE.md +20 -4
  6. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +19 -0
  7. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  8. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
  9. package/docs/en/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  10. package/docs/en/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  11. package/docs/en/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  12. package/docs/en/README.md +6 -0
  13. package/docs/zh/00_QUICK_START.md +2 -2
  14. package/docs/zh/01_SETTINGS_REFERENCE.md +20 -4
  15. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +19 -0
  16. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  17. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
  18. package/docs/zh/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  19. package/docs/zh/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  20. package/docs/zh/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  21. package/docs/zh/README.md +6 -0
  22. package/install.sh +2 -0
  23. package/package.json +1 -1
  24. package/pyproject.toml +1 -1
  25. package/src/deepscientist/__init__.py +1 -1
  26. package/src/deepscientist/artifact/charts.py +567 -0
  27. package/src/deepscientist/artifact/guidance.py +50 -10
  28. package/src/deepscientist/artifact/metrics.py +228 -5
  29. package/src/deepscientist/artifact/schemas.py +3 -0
  30. package/src/deepscientist/artifact/service.py +3534 -191
  31. package/src/deepscientist/bash_exec/models.py +23 -0
  32. package/src/deepscientist/bash_exec/monitor.py +147 -67
  33. package/src/deepscientist/bash_exec/runtime.py +218 -156
  34. package/src/deepscientist/bash_exec/service.py +79 -64
  35. package/src/deepscientist/bash_exec/shells.py +87 -0
  36. package/src/deepscientist/bridges/connectors.py +51 -2
  37. package/src/deepscientist/config/models.py +6 -3
  38. package/src/deepscientist/config/service.py +7 -2
  39. package/src/deepscientist/connector/weixin_support.py +122 -1
  40. package/src/deepscientist/daemon/api/handlers.py +75 -4
  41. package/src/deepscientist/daemon/api/router.py +1 -0
  42. package/src/deepscientist/daemon/app.py +758 -206
  43. package/src/deepscientist/doctor.py +51 -0
  44. package/src/deepscientist/file_lock.py +48 -0
  45. package/src/deepscientist/gitops/diff.py +167 -1
  46. package/src/deepscientist/mcp/server.py +173 -5
  47. package/src/deepscientist/process_control.py +161 -0
  48. package/src/deepscientist/prompts/builder.py +267 -442
  49. package/src/deepscientist/quest/service.py +2255 -163
  50. package/src/deepscientist/quest/stage_views.py +171 -0
  51. package/src/deepscientist/runners/base.py +2 -0
  52. package/src/deepscientist/runners/codex.py +88 -5
  53. package/src/deepscientist/runners/runtime_overrides.py +17 -1
  54. package/src/prompts/contracts/shared_interaction.md +13 -4
  55. package/src/prompts/system.md +916 -72
  56. package/src/skills/analysis-campaign/SKILL.md +31 -2
  57. package/src/skills/analysis-campaign/references/artifact-orchestration.md +1 -1
  58. package/src/skills/analysis-campaign/references/writing-facing-slice-examples.md +65 -0
  59. package/src/skills/baseline/SKILL.md +2 -0
  60. package/src/skills/decision/SKILL.md +19 -2
  61. package/src/skills/experiment/SKILL.md +8 -2
  62. package/src/skills/finalize/SKILL.md +18 -0
  63. package/src/skills/idea/SKILL.md +78 -0
  64. package/src/skills/idea/references/idea-generation-playbook.md +100 -0
  65. package/src/skills/idea/references/outline-seeding-example.md +60 -0
  66. package/src/skills/intake-audit/SKILL.md +1 -1
  67. package/src/skills/optimize/SKILL.md +1644 -0
  68. package/src/skills/rebuttal/SKILL.md +2 -1
  69. package/src/skills/review/SKILL.md +2 -1
  70. package/src/skills/write/SKILL.md +80 -12
  71. package/src/skills/write/references/outline-evidence-contract-example.md +107 -0
  72. package/src/tui/dist/app/AppContainer.js +3 -0
  73. package/src/tui/package.json +1 -1
  74. package/src/ui/dist/assets/{AiManusChatView-DaF9Nge_.js → AiManusChatView-DDjbFnbt.js} +12 -12
  75. package/src/ui/dist/assets/{AnalysisPlugin-BSVx6dXE.js → AnalysisPlugin-Yb5IdmaU.js} +1 -1
  76. package/src/ui/dist/assets/CliPlugin-e64sreyu.js +31037 -0
  77. package/src/ui/dist/assets/{CodeEditorPlugin-DU9G0Tox.js → CodeEditorPlugin-C4D2TIkU.js} +8 -8
  78. package/src/ui/dist/assets/{CodeViewerPlugin-DoX_fI9l.js → CodeViewerPlugin-BVoNZIvC.js} +5 -5
  79. package/src/ui/dist/assets/{DocViewerPlugin-C4FWIXuU.js → DocViewerPlugin-CLChbllo.js} +3 -3
  80. package/src/ui/dist/assets/{GitDiffViewerPlugin-BgfFMgtf.js → GitDiffViewerPlugin-C4xeFyFQ.js} +20 -20
  81. package/src/ui/dist/assets/{ImageViewerPlugin-tcPkfY_x.js → ImageViewerPlugin-OiMUAcLi.js} +5 -5
  82. package/src/ui/dist/assets/{LabCopilotPanel-_dKV60Bf.js → LabCopilotPanel-BjD2ThQF.js} +11 -11
  83. package/src/ui/dist/assets/{LabPlugin-Bje0ayoC.js → LabPlugin-DQPg-NrB.js} +2 -2
  84. package/src/ui/dist/assets/{LatexPlugin-CVsBzAln.js → LatexPlugin-CI05XAV9.js} +7 -7
  85. package/src/ui/dist/assets/{MarkdownViewerPlugin-xjmrqv_8.js → MarkdownViewerPlugin-DpeBLYZf.js} +4 -4
  86. package/src/ui/dist/assets/{MarketplacePlugin-mMM2A8wP.js → MarketplacePlugin-DolE58Q2.js} +3 -3
  87. package/src/ui/dist/assets/{NotebookEditor-3kVDSOBo.js → NotebookEditor-7Qm2rSWD.js} +11 -11
  88. package/src/ui/dist/assets/{NotebookEditor-SoJ8X-MO.js → NotebookEditor-C1kWaxKi.js} +1 -1
  89. package/src/ui/dist/assets/{PdfLoader-DElVuHl9.js → PdfLoader-BfOHw8Zw.js} +1 -1
  90. package/src/ui/dist/assets/{PdfMarkdownPlugin-Bq88XT4G.js → PdfMarkdownPlugin-BulDREv1.js} +2 -2
  91. package/src/ui/dist/assets/{PdfViewerPlugin-CsCXMo9S.js → PdfViewerPlugin-C-daaOaL.js} +10 -10
  92. package/src/ui/dist/assets/{SearchPlugin-oUPvy19k.js → SearchPlugin-CjpaiJ3A.js} +1 -1
  93. package/src/ui/dist/assets/{TextViewerPlugin-CRkT9yNy.js → TextViewerPlugin-BxIyqPQC.js} +5 -5
  94. package/src/ui/dist/assets/{VNCViewer-BgbuvWhR.js → VNCViewer-HAg9mF7M.js} +10 -10
  95. package/src/ui/dist/assets/{bot-v_RASACv.js → bot-0DYntytV.js} +1 -1
  96. package/src/ui/dist/assets/{code-5hC9d0VH.js → code-B20Slj_w.js} +1 -1
  97. package/src/ui/dist/assets/{file-content-D1PxfOrp.js → file-content-DT24KFma.js} +1 -1
  98. package/src/ui/dist/assets/{file-diff-panel-DG1oT_Hj.js → file-diff-panel-DK13YPql.js} +1 -1
  99. package/src/ui/dist/assets/{file-socket-BmdFYQlk.js → file-socket-B4T2o4nR.js} +1 -1
  100. package/src/ui/dist/assets/{image-Dqe2X2tW.js → image-DSeR_sDS.js} +1 -1
  101. package/src/ui/dist/assets/{index-RDlNXXx1.js → index-BrFje2Uk.js} +2 -2
  102. package/src/ui/dist/assets/{index-DVsMKK_y.js → index-BwRJaoTl.js} +1 -1
  103. package/src/ui/dist/assets/{index-Nt9hS4ck.js → index-D_E4281X.js} +5007 -28514
  104. package/src/ui/dist/assets/{index-Duvz8Ip0.js → index-DnYB3xb1.js} +12 -12
  105. package/src/ui/dist/assets/{index-BQG-1s2o.css → index-G7AcWcMu.css} +43 -2
  106. package/src/ui/dist/assets/{monaco-DIXge1CP.js → monaco-LExaAN3Y.js} +1 -1
  107. package/src/ui/dist/assets/{pdf-effect-queue-BBTTQaO-.js → pdf-effect-queue-BJk5okWJ.js} +1 -1
  108. package/src/ui/dist/assets/{popover-BWlolyxo.js → popover-D3Gg_FoV.js} +1 -1
  109. package/src/ui/dist/assets/{project-sync-BM5PkFH4.js → project-sync-C_ygLlVU.js} +1 -1
  110. package/src/ui/dist/assets/{select-D4dAtrA8.js → select-CpAK6uWm.js} +2 -2
  111. package/src/ui/dist/assets/{sigma-CKbE5jJT.js → sigma-DEccaSgk.js} +1 -1
  112. package/src/ui/dist/assets/{square-check-big-CZNGMgiB.js → square-check-big-uUfyVsbD.js} +1 -1
  113. package/src/ui/dist/assets/{trash-DaB37xAz.js → trash-CXvwwSe8.js} +1 -1
  114. package/src/ui/dist/assets/{useCliAccess-C2OmAcWe.js → useCliAccess-Bnop4mgR.js} +1 -1
  115. package/src/ui/dist/assets/{useFileDiffOverlay-Dowd1Ij4.js → useFileDiffOverlay-B8eUAX0I.js} +1 -1
  116. package/src/ui/dist/assets/{wrap-text-BGjAhAUq.js → wrap-text-9vbOBpkW.js} +1 -1
  117. package/src/ui/dist/assets/{zoom-out-dMZQMXzc.js → zoom-out-BgVMmOW4.js} +1 -1
  118. package/src/ui/dist/index.html +2 -2
  119. package/src/ui/dist/assets/CliPlugin-C9gzJX41.js +0 -5905
@@ -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
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]:
@@ -182,6 +188,14 @@ class QuestService:
182
188
  self._codex_history_cache: dict[str, dict[str, Any]] = {}
183
189
  self._runtime_state_locks_lock = threading.Lock()
184
190
  self._runtime_state_locks: dict[str, threading.Lock] = {}
191
+ self._artifact_projection_locks_lock = threading.Lock()
192
+ self._artifact_projection_locks: dict[str, threading.Lock] = {}
193
+ self._quest_projection_locks_lock = threading.Lock()
194
+ self._quest_projection_locks: dict[str, threading.Lock] = {}
195
+ self._quest_projection_builds_lock = threading.Lock()
196
+ self._quest_projection_builds: dict[str, threading.Thread] = {}
197
+ self._quest_projection_refresh_lock = threading.Lock()
198
+ self._quest_projection_refresh_at: dict[str, float] = {}
185
199
 
186
200
  def _quest_root(self, quest_id: str) -> Path:
187
201
  return self.quests_root / quest_id
@@ -339,7 +353,9 @@ class QuestService:
339
353
  if value is _UNSET:
340
354
  continue
341
355
  current[key] = str(value) if isinstance(value, Path) else value
342
- return self.write_research_state(quest_root, current)
356
+ payload = self.write_research_state(quest_root, current)
357
+ self.schedule_projection_refresh(quest_root, kinds=("details", "canvas"))
358
+ return payload
343
359
 
344
360
  def read_lab_canvas_state(self, quest_root: Path) -> dict[str, Any]:
345
361
  self._initialize_runtime_files(quest_root)
@@ -443,7 +459,288 @@ class QuestService:
443
459
  str(path),
444
460
  )
445
461
 
446
- def _collect_artifacts(self, quest_root: Path) -> list[dict[str, Any]]:
462
+ @staticmethod
463
+ def _artifact_projection_path(quest_root: Path) -> Path:
464
+ return quest_root / ".ds" / "cache" / "artifact_projection.v2.json"
465
+
466
+ @staticmethod
467
+ def _artifact_projection_lock_path(quest_root: Path) -> Path:
468
+ return quest_root / ".ds" / "artifact_projection.lock"
469
+
470
+ @staticmethod
471
+ def _metrics_timeline_cache_path(quest_root: Path) -> Path:
472
+ return quest_root / ".ds" / "cache" / "metrics_timeline.v1.json"
473
+
474
+ @staticmethod
475
+ def _metrics_timeline_cache_lock_path(quest_root: Path) -> Path:
476
+ return quest_root / ".ds" / "cache" / "metrics_timeline.lock"
477
+
478
+ @staticmethod
479
+ def _baseline_compare_cache_path(quest_root: Path) -> Path:
480
+ return quest_root / ".ds" / "cache" / "baseline_compare.v1.json"
481
+
482
+ @staticmethod
483
+ def _baseline_compare_cache_lock_path(quest_root: Path) -> Path:
484
+ return quest_root / ".ds" / "cache" / "baseline_compare.lock"
485
+
486
+ @staticmethod
487
+ def _json_compatible_state(value: Any) -> Any:
488
+ if isinstance(value, tuple):
489
+ return [QuestService._json_compatible_state(item) for item in value]
490
+ if isinstance(value, list):
491
+ return [QuestService._json_compatible_state(item) for item in value]
492
+ if isinstance(value, dict):
493
+ return {
494
+ str(key): QuestService._json_compatible_state(item)
495
+ for key, item in value.items()
496
+ }
497
+ return value
498
+
499
+ @contextmanager
500
+ def _artifact_projection_lock(self, quest_root: Path):
501
+ lock_key = str(quest_root.resolve())
502
+ with self._artifact_projection_locks_lock:
503
+ thread_lock = self._artifact_projection_locks.setdefault(lock_key, threading.Lock())
504
+ with thread_lock:
505
+ with advisory_file_lock(self._artifact_projection_lock_path(quest_root)):
506
+ yield
507
+
508
+ def _artifact_index_collection_state(self, quest_root: Path) -> list[list[Any]]:
509
+ states: list[list[Any]] = []
510
+ for root in self._artifact_roots(quest_root):
511
+ artifacts_root = root / "artifacts"
512
+ if not artifacts_root.exists():
513
+ continue
514
+ try:
515
+ label = str(root.relative_to(quest_root))
516
+ except ValueError:
517
+ label = str(root)
518
+ states.append(
519
+ [
520
+ label,
521
+ self._json_compatible_state(self._path_state(artifacts_root / "_index.jsonl")),
522
+ ]
523
+ )
524
+ return states
525
+
526
+ def _metrics_timeline_attachment_state(self, quest_root: Path, workspace_root: Path) -> list[list[Any]]:
527
+ states: list[list[Any]] = []
528
+ seen_paths: set[str] = set()
529
+ for root in (workspace_root, quest_root):
530
+ attachment_root = root / "baselines" / "imported"
531
+ if not attachment_root.exists():
532
+ continue
533
+ for path in sorted(attachment_root.glob("*/attachment.yaml")):
534
+ key = str(path.resolve())
535
+ if key in seen_paths:
536
+ continue
537
+ seen_paths.add(key)
538
+ try:
539
+ label = str(path.relative_to(quest_root))
540
+ except ValueError:
541
+ label = str(path)
542
+ states.append([label, self._json_compatible_state(self._path_state(path))])
543
+ return states
544
+
545
+ def _metrics_timeline_state(self, quest_root: Path, workspace_root: Path) -> list[Any]:
546
+ return [
547
+ str(workspace_root.resolve()),
548
+ self._artifact_index_collection_state(quest_root),
549
+ self._metrics_timeline_attachment_state(quest_root, workspace_root),
550
+ ]
551
+
552
+ def _baseline_compare_state(self, quest_root: Path, workspace_root: Path) -> list[Any]:
553
+ return [
554
+ str(workspace_root.resolve()),
555
+ self._artifact_index_collection_state(quest_root),
556
+ self._metrics_timeline_attachment_state(quest_root, workspace_root),
557
+ self._json_compatible_state(self._path_state(self._quest_yaml_path(quest_root))),
558
+ ]
559
+
560
+ def _baseline_compare_entries(self, quest_root: Path, workspace_root: Path) -> list[dict[str, Any]]:
561
+ entries: list[dict[str, Any]] = []
562
+ for item in self._collect_artifacts_raw(quest_root):
563
+ if str(item.get("kind") or "").strip() != "baselines":
564
+ continue
565
+ payload = item.get("payload") or {}
566
+ if not isinstance(payload, dict):
567
+ continue
568
+ status = str(payload.get("status") or "").strip().lower()
569
+ if status not in {"confirmed", "published", "quest_confirmed"}:
570
+ continue
571
+ entries.append(dict(payload))
572
+ attachment = self._active_baseline_attachment(quest_root, workspace_root)
573
+ attachment_entry = dict(attachment.get("entry") or {}) if isinstance(attachment, dict) else None
574
+ if attachment_entry:
575
+ entries.append(attachment_entry)
576
+ return entries
577
+
578
+ def _artifact_projection_state(self, quest_root: Path) -> tuple[str, Any]:
579
+ index_state = self._artifact_index_collection_state(quest_root)
580
+ if index_state and all(item[1] is not None for item in index_state):
581
+ return "index", index_state
582
+ if not index_state:
583
+ return "index", []
584
+ return "raw", self._json_compatible_state(self._artifact_collection_state(quest_root))
585
+
586
+ def _projection_artifact_item(
587
+ self,
588
+ *,
589
+ record: dict[str, Any],
590
+ artifact_path: Path,
591
+ workspace_root: Path,
592
+ ) -> dict[str, Any]:
593
+ return {
594
+ "kind": artifact_path.parent.name,
595
+ "path": str(artifact_path),
596
+ "payload": copy.deepcopy(record),
597
+ "workspace_root": str(workspace_root),
598
+ }
599
+
600
+ def _write_artifact_projection_locked(
601
+ self,
602
+ quest_root: Path,
603
+ *,
604
+ state_kind: str,
605
+ state: Any,
606
+ artifacts: list[dict[str, Any]],
607
+ ) -> list[dict[str, Any]]:
608
+ projection_path = self._artifact_projection_path(quest_root)
609
+ ensure_dir(projection_path.parent)
610
+ payload = {
611
+ "schema_version": 2,
612
+ "generated_at": utc_now(),
613
+ "state_kind": state_kind,
614
+ "state": self._json_compatible_state(state),
615
+ "artifacts": copy.deepcopy(artifacts),
616
+ }
617
+ write_json(projection_path, payload)
618
+ return copy.deepcopy(artifacts)
619
+
620
+ def refresh_artifact_projection(
621
+ self,
622
+ quest_root: Path,
623
+ *,
624
+ state_kind: str | None = None,
625
+ state: Any | None = None,
626
+ ) -> list[dict[str, Any]]:
627
+ resolved_state_kind, resolved_state = (
628
+ (state_kind, state)
629
+ if state_kind is not None and state is not None
630
+ else self._artifact_projection_state(quest_root)
631
+ )
632
+ artifacts = self._collect_artifacts_raw(quest_root)
633
+ return self._write_artifact_projection_locked(
634
+ quest_root,
635
+ state_kind=resolved_state_kind,
636
+ state=resolved_state,
637
+ artifacts=artifacts,
638
+ )
639
+
640
+ def update_artifact_projection(
641
+ self,
642
+ quest_root: Path,
643
+ *,
644
+ record: dict[str, Any],
645
+ artifact_path: Path,
646
+ workspace_root: Path,
647
+ previous_state_kind: str | None = None,
648
+ previous_state: Any | None = None,
649
+ current_state_kind: str | None = None,
650
+ current_state: Any | None = None,
651
+ ) -> list[dict[str, Any]]:
652
+ resolved_previous_kind = previous_state_kind
653
+ resolved_previous_state = self._json_compatible_state(previous_state) if previous_state is not None else None
654
+ resolved_current_kind, resolved_current_state = (
655
+ (current_state_kind, self._json_compatible_state(current_state))
656
+ if current_state_kind is not None and current_state is not None
657
+ else self._artifact_projection_state(quest_root)
658
+ )
659
+ projection_path = self._artifact_projection_path(quest_root)
660
+ with self._artifact_projection_lock(quest_root):
661
+ payload = read_json(projection_path, {})
662
+ projected_artifacts = payload.get("artifacts") if isinstance(payload.get("artifacts"), list) else None
663
+ can_incrementally_update = (
664
+ isinstance(payload, dict)
665
+ and int(payload.get("schema_version") or 0) == 2
666
+ and isinstance(projected_artifacts, list)
667
+ and resolved_previous_kind is not None
668
+ and payload.get("state_kind") == resolved_previous_kind
669
+ and self._json_compatible_state(payload.get("state")) == resolved_previous_state
670
+ )
671
+ if not can_incrementally_update:
672
+ return self.refresh_artifact_projection(
673
+ quest_root,
674
+ state_kind=resolved_current_kind,
675
+ state=resolved_current_state,
676
+ )
677
+
678
+ artifacts: list[dict[str, Any]] = [
679
+ dict(item)
680
+ for item in projected_artifacts
681
+ if isinstance(item, dict)
682
+ ]
683
+ next_item = self._projection_artifact_item(
684
+ record=record,
685
+ artifact_path=artifact_path,
686
+ workspace_root=workspace_root,
687
+ )
688
+ next_identity = self._artifact_item_identity(
689
+ artifact_path,
690
+ record,
691
+ kind=str(next_item.get("kind") or ""),
692
+ )
693
+ try:
694
+ next_mtime_ns = artifact_path.stat().st_mtime_ns
695
+ except OSError:
696
+ next_mtime_ns = 0
697
+ replaced = False
698
+ for index, existing in enumerate(artifacts):
699
+ existing_payload = existing.get("payload") if isinstance(existing.get("payload"), dict) else {}
700
+ existing_path = Path(str(existing.get("path") or artifact_path))
701
+ if (
702
+ self._artifact_item_identity(
703
+ existing_path,
704
+ existing_payload,
705
+ kind=str(existing.get("kind") or existing_path.parent.name or ""),
706
+ )
707
+ != next_identity
708
+ ):
709
+ continue
710
+ try:
711
+ existing_mtime_ns = existing_path.stat().st_mtime_ns
712
+ except OSError:
713
+ existing_mtime_ns = 0
714
+ if self._artifact_item_rank(
715
+ record,
716
+ path=artifact_path,
717
+ mtime_ns=next_mtime_ns,
718
+ ) >= self._artifact_item_rank(
719
+ existing_payload,
720
+ path=existing_path,
721
+ mtime_ns=existing_mtime_ns,
722
+ ):
723
+ artifacts[index] = next_item
724
+ replaced = True
725
+ break
726
+ if not replaced:
727
+ artifacts.append(next_item)
728
+ artifacts.sort(
729
+ key=lambda item: str(
730
+ ((item.get("payload") or {}).get("updated_at"))
731
+ or ((item.get("payload") or {}).get("created_at"))
732
+ or item.get("path")
733
+ or ""
734
+ )
735
+ )
736
+ return self._write_artifact_projection_locked(
737
+ quest_root,
738
+ state_kind=resolved_current_kind,
739
+ state=resolved_current_state,
740
+ artifacts=artifacts,
741
+ )
742
+
743
+ def _collect_artifacts_raw(self, quest_root: Path) -> list[dict[str, Any]]:
447
744
  artifacts_by_identity: dict[str, dict[str, Any]] = {}
448
745
  for root in self._artifact_roots(quest_root):
449
746
  artifacts_root = root / "artifacts"
@@ -492,35 +789,1657 @@ class QuestService:
492
789
  or ""
493
790
  )
494
791
  )
495
- return artifacts
792
+ return artifacts
793
+
794
+ def _collect_artifacts(self, quest_root: Path) -> list[dict[str, Any]]:
795
+ state_kind, state = self._artifact_projection_state(quest_root)
796
+ projection_path = self._artifact_projection_path(quest_root)
797
+ cached_projection = self._read_cached_json(projection_path, {})
798
+ if (
799
+ isinstance(cached_projection, dict)
800
+ and int(cached_projection.get("schema_version") or 0) == 2
801
+ and cached_projection.get("state_kind") == state_kind
802
+ and self._json_compatible_state(cached_projection.get("state")) == self._json_compatible_state(state)
803
+ and isinstance(cached_projection.get("artifacts"), list)
804
+ ):
805
+ return [
806
+ dict(item)
807
+ for item in cached_projection.get("artifacts") or []
808
+ if isinstance(item, dict)
809
+ ]
810
+
811
+ with self._artifact_projection_lock(quest_root):
812
+ cached_projection = self._read_cached_json(projection_path, {})
813
+ if (
814
+ isinstance(cached_projection, dict)
815
+ and int(cached_projection.get("schema_version") or 0) == 2
816
+ and cached_projection.get("state_kind") == state_kind
817
+ and self._json_compatible_state(cached_projection.get("state")) == self._json_compatible_state(state)
818
+ and isinstance(cached_projection.get("artifacts"), list)
819
+ ):
820
+ return [
821
+ dict(item)
822
+ for item in cached_projection.get("artifacts") or []
823
+ if isinstance(item, dict)
824
+ ]
825
+ return self.refresh_artifact_projection(
826
+ quest_root,
827
+ state_kind=state_kind,
828
+ state=state,
829
+ )
830
+
831
+ def _collect_run_artifacts_raw(
832
+ self,
833
+ quest_root: Path,
834
+ *,
835
+ run_kind: str | None = None,
836
+ ) -> list[dict[str, Any]]:
837
+ artifacts_by_identity: dict[str, dict[str, Any]] = {}
838
+ normalized_run_kind = str(run_kind or "").strip()
839
+ for root in self._artifact_roots(quest_root):
840
+ runs_root = root / "artifacts" / "runs"
841
+ if not runs_root.exists():
842
+ continue
843
+ for path in sorted(runs_root.glob("*.json")):
844
+ item = self._read_cached_json(path, {})
845
+ payload = item if isinstance(item, dict) else {}
846
+ if normalized_run_kind and str(payload.get("run_kind") or "").strip() != normalized_run_kind:
847
+ continue
848
+ try:
849
+ mtime_ns = path.stat().st_mtime_ns
850
+ except OSError:
851
+ mtime_ns = 0
852
+ artifact = {
853
+ "kind": "run",
854
+ "path": str(path),
855
+ "payload": item,
856
+ "workspace_root": str(root),
857
+ }
858
+ identity = self._artifact_item_identity(path, payload, kind="run")
859
+ existing = artifacts_by_identity.get(identity)
860
+ existing_payload = existing.get("payload") if isinstance((existing or {}).get("payload"), dict) else {}
861
+ existing_path = Path(str((existing or {}).get("path") or path))
862
+ try:
863
+ existing_mtime_ns = existing_path.stat().st_mtime_ns if existing else 0
864
+ except OSError:
865
+ existing_mtime_ns = 0
866
+ if existing is None or self._artifact_item_rank(
867
+ payload,
868
+ path=path,
869
+ mtime_ns=mtime_ns,
870
+ ) >= self._artifact_item_rank(
871
+ existing_payload,
872
+ path=existing_path,
873
+ mtime_ns=existing_mtime_ns,
874
+ ):
875
+ artifacts_by_identity[identity] = artifact
876
+ artifacts = list(artifacts_by_identity.values())
877
+ artifacts.sort(
878
+ key=lambda item: str(
879
+ ((item.get("payload") or {}).get("updated_at"))
880
+ or ((item.get("payload") or {}).get("created_at"))
881
+ or item.get("path")
882
+ or ""
883
+ )
884
+ )
885
+ return artifacts
886
+
887
+ @staticmethod
888
+ def _projection_id(kind: str) -> str:
889
+ return f"{kind}.v1"
890
+
891
+ @staticmethod
892
+ def _projection_directory(quest_root: Path) -> Path:
893
+ return quest_root / ".ds" / "projections"
894
+
895
+ @classmethod
896
+ def _projection_manifest_path(cls, quest_root: Path) -> Path:
897
+ return cls._projection_directory(quest_root) / "manifest.json"
898
+
899
+ @classmethod
900
+ def _projection_payload_path(cls, quest_root: Path, kind: str) -> Path:
901
+ return cls._projection_directory(quest_root) / f"{cls._projection_id(kind)}.json"
902
+
903
+ @classmethod
904
+ def _projection_lock_path(cls, quest_root: Path, kind: str) -> Path:
905
+ return cls._projection_directory(quest_root) / f"{cls._projection_id(kind)}.lock"
906
+
907
+ def _projection_build_key(self, quest_root: Path, kind: str) -> str:
908
+ return f"{quest_root.resolve()}::{kind}"
909
+
910
+ def _codex_history_events_state(self, quest_root: Path) -> tuple[tuple[str, tuple[int, int, int] | None], ...]:
911
+ return self._glob_states(quest_root / ".ds" / "codex_history", "*/events.jsonl")
912
+
913
+ def _details_projection_state(self, quest_root: Path) -> tuple[Any, ...]:
914
+ workspace_root = self.active_workspace_root(quest_root)
915
+ core_paths = [
916
+ self._quest_yaml_path(quest_root),
917
+ quest_root / "status.md",
918
+ quest_root / ".ds" / "runtime_state.json",
919
+ quest_root / ".ds" / "research_state.json",
920
+ quest_root / ".ds" / "interaction_state.json",
921
+ quest_root / ".ds" / "bindings.json",
922
+ quest_root / ".ds" / "bash_exec" / "summary.json",
923
+ self._artifact_projection_path(quest_root),
924
+ workspace_root / "brief.md",
925
+ workspace_root / "plan.md",
926
+ workspace_root / "status.md",
927
+ workspace_root / "SUMMARY.md",
928
+ ]
929
+ return (
930
+ str(workspace_root.resolve()),
931
+ self._path_states(core_paths),
932
+ self._codex_meta_state(quest_root),
933
+ self._codex_history_events_state(quest_root),
934
+ )
935
+
936
+ def _git_branch_projection_state(self, quest_root: Path) -> dict[str, Any]:
937
+ result = run_command(
938
+ [
939
+ "git",
940
+ "for-each-ref",
941
+ "--sort=refname",
942
+ "--format=%(refname:short)%09%(objectname)%09%(committerdate:iso-strict)",
943
+ "refs/heads",
944
+ ],
945
+ cwd=quest_root,
946
+ check=False,
947
+ )
948
+ refs = [line.strip() for line in str(result.stdout or "").splitlines() if line.strip()]
949
+ if result.returncode != 0:
950
+ refs = [f"error:{result.returncode}:{str(result.stderr or '').strip()}"]
951
+ return {
952
+ "current_ref": current_branch(quest_root),
953
+ "head": head_commit(quest_root),
954
+ "refs": refs,
955
+ }
956
+
957
+ def _canvas_projection_state(self, quest_root: Path) -> tuple[Any, ...]:
958
+ return (
959
+ self._path_states(
960
+ [
961
+ self._quest_yaml_path(quest_root),
962
+ quest_root / ".ds" / "research_state.json",
963
+ self._artifact_projection_path(quest_root),
964
+ ]
965
+ ),
966
+ self._git_branch_projection_state(quest_root),
967
+ )
968
+
969
+ def _projection_state_for_kind(self, quest_root: Path, kind: str) -> Any:
970
+ if kind == "details":
971
+ return self._details_projection_state(quest_root)
972
+ if kind == "canvas":
973
+ return self._canvas_projection_state(quest_root)
974
+ raise ValueError(f"Unsupported projection kind `{kind}`.")
975
+
976
+ def _projection_source_signature(self, quest_root: Path, kind: str) -> str:
977
+ state = {
978
+ "projection_id": self._projection_id(kind),
979
+ "state": self._json_compatible_state(self._projection_state_for_kind(quest_root, kind)),
980
+ }
981
+ return sha256_text(json.dumps(state, ensure_ascii=False, sort_keys=True))
982
+
983
+ def _default_projection_status(self, kind: str) -> dict[str, Any]:
984
+ return {
985
+ "projection_id": self._projection_id(kind),
986
+ "state": "missing",
987
+ "progress_current": 0,
988
+ "progress_total": 0,
989
+ "current_step": None,
990
+ "source_signature": None,
991
+ "generated_at": None,
992
+ "last_success_at": None,
993
+ "error": None,
994
+ }
995
+
996
+ def _normalize_projection_status(self, kind: str, raw: Any) -> dict[str, Any]:
997
+ normalized = self._default_projection_status(kind)
998
+ if isinstance(raw, dict):
999
+ normalized.update(
1000
+ {
1001
+ "state": str(raw.get("state") or normalized["state"]).strip() or normalized["state"],
1002
+ "progress_current": max(0, int(raw.get("progress_current") or 0)),
1003
+ "progress_total": max(0, int(raw.get("progress_total") or 0)),
1004
+ "current_step": str(raw.get("current_step") or "").strip() or None,
1005
+ "source_signature": str(raw.get("source_signature") or "").strip() or None,
1006
+ "generated_at": str(raw.get("generated_at") or "").strip() or None,
1007
+ "last_success_at": str(raw.get("last_success_at") or "").strip() or None,
1008
+ "error": str(raw.get("error") or "").strip() or None,
1009
+ }
1010
+ )
1011
+ return normalized
1012
+
1013
+ def _read_projection_manifest(self, quest_root: Path) -> dict[str, Any]:
1014
+ manifest = self._read_cached_json(
1015
+ self._projection_manifest_path(quest_root),
1016
+ {
1017
+ "schema_version": _PROJECTION_SCHEMA_VERSION,
1018
+ "projections": {},
1019
+ },
1020
+ )
1021
+ if not isinstance(manifest, dict):
1022
+ return {
1023
+ "schema_version": _PROJECTION_SCHEMA_VERSION,
1024
+ "projections": {},
1025
+ }
1026
+ return manifest
1027
+
1028
+ def _read_projection_payload_file(self, quest_root: Path, kind: str) -> dict[str, Any] | None:
1029
+ payload = self._read_cached_json(self._projection_payload_path(quest_root, kind), {})
1030
+ if not isinstance(payload, dict):
1031
+ return None
1032
+ if str(payload.get("projection_id") or "").strip() != self._projection_id(kind):
1033
+ return None
1034
+ if not isinstance(payload.get("payload"), dict):
1035
+ return None
1036
+ return payload
1037
+
1038
+ def _write_projection_manifest_locked(
1039
+ self,
1040
+ quest_root: Path,
1041
+ kind: str,
1042
+ status: dict[str, Any],
1043
+ ) -> dict[str, Any]:
1044
+ path = self._projection_manifest_path(quest_root)
1045
+ ensure_dir(path.parent)
1046
+ manifest = read_json(path, {})
1047
+ if not isinstance(manifest, dict):
1048
+ manifest = {}
1049
+ projections = manifest.get("projections") if isinstance(manifest.get("projections"), dict) else {}
1050
+ next_status = self._normalize_projection_status(kind, status)
1051
+ projections = {
1052
+ **projections,
1053
+ kind: next_status,
1054
+ }
1055
+ write_json(
1056
+ path,
1057
+ {
1058
+ "schema_version": _PROJECTION_SCHEMA_VERSION,
1059
+ "updated_at": utc_now(),
1060
+ "projections": projections,
1061
+ },
1062
+ )
1063
+ return next_status
1064
+
1065
+ def _write_projection_payload_locked(
1066
+ self,
1067
+ quest_root: Path,
1068
+ kind: str,
1069
+ *,
1070
+ source_signature: str,
1071
+ payload: dict[str, Any],
1072
+ generated_at: str | None = None,
1073
+ ) -> dict[str, Any]:
1074
+ path = self._projection_payload_path(quest_root, kind)
1075
+ ensure_dir(path.parent)
1076
+ resolved_generated_at = generated_at or utc_now()
1077
+ wrapper = {
1078
+ "schema_version": _PROJECTION_SCHEMA_VERSION,
1079
+ "projection_id": self._projection_id(kind),
1080
+ "generated_at": resolved_generated_at,
1081
+ "source_signature": source_signature,
1082
+ "payload": copy.deepcopy(payload),
1083
+ }
1084
+ write_json(path, wrapper)
1085
+ return copy.deepcopy(payload)
1086
+
1087
+ @contextmanager
1088
+ def _projection_lock(self, quest_root: Path, kind: str):
1089
+ lock_key = self._projection_build_key(quest_root, kind)
1090
+ with self._quest_projection_locks_lock:
1091
+ thread_lock = self._quest_projection_locks.setdefault(lock_key, threading.Lock())
1092
+ with thread_lock:
1093
+ with advisory_file_lock(self._projection_lock_path(quest_root, kind)):
1094
+ yield
1095
+
1096
+ def _projection_build_active(self, quest_root: Path, kind: str) -> bool:
1097
+ build_key = self._projection_build_key(quest_root, kind)
1098
+ with self._quest_projection_builds_lock:
1099
+ thread = self._quest_projection_builds.get(build_key)
1100
+ if thread is not None and not thread.is_alive():
1101
+ self._quest_projection_builds.pop(build_key, None)
1102
+ thread = None
1103
+ return thread is not None
1104
+
1105
+ def _present_projection_status(
1106
+ self,
1107
+ quest_root: Path,
1108
+ kind: str,
1109
+ *,
1110
+ source_signature: str,
1111
+ payload_wrapper: dict[str, Any] | None,
1112
+ ) -> dict[str, Any]:
1113
+ manifest = self._read_projection_manifest(quest_root)
1114
+ projections = manifest.get("projections") if isinstance(manifest.get("projections"), dict) else {}
1115
+ status = self._normalize_projection_status(kind, projections.get(kind))
1116
+ payload_signature = (
1117
+ str(payload_wrapper.get("source_signature") or "").strip()
1118
+ if isinstance(payload_wrapper, dict)
1119
+ else None
1120
+ ) or None
1121
+ payload_generated_at = (
1122
+ str(payload_wrapper.get("generated_at") or "").strip()
1123
+ if isinstance(payload_wrapper, dict)
1124
+ else None
1125
+ ) or None
1126
+ payload_ready = (
1127
+ isinstance(payload_wrapper, dict)
1128
+ and isinstance(payload_wrapper.get("payload"), dict)
1129
+ and payload_signature == source_signature
1130
+ )
1131
+ if payload_ready:
1132
+ status.update(
1133
+ {
1134
+ "state": "ready",
1135
+ "source_signature": source_signature,
1136
+ "generated_at": payload_generated_at,
1137
+ "last_success_at": payload_generated_at or status.get("last_success_at"),
1138
+ "progress_current": _PROJECTION_BUILD_TOTAL_STEPS,
1139
+ "progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
1140
+ "current_step": None,
1141
+ "error": None,
1142
+ }
1143
+ )
1144
+ return status
1145
+ if self._projection_build_active(quest_root, kind):
1146
+ status["state"] = "building" if status.get("state") != "queued" else "queued"
1147
+ status["progress_total"] = max(int(status.get("progress_total") or 0), _PROJECTION_BUILD_TOTAL_STEPS)
1148
+ status["current_step"] = status.get("current_step") or "Building projection"
1149
+ return status
1150
+ if isinstance(payload_wrapper, dict) and isinstance(payload_wrapper.get("payload"), dict):
1151
+ status.update(
1152
+ {
1153
+ "state": "stale",
1154
+ "generated_at": payload_generated_at,
1155
+ "last_success_at": payload_generated_at or status.get("last_success_at"),
1156
+ "progress_current": 0,
1157
+ "progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
1158
+ "current_step": "Queued for refresh",
1159
+ }
1160
+ )
1161
+ return status
1162
+ if status.get("state") == "failed":
1163
+ status["progress_total"] = max(int(status.get("progress_total") or 0), _PROJECTION_BUILD_TOTAL_STEPS)
1164
+ return status
1165
+ return self._default_projection_status(kind)
1166
+
1167
+ def _queue_projection_build(self, quest_root: Path, kind: str, *, source_signature: str) -> None:
1168
+ if self._projection_build_active(quest_root, kind):
1169
+ return
1170
+
1171
+ with self._projection_lock(quest_root, kind):
1172
+ payload_wrapper = self._read_projection_payload_file(quest_root, kind)
1173
+ if (
1174
+ isinstance(payload_wrapper, dict)
1175
+ and str(payload_wrapper.get("source_signature") or "").strip() == source_signature
1176
+ and isinstance(payload_wrapper.get("payload"), dict)
1177
+ ):
1178
+ ready_status = self._default_projection_status(kind)
1179
+ ready_status.update(
1180
+ {
1181
+ "state": "ready",
1182
+ "source_signature": source_signature,
1183
+ "generated_at": str(payload_wrapper.get("generated_at") or "").strip() or None,
1184
+ "last_success_at": str(payload_wrapper.get("generated_at") or "").strip() or None,
1185
+ "progress_current": _PROJECTION_BUILD_TOTAL_STEPS,
1186
+ "progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
1187
+ }
1188
+ )
1189
+ self._write_projection_manifest_locked(quest_root, kind, ready_status)
1190
+ return
1191
+ queued_status = self._default_projection_status(kind)
1192
+ queued_status.update(
1193
+ {
1194
+ "state": "queued",
1195
+ "source_signature": source_signature,
1196
+ "progress_current": 0,
1197
+ "progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
1198
+ "current_step": "Queued for background rebuild",
1199
+ "error": None,
1200
+ }
1201
+ )
1202
+ self._write_projection_manifest_locked(quest_root, kind, queued_status)
1203
+
1204
+ build_key = self._projection_build_key(quest_root, kind)
1205
+
1206
+ def _update_progress(current: int, step: str | None) -> None:
1207
+ with self._projection_lock(quest_root, kind):
1208
+ manifest = self._read_projection_manifest(quest_root)
1209
+ projections = manifest.get("projections") if isinstance(manifest.get("projections"), dict) else {}
1210
+ status = self._normalize_projection_status(kind, projections.get(kind))
1211
+ status.update(
1212
+ {
1213
+ "state": "building",
1214
+ "source_signature": source_signature,
1215
+ "progress_current": max(0, min(current, _PROJECTION_BUILD_TOTAL_STEPS)),
1216
+ "progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
1217
+ "current_step": step,
1218
+ "error": None,
1219
+ }
1220
+ )
1221
+ self._write_projection_manifest_locked(quest_root, kind, status)
1222
+
1223
+ def _worker() -> None:
1224
+ try:
1225
+ _update_progress(0, "Preparing projection inputs")
1226
+ payload = self._build_projection_payload(
1227
+ quest_root,
1228
+ kind,
1229
+ source_signature=source_signature,
1230
+ update_progress=_update_progress,
1231
+ )
1232
+ _update_progress(_PROJECTION_BUILD_TOTAL_STEPS, "Writing projection")
1233
+ generated_at = utc_now()
1234
+ with self._projection_lock(quest_root, kind):
1235
+ self._write_projection_payload_locked(
1236
+ quest_root,
1237
+ kind,
1238
+ source_signature=source_signature,
1239
+ payload=payload,
1240
+ generated_at=generated_at,
1241
+ )
1242
+ ready_status = self._default_projection_status(kind)
1243
+ ready_status.update(
1244
+ {
1245
+ "state": "ready",
1246
+ "source_signature": source_signature,
1247
+ "generated_at": generated_at,
1248
+ "last_success_at": generated_at,
1249
+ "progress_current": _PROJECTION_BUILD_TOTAL_STEPS,
1250
+ "progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
1251
+ "current_step": None,
1252
+ "error": None,
1253
+ }
1254
+ )
1255
+ self._write_projection_manifest_locked(quest_root, kind, ready_status)
1256
+ except Exception as exc:
1257
+ with self._projection_lock(quest_root, kind):
1258
+ failed_status = self._default_projection_status(kind)
1259
+ failed_status.update(
1260
+ {
1261
+ "state": "failed",
1262
+ "source_signature": source_signature,
1263
+ "progress_current": 0,
1264
+ "progress_total": _PROJECTION_BUILD_TOTAL_STEPS,
1265
+ "current_step": None,
1266
+ "error": str(exc),
1267
+ }
1268
+ )
1269
+ self._write_projection_manifest_locked(quest_root, kind, failed_status)
1270
+ finally:
1271
+ with self._quest_projection_builds_lock:
1272
+ active = self._quest_projection_builds.get(build_key)
1273
+ if active is threading.current_thread():
1274
+ self._quest_projection_builds.pop(build_key, None)
1275
+
1276
+ worker = threading.Thread(
1277
+ target=_worker,
1278
+ daemon=True,
1279
+ name=f"ds-projection-{quest_root.name}-{kind}",
1280
+ )
1281
+ with self._quest_projection_builds_lock:
1282
+ self._quest_projection_builds[build_key] = worker
1283
+ worker.start()
1284
+
1285
+ def _recent_codex_runs(self, quest_root: Path, *, limit: int = 5) -> list[dict[str, Any]]:
1286
+ history_root = quest_root / ".ds" / "codex_history"
1287
+ if not history_root.exists():
1288
+ return []
1289
+ runs: list[dict[str, Any]] = []
1290
+ for meta_path in sorted(history_root.glob("*/meta.json")):
1291
+ payload = self._read_cached_json(meta_path, {})
1292
+ if not isinstance(payload, dict) or not payload:
1293
+ continue
1294
+ record = dict(payload)
1295
+ record.setdefault("history_root", str(meta_path.parent))
1296
+ runs.append(record)
1297
+ runs.sort(
1298
+ key=lambda item: str(
1299
+ item.get("updated_at")
1300
+ or item.get("completed_at")
1301
+ or item.get("created_at")
1302
+ or item.get("run_id")
1303
+ or ""
1304
+ )
1305
+ )
1306
+ return runs[-limit:]
1307
+
1308
+ def _build_workflow_payload(
1309
+ self,
1310
+ quest_id: str,
1311
+ quest_root: Path,
1312
+ workspace_root: Path,
1313
+ *,
1314
+ recent_runs: list[dict[str, Any]],
1315
+ recent_artifacts: list[dict[str, Any]],
1316
+ ) -> dict[str, Any]:
1317
+ entries: list[dict[str, Any]] = []
1318
+ changed_files: list[dict[str, Any]] = []
1319
+ seen_files: set[str] = set()
1320
+
1321
+ def add_file(path: str | None, *, source: str, document_id: str | None = None, writable: bool | None = None) -> None:
1322
+ if not path:
1323
+ return
1324
+ normalized = str(path)
1325
+ if normalized in seen_files:
1326
+ return
1327
+ seen_files.add(normalized)
1328
+ resolved_document_id = document_id or self._path_to_document_id(
1329
+ normalized,
1330
+ quest_root=quest_root,
1331
+ workspace_root=workspace_root,
1332
+ )
1333
+ changed_files.append(
1334
+ {
1335
+ "path": normalized,
1336
+ "source": source,
1337
+ "document_id": resolved_document_id,
1338
+ "writable": writable,
1339
+ }
1340
+ )
1341
+
1342
+ for relative in ("brief.md", "plan.md", "status.md", "SUMMARY.md"):
1343
+ add_file(
1344
+ str(workspace_root / relative),
1345
+ source="document",
1346
+ document_id=relative,
1347
+ writable=True,
1348
+ )
1349
+
1350
+ for run in recent_runs:
1351
+ run_id = str(run.get("run_id") or "run")
1352
+ entries.append(
1353
+ {
1354
+ "id": f"run:{run_id}",
1355
+ "kind": "run",
1356
+ "run_id": run_id,
1357
+ "skill_id": run.get("skill_id"),
1358
+ "title": run_id,
1359
+ "summary": run.get("summary") or "Run completed.",
1360
+ "status": "completed" if run.get("exit_code", 0) == 0 else "failed",
1361
+ "created_at": run.get("completed_at") or run.get("created_at") or run.get("updated_at"),
1362
+ "paths": [item for item in [run.get("history_root"), run.get("run_root"), run.get("output_path")] if item],
1363
+ }
1364
+ )
1365
+ for path in (run.get("history_root"), run.get("run_root"), run.get("output_path")):
1366
+ add_file(path, source="run")
1367
+ history_root = run.get("history_root")
1368
+ if history_root:
1369
+ entries.extend(
1370
+ self._parse_codex_history_cached(
1371
+ Path(str(history_root)),
1372
+ quest_id=quest_id,
1373
+ run_id=run_id,
1374
+ skill_id=run.get("skill_id"),
1375
+ )
1376
+ )
1377
+
1378
+ for artifact in recent_artifacts:
1379
+ payload = artifact.get("payload") if isinstance(artifact.get("payload"), dict) else {}
1380
+ artifact_path = artifact.get("path")
1381
+ entries.append(
1382
+ {
1383
+ "id": f"artifact:{payload.get('artifact_id') or artifact_path}",
1384
+ "kind": "artifact",
1385
+ "title": str(payload.get("artifact_id") or artifact.get("kind") or "artifact"),
1386
+ "summary": payload.get("summary") or payload.get("message") or payload.get("reason") or "Artifact updated.",
1387
+ "status": payload.get("status"),
1388
+ "reason": payload.get("reason"),
1389
+ "created_at": payload.get("updated_at") or payload.get("created_at"),
1390
+ "paths": list((payload.get("paths") or {}).values()) + ([str(artifact_path)] if artifact_path else []),
1391
+ }
1392
+ )
1393
+ add_file(str(artifact_path) if artifact_path else None, source="artifact")
1394
+ for path in (payload.get("paths") or {}).values():
1395
+ add_file(str(path), source="artifact_path")
1396
+
1397
+ entries.sort(key=lambda item: str(item.get("created_at") or item.get("id") or ""))
1398
+ return {
1399
+ "quest_id": quest_id,
1400
+ "quest_root": str(quest_root.resolve()),
1401
+ "entries": entries[-80:],
1402
+ "changed_files": changed_files[-30:],
1403
+ }
1404
+
1405
+ def _build_details_projection_payload(
1406
+ self,
1407
+ quest_root: Path,
1408
+ *,
1409
+ source_signature: str,
1410
+ update_progress: Any,
1411
+ ) -> dict[str, Any]:
1412
+ quest_id = quest_root.name
1413
+ workspace_root = self.active_workspace_root(quest_root)
1414
+ update_progress(1, "Loading recent workflow sources")
1415
+ recent_artifacts = self._collect_artifacts(quest_root)[-8:]
1416
+ recent_runs = self._recent_codex_runs(quest_root, limit=5)
1417
+ update_progress(2, "Materializing workflow timeline")
1418
+ return self._build_workflow_payload(
1419
+ quest_id,
1420
+ quest_root,
1421
+ workspace_root,
1422
+ recent_runs=recent_runs,
1423
+ recent_artifacts=recent_artifacts,
1424
+ )
1425
+
1426
+ def _build_canvas_projection_payload(
1427
+ self,
1428
+ quest_root: Path,
1429
+ *,
1430
+ source_signature: str,
1431
+ update_progress: Any,
1432
+ ) -> dict[str, Any]:
1433
+ update_progress(1, "Scanning branch references")
1434
+ update_progress(2, "Computing branch canvas")
1435
+ return list_branch_canvas(quest_root, quest_id=quest_root.name)
1436
+
1437
+ def _build_projection_payload(
1438
+ self,
1439
+ quest_root: Path,
1440
+ kind: str,
1441
+ *,
1442
+ source_signature: str,
1443
+ update_progress: Any,
1444
+ ) -> dict[str, Any]:
1445
+ if kind == "details":
1446
+ return self._build_details_projection_payload(
1447
+ quest_root,
1448
+ source_signature=source_signature,
1449
+ update_progress=update_progress,
1450
+ )
1451
+ if kind == "canvas":
1452
+ return self._build_canvas_projection_payload(
1453
+ quest_root,
1454
+ source_signature=source_signature,
1455
+ update_progress=update_progress,
1456
+ )
1457
+ raise ValueError(f"Unsupported projection kind `{kind}`.")
1458
+
1459
+ def _placeholder_workflow_payload(self, quest_id: str, quest_root: Path) -> dict[str, Any]:
1460
+ workspace_root = self.active_workspace_root(quest_root)
1461
+ return self._build_workflow_payload(
1462
+ quest_id,
1463
+ quest_root,
1464
+ workspace_root,
1465
+ recent_runs=[],
1466
+ recent_artifacts=[],
1467
+ )
1468
+
1469
+ def _placeholder_canvas_payload(self, quest_id: str, quest_root: Path) -> dict[str, Any]:
1470
+ research_state = self.read_research_state(quest_root)
1471
+ default_ref = (
1472
+ str(research_state.get("research_head_branch") or "").strip()
1473
+ or str(research_state.get("current_workspace_branch") or "").strip()
1474
+ or current_branch(quest_root)
1475
+ )
1476
+ return {
1477
+ "quest_id": quest_id,
1478
+ "default_ref": default_ref,
1479
+ "current_ref": default_ref,
1480
+ "head": head_commit(quest_root),
1481
+ "nodes": [],
1482
+ "edges": [],
1483
+ "views": {
1484
+ "ideas": [],
1485
+ "analysis": [],
1486
+ },
1487
+ }
1488
+
1489
+ def _projected_payload(self, quest_id: str, kind: str) -> dict[str, Any]:
1490
+ quest_root = self._quest_root(quest_id)
1491
+ source_signature = self._projection_source_signature(quest_root, kind)
1492
+ payload_wrapper = self._read_projection_payload_file(quest_root, kind)
1493
+ payload_ready = (
1494
+ isinstance(payload_wrapper, dict)
1495
+ and str(payload_wrapper.get("source_signature") or "").strip() == source_signature
1496
+ and isinstance(payload_wrapper.get("payload"), dict)
1497
+ )
1498
+ if not payload_ready:
1499
+ self._queue_projection_build(quest_root, kind, source_signature=source_signature)
1500
+ payload_wrapper = self._read_projection_payload_file(quest_root, kind)
1501
+ status = self._present_projection_status(
1502
+ quest_root,
1503
+ kind,
1504
+ source_signature=source_signature,
1505
+ payload_wrapper=payload_wrapper,
1506
+ )
1507
+ payload = (
1508
+ copy.deepcopy(payload_wrapper.get("payload"))
1509
+ if isinstance(payload_wrapper, dict) and isinstance(payload_wrapper.get("payload"), dict)
1510
+ else None
1511
+ )
1512
+ if payload is None:
1513
+ payload = (
1514
+ self._placeholder_workflow_payload(quest_id, quest_root)
1515
+ if kind == "details"
1516
+ else self._placeholder_canvas_payload(quest_id, quest_root)
1517
+ )
1518
+ payload["projection_status"] = status
1519
+ return payload
1520
+
1521
+ def prime_projection(self, quest_id: str, kind: str) -> None:
1522
+ quest_root = self._quest_root(quest_id)
1523
+ self._queue_projection_build(
1524
+ quest_root,
1525
+ kind,
1526
+ source_signature=self._projection_source_signature(quest_root, kind),
1527
+ )
1528
+
1529
+ def schedule_projection_refresh(
1530
+ self,
1531
+ quest_root: Path,
1532
+ *,
1533
+ kinds: tuple[str, ...] | list[str] | None = None,
1534
+ throttle_seconds: float = _PROJECTION_REFRESH_THROTTLE_SECONDS,
1535
+ ) -> None:
1536
+ resolved_kinds = [
1537
+ str(kind).strip()
1538
+ for kind in (kinds or ("details", "canvas"))
1539
+ if str(kind).strip() in {"details", "canvas"}
1540
+ ]
1541
+ if not resolved_kinds:
1542
+ return
1543
+ min_interval = max(0.0, float(throttle_seconds))
1544
+ now = time.monotonic()
1545
+ for kind in resolved_kinds:
1546
+ build_key = self._projection_build_key(quest_root, kind)
1547
+ if self._projection_build_active(quest_root, kind):
1548
+ continue
1549
+ with self._quest_projection_refresh_lock:
1550
+ previous = float(self._quest_projection_refresh_at.get(build_key) or 0.0)
1551
+ if min_interval > 0 and now - previous < min_interval:
1552
+ continue
1553
+ self._quest_projection_refresh_at[build_key] = now
1554
+ try:
1555
+ self._queue_projection_build(
1556
+ quest_root,
1557
+ kind,
1558
+ source_signature=self._projection_source_signature(quest_root, kind),
1559
+ )
1560
+ except Exception:
1561
+ continue
1562
+
1563
+ def git_branch_canvas(self, quest_id: str) -> dict[str, Any]:
1564
+ return self._projected_payload(quest_id, "canvas")
1565
+
1566
+ def _active_baseline_attachment(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
1567
+ attachments: list[dict[str, Any]] = []
1568
+ seen_paths: set[str] = set()
1569
+ for root in (workspace_root, quest_root):
1570
+ attachment_root = root / "baselines" / "imported"
1571
+ if not attachment_root.exists():
1572
+ continue
1573
+ for path in sorted(attachment_root.glob("*/attachment.yaml")):
1574
+ key = str(path.resolve())
1575
+ if key in seen_paths:
1576
+ continue
1577
+ seen_paths.add(key)
1578
+ payload = self._read_cached_yaml(path, {})
1579
+ baseline_id = str(payload.get("source_baseline_id") or "").strip() if isinstance(payload, dict) else ""
1580
+ if baseline_id and self.baseline_registry.is_deleted(baseline_id):
1581
+ continue
1582
+ if isinstance(payload, dict) and payload:
1583
+ attachments.append(payload)
1584
+ if not attachments:
1585
+ return None
1586
+ return max(
1587
+ attachments,
1588
+ key=lambda item: (
1589
+ str(item.get("attached_at") or ""),
1590
+ str(item.get("source_baseline_id") or ""),
1591
+ ),
1592
+ )
1593
+
1594
+ @staticmethod
1595
+ def _markdown_excerpt(path: Path, *, max_lines: int = 8) -> str | None:
1596
+ if not path.exists() or not path.is_file():
1597
+ return None
1598
+ text = read_text(path, "")
1599
+ if not text.strip():
1600
+ return None
1601
+ lines = [line.rstrip() for line in text.splitlines() if line.strip()]
1602
+ if not lines:
1603
+ return None
1604
+ excerpt = "\n".join(lines[:max_lines]).strip()
1605
+ return excerpt or None
1606
+
1607
+ def _snapshot_workspace_candidates(self, quest_root: Path, workspace_root: Path) -> list[Path]:
1608
+ candidates: list[Path] = []
1609
+ seen: set[str] = set()
1610
+
1611
+ def add(path: Path | None) -> None:
1612
+ if path is None:
1613
+ return
1614
+ resolved = path.resolve()
1615
+ key = str(resolved)
1616
+ if key in seen or not resolved.exists():
1617
+ return
1618
+ seen.add(key)
1619
+ candidates.append(resolved)
1620
+
1621
+ add(workspace_root)
1622
+ add(quest_root)
1623
+ worktrees_root = quest_root / ".ds" / "worktrees"
1624
+ if worktrees_root.exists():
1625
+ for item in sorted(worktrees_root.iterdir()):
1626
+ if item.is_dir():
1627
+ add(item)
1628
+ return candidates
1629
+
1630
+ @staticmethod
1631
+ def _path_mtime(path: Path) -> float:
1632
+ try:
1633
+ return path.stat().st_mtime
1634
+ except OSError:
1635
+ return 0.0
1636
+
1637
+ def _best_paper_root(self, quest_root: Path, workspace_root: Path) -> Path | None:
1638
+ best_root: Path | None = None
1639
+ best_rank: tuple[int, float] = (-1, -1.0)
1640
+ for candidate in self._snapshot_workspace_candidates(quest_root, workspace_root):
1641
+ paper_root = candidate / "paper"
1642
+ if not paper_root.exists() or not paper_root.is_dir():
1643
+ continue
1644
+ selected_outline = paper_root / "selected_outline.json"
1645
+ bundle_manifest = paper_root / "paper_bundle_manifest.json"
1646
+ draft = paper_root / "draft.md"
1647
+ score = 0
1648
+ if selected_outline.exists():
1649
+ score += 4
1650
+ if bundle_manifest.exists():
1651
+ score += 5
1652
+ if draft.exists():
1653
+ score += 2
1654
+ latest = max(
1655
+ self._path_mtime(selected_outline),
1656
+ self._path_mtime(bundle_manifest),
1657
+ self._path_mtime(draft),
1658
+ self._path_mtime(paper_root),
1659
+ )
1660
+ rank = (score, latest)
1661
+ if rank > best_rank:
1662
+ best_rank = rank
1663
+ best_root = paper_root
1664
+ return best_root
1665
+
1666
+ def _outline_record_from_paper_root(self, paper_root: Path) -> dict[str, Any]:
1667
+ outline_root = paper_root / "outline"
1668
+ manifest_path = outline_root / "manifest.json"
1669
+ if manifest_path.exists():
1670
+ manifest = read_json(manifest_path, {})
1671
+ if isinstance(manifest, dict) and manifest:
1672
+ manifest_sections = [
1673
+ dict(item) for item in (manifest.get("sections") or []) if isinstance(item, dict)
1674
+ ]
1675
+ by_id = {
1676
+ str(item.get("section_id") or "").strip(): dict(item)
1677
+ for item in manifest_sections
1678
+ if str(item.get("section_id") or "").strip()
1679
+ }
1680
+ section_order = [
1681
+ str(item).strip() for item in (manifest.get("section_order") or []) if str(item).strip()
1682
+ ]
1683
+ sections_root = outline_root / "sections"
1684
+ if sections_root.exists():
1685
+ for section_dir in sorted(sections_root.iterdir()):
1686
+ if not section_dir.is_dir():
1687
+ continue
1688
+ section_id = section_dir.name
1689
+ section = dict(by_id.get(section_id) or {})
1690
+ section.setdefault("section_id", section_id)
1691
+ section.setdefault("title", section_id)
1692
+ result_table_payload = read_json(section_dir / "result_table.json", {})
1693
+ rows = result_table_payload.get("rows") if isinstance(result_table_payload, dict) else []
1694
+ section["result_table"] = rows if isinstance(rows, list) else []
1695
+ by_id[section_id] = section
1696
+ ordered_sections: list[dict[str, Any]] = []
1697
+ emitted: set[str] = set()
1698
+ for section_id in section_order:
1699
+ section = by_id.get(section_id)
1700
+ if section is None:
1701
+ continue
1702
+ ordered_sections.append(section)
1703
+ emitted.add(section_id)
1704
+ for section_id, section in by_id.items():
1705
+ if section_id in emitted:
1706
+ continue
1707
+ ordered_sections.append(section)
1708
+ return {
1709
+ "schema_version": 1,
1710
+ "outline_id": manifest.get("outline_id"),
1711
+ "status": manifest.get("status"),
1712
+ "title": manifest.get("title"),
1713
+ "note": manifest.get("note"),
1714
+ "story": manifest.get("story"),
1715
+ "ten_questions": manifest.get("ten_questions") if isinstance(manifest.get("ten_questions"), list) else [],
1716
+ "detailed_outline": manifest.get("detailed_outline") if isinstance(manifest.get("detailed_outline"), dict) else {},
1717
+ "sections": ordered_sections,
1718
+ "evidence_contract": manifest.get("evidence_contract") if isinstance(manifest.get("evidence_contract"), dict) else None,
1719
+ "created_at": manifest.get("created_at"),
1720
+ "updated_at": manifest.get("updated_at"),
1721
+ }
1722
+ selected_outline_path = paper_root / "selected_outline.json"
1723
+ payload = read_json(selected_outline_path, {})
1724
+ return payload if isinstance(payload, dict) else {}
1725
+
1726
+ def _paper_evidence_payload(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
1727
+ best_payload: dict[str, Any] | None = None
1728
+ best_rank: tuple[str, float] = ("", -1.0)
1729
+ for candidate in self._snapshot_workspace_candidates(quest_root, workspace_root):
1730
+ paper_root = candidate / "paper"
1731
+ ledger_json_path = paper_root / "evidence_ledger.json"
1732
+ if not ledger_json_path.exists():
1733
+ continue
1734
+ payload = read_json(ledger_json_path, {})
1735
+ if not isinstance(payload, dict) or not payload:
1736
+ continue
1737
+ items = [dict(item) for item in (payload.get("items") or []) if isinstance(item, dict)]
1738
+ latest = max(
1739
+ self._path_mtime(ledger_json_path),
1740
+ self._path_mtime(paper_root / "evidence_ledger.md"),
1741
+ self._path_mtime(paper_root),
1742
+ )
1743
+ rank = (str(payload.get("updated_at") or payload.get("created_at") or ""), latest)
1744
+ if rank < best_rank:
1745
+ continue
1746
+ best_rank = rank
1747
+ best_payload = {
1748
+ "paper_root": str(paper_root),
1749
+ "workspace_root": str(paper_root.parent),
1750
+ "selected_outline_ref": str(payload.get("selected_outline_ref") or "").strip() or None,
1751
+ "item_count": len(items),
1752
+ "main_text_ready_count": sum(
1753
+ 1
1754
+ for item in items
1755
+ if str(item.get("paper_role") or "").strip() == "main_text"
1756
+ and str(item.get("status") or "").strip().lower() in {"ready", "completed", "analyzed", "written", "recorded", "supported"}
1757
+ ),
1758
+ "appendix_item_count": sum(
1759
+ 1 for item in items if str(item.get("paper_role") or "").strip() == "appendix"
1760
+ ),
1761
+ "unmapped_item_count": sum(
1762
+ 1
1763
+ for item in items
1764
+ if not str(item.get("section_id") or "").strip() or not str(item.get("paper_role") or "").strip()
1765
+ ),
1766
+ "items": items[:40],
1767
+ "paths": {
1768
+ "ledger_json": str(ledger_json_path),
1769
+ "ledger_md": str(paper_root / "evidence_ledger.md") if (paper_root / "evidence_ledger.md").exists() else None,
1770
+ },
1771
+ }
1772
+ return best_payload
1773
+
1774
+ def _paper_contract_payload(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
1775
+ paper_root = self._best_paper_root(quest_root, workspace_root)
1776
+ if paper_root is None:
1777
+ return None
1778
+ selected_outline_path = paper_root / "selected_outline.json"
1779
+ selected_outline = self._outline_record_from_paper_root(paper_root)
1780
+ selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
1781
+ detailed_outline = (
1782
+ dict(selected_outline.get("detailed_outline") or {})
1783
+ if isinstance(selected_outline.get("detailed_outline"), dict)
1784
+ else {}
1785
+ )
1786
+ outline_manifest_path = paper_root / "outline" / "manifest.json"
1787
+ bundle_manifest_path = paper_root / "paper_bundle_manifest.json"
1788
+ bundle_manifest = read_json(bundle_manifest_path, {})
1789
+ bundle_manifest = bundle_manifest if isinstance(bundle_manifest, dict) else {}
1790
+ experiment_matrix_path = paper_root / "paper_experiment_matrix.md"
1791
+ experiment_matrix_json_path = paper_root / "paper_experiment_matrix.json"
1792
+ claim_map_path = paper_root / "claim_evidence_map.json"
1793
+ paper_line_state_path = paper_root / "paper_line_state.json"
1794
+ evidence_ledger = self._paper_evidence_payload(quest_root, workspace_root)
1795
+ checklist_path = paper_root / "review" / "submission_checklist.json"
1796
+ draft_path = paper_root / "draft.md"
1797
+ status_path = paper_root.parent / "status.md"
1798
+ summary_path = paper_root.parent / "SUMMARY.md"
1799
+
1800
+ raw_sections = selected_outline.get("sections") if isinstance(selected_outline.get("sections"), list) else []
1801
+ sections = []
1802
+ if raw_sections:
1803
+ for index, raw in enumerate(raw_sections, start=1):
1804
+ if not isinstance(raw, dict):
1805
+ continue
1806
+ title = str(raw.get("title") or raw.get("section_id") or "").strip()
1807
+ if not title:
1808
+ title = f"Section {index}"
1809
+ sections.append(
1810
+ {
1811
+ "section_id": str(raw.get("section_id") or slugify(title, f"section-{index}")).strip() or slugify(title, f"section-{index}"),
1812
+ "title": title,
1813
+ "paper_role": str(raw.get("paper_role") or "").strip() or None,
1814
+ "status": str(raw.get("status") or "").strip() or None,
1815
+ "claims": raw.get("claims") if isinstance(raw.get("claims"), list) else [],
1816
+ "required_items": raw.get("required_items") if isinstance(raw.get("required_items"), list) else [],
1817
+ "optional_items": raw.get("optional_items") if isinstance(raw.get("optional_items"), list) else [],
1818
+ "result_table": raw.get("result_table") if isinstance(raw.get("result_table"), list) else [],
1819
+ }
1820
+ )
1821
+ else:
1822
+ for item in detailed_outline.get("experimental_designs") or []:
1823
+ text = str(item or "").strip()
1824
+ if not text:
1825
+ continue
1826
+ sections.append(
1827
+ {
1828
+ "section_id": slugify(text, "section"),
1829
+ "title": text,
1830
+ "paper_role": "main_text",
1831
+ "status": "recorded",
1832
+ "claims": [],
1833
+ "required_items": [],
1834
+ "optional_items": [],
1835
+ "result_table": [],
1836
+ }
1837
+ )
496
1838
 
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():
1839
+ return {
1840
+ "paper_root": str(paper_root),
1841
+ "workspace_root": str(paper_root.parent),
1842
+ "paper_branch": str(bundle_manifest.get("paper_branch") or "").strip() or current_branch(paper_root.parent),
1843
+ "source_branch": str(bundle_manifest.get("source_branch") or "").strip() or None,
1844
+ "selected_outline_ref": str(selected_outline.get("outline_id") or bundle_manifest.get("selected_outline_ref") or "").strip() or None,
1845
+ "title": str(selected_outline.get("title") or bundle_manifest.get("title") or "").strip() or None,
1846
+ "story": str(selected_outline.get("story") or "").strip() or None,
1847
+ "research_questions": detailed_outline.get("research_questions") if isinstance(detailed_outline.get("research_questions"), list) else [],
1848
+ "experimental_designs": detailed_outline.get("experimental_designs") if isinstance(detailed_outline.get("experimental_designs"), list) else [],
1849
+ "contributions": detailed_outline.get("contributions") if isinstance(detailed_outline.get("contributions"), list) else [],
1850
+ "evidence_contract": selected_outline.get("evidence_contract") if isinstance(selected_outline.get("evidence_contract"), dict) else None,
1851
+ "sections": sections,
1852
+ "evidence_summary": {
1853
+ "item_count": int((evidence_ledger or {}).get("item_count") or 0),
1854
+ "main_text_ready_count": int((evidence_ledger or {}).get("main_text_ready_count") or 0),
1855
+ "appendix_item_count": int((evidence_ledger or {}).get("appendix_item_count") or 0),
1856
+ "unmapped_item_count": int((evidence_ledger or {}).get("unmapped_item_count") or 0),
1857
+ },
1858
+ "summary": str(bundle_manifest.get("summary") or "").strip() or self._markdown_excerpt(summary_path),
1859
+ "paths": {
1860
+ "selected_outline": str(selected_outline_path) if selected_outline_path.exists() else None,
1861
+ "outline_manifest": str(outline_manifest_path) if outline_manifest_path.exists() else None,
1862
+ "experiment_matrix": str(experiment_matrix_path) if experiment_matrix_path.exists() else None,
1863
+ "experiment_matrix_json": str(experiment_matrix_json_path) if experiment_matrix_json_path.exists() else None,
1864
+ "bundle_manifest": str(bundle_manifest_path) if bundle_manifest_path.exists() else None,
1865
+ "claim_evidence_map": str(claim_map_path) if claim_map_path.exists() else None,
1866
+ "paper_line_state": str(paper_line_state_path) if paper_line_state_path.exists() else None,
1867
+ "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,
1868
+ "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,
1869
+ "submission_checklist": str(checklist_path) if checklist_path.exists() else None,
1870
+ "draft": str(draft_path) if draft_path.exists() else None,
1871
+ "status": str(status_path) if status_path.exists() else None,
1872
+ "summary": str(summary_path) if summary_path.exists() else None,
1873
+ },
1874
+ "bundle_manifest": bundle_manifest or None,
1875
+ "outline_payload": selected_outline or None,
1876
+ }
1877
+
1878
+ def _paper_lines_payload(self, quest_root: Path, workspace_root: Path) -> tuple[list[dict[str, Any]], str | None]:
1879
+ lines_by_id: dict[str, dict[str, Any]] = {}
1880
+ active_ref: str | None = None
1881
+ for candidate in self._snapshot_workspace_candidates(quest_root, workspace_root):
1882
+ paper_root = candidate / "paper"
1883
+ if not paper_root.exists() or not paper_root.is_dir():
503
1884
  continue
504
- for path in sorted(attachment_root.glob("*/attachment.yaml")):
505
- key = str(path.resolve())
506
- if key in seen_paths:
1885
+ state_path = paper_root / "paper_line_state.json"
1886
+ payload = read_json(state_path, {}) if state_path.exists() else {}
1887
+ if not isinstance(payload, dict) or not payload:
1888
+ contract = self._paper_contract_payload(quest_root, candidate)
1889
+ if not contract:
507
1890
  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):
1891
+ bundle_manifest = (
1892
+ dict(contract.get("bundle_manifest") or {})
1893
+ if isinstance(contract.get("bundle_manifest"), dict)
1894
+ else {}
1895
+ )
1896
+ payload = {
1897
+ "paper_line_id": slugify(
1898
+ "::".join(
1899
+ [
1900
+ str(contract.get("paper_branch") or "paper").strip() or "paper",
1901
+ str(contract.get("selected_outline_ref") or "outline").strip() or "outline",
1902
+ str(bundle_manifest.get("source_run_id") or "run").strip() or "run",
1903
+ ]
1904
+ ),
1905
+ "paper-line",
1906
+ ),
1907
+ "paper_branch": contract.get("paper_branch"),
1908
+ "paper_root": str(paper_root),
1909
+ "workspace_root": str(candidate),
1910
+ "source_branch": contract.get("source_branch"),
1911
+ "source_run_id": bundle_manifest.get("source_run_id"),
1912
+ "source_idea_id": bundle_manifest.get("source_idea_id"),
1913
+ "selected_outline_ref": contract.get("selected_outline_ref"),
1914
+ "title": contract.get("title"),
1915
+ "required_count": sum(len(item.get("required_items") or []) for item in (contract.get("sections") or [])),
1916
+ "ready_required_count": int((contract.get("evidence_summary") or {}).get("main_text_ready_count") or 0),
1917
+ "section_count": len(contract.get("sections") or []),
1918
+ "ready_section_count": 0,
1919
+ "unmapped_count": int((contract.get("evidence_summary") or {}).get("unmapped_item_count") or 0),
1920
+ "open_supplementary_count": 0,
1921
+ "draft_status": "present" if (paper_root / "draft.md").exists() else "missing",
1922
+ "bundle_status": "present" if (paper_root / "paper_bundle_manifest.json").exists() else "missing",
1923
+ "updated_at": "",
1924
+ }
1925
+ paper_line_id = str(payload.get("paper_line_id") or "").strip()
1926
+ if not paper_line_id:
1927
+ continue
1928
+ payload["paths"] = {
1929
+ "paper_line_state": str(state_path) if state_path.exists() else None,
1930
+ "paper_root": str(paper_root),
1931
+ }
1932
+ current = lines_by_id.get(paper_line_id)
1933
+ if current is None or str(payload.get("updated_at") or "") >= str(current.get("updated_at") or ""):
1934
+ lines_by_id[paper_line_id] = payload
1935
+ if str(candidate) == str(workspace_root):
1936
+ active_ref = paper_line_id
1937
+ lines = sorted(lines_by_id.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
1938
+ if not active_ref and lines:
1939
+ active_ref = str(lines[0].get("paper_line_id") or "").strip() or None
1940
+ return lines, active_ref
1941
+
1942
+ def _analysis_inventory_payload(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
1943
+ manifest_by_id: dict[str, dict[str, Any]] = {}
1944
+ campaigns_root = quest_root / ".ds" / "analysis_campaigns"
1945
+ if campaigns_root.exists():
1946
+ for path in sorted(campaigns_root.glob("*.json")):
1947
+ payload = read_json(path, {})
1948
+ if not isinstance(payload, dict) or not payload:
512
1949
  continue
513
- if isinstance(payload, dict) and payload:
514
- attachments.append(payload)
515
- if not attachments:
1950
+ campaign_id = str(payload.get("campaign_id") or path.stem).strip() or path.stem
1951
+ manifest_by_id[campaign_id] = payload
1952
+ campaigns_by_id: dict[str, dict[str, Any]] = {}
1953
+ for candidate in self._snapshot_workspace_candidates(quest_root, workspace_root):
1954
+ analysis_root = candidate / "experiments" / "analysis-results"
1955
+ if not analysis_root.exists() or not analysis_root.is_dir():
1956
+ continue
1957
+ for campaign_dir in sorted(analysis_root.iterdir()):
1958
+ if not campaign_dir.is_dir():
1959
+ continue
1960
+ campaign_id = campaign_dir.name
1961
+ todo_manifest_path = campaign_dir / "todo_manifest.json"
1962
+ campaign_md_path = campaign_dir / "campaign.md"
1963
+ summary_md_path = campaign_dir / "SUMMARY.md"
1964
+ todo_manifest = read_json(todo_manifest_path, {})
1965
+ todo_manifest = todo_manifest if isinstance(todo_manifest, dict) else {}
1966
+ campaign_manifest = dict(manifest_by_id.get(campaign_id) or {})
1967
+ todo_items = todo_manifest.get("todo_items") if isinstance(todo_manifest.get("todo_items"), list) else []
1968
+ manifest_slices = {
1969
+ str(item.get("slice_id") or "").strip(): dict(item)
1970
+ for item in (campaign_manifest.get("slices") or [])
1971
+ if isinstance(item, dict) and str(item.get("slice_id") or "").strip()
1972
+ }
1973
+ slice_files = []
1974
+ for path in sorted(campaign_dir.glob("*.md")):
1975
+ if path.name in {"campaign.md", "SUMMARY.md"}:
1976
+ continue
1977
+ slice_files.append(path)
1978
+ slices: list[dict[str, Any]] = []
1979
+ for index, path in enumerate(slice_files):
1980
+ matched_todo = todo_items[index] if index < len(todo_items) and isinstance(todo_items[index], dict) else {}
1981
+ slice_id = str(matched_todo.get("slice_id") or path.stem).strip() or path.stem
1982
+ title = str(matched_todo.get("title") or path.stem).strip() or path.stem
1983
+ manifest_slice = dict(manifest_slices.get(slice_id) or {})
1984
+ slices.append(
1985
+ {
1986
+ "slice_id": slice_id,
1987
+ "title": title,
1988
+ "status": str(manifest_slice.get("status") or matched_todo.get("status") or "completed").strip() or "completed",
1989
+ "tier": str(matched_todo.get("tier") or "").strip() or None,
1990
+ "exp_id": str(matched_todo.get("exp_id") or "").strip() or None,
1991
+ "paper_role": str(matched_todo.get("paper_placement") or matched_todo.get("paper_role") or "").strip() or None,
1992
+ "section_id": str(matched_todo.get("section_id") or "").strip() or None,
1993
+ "item_id": str(matched_todo.get("item_id") or "").strip() or None,
1994
+ "claim_links": matched_todo.get("claim_links") if isinstance(matched_todo.get("claim_links"), list) else [],
1995
+ "research_question": str(matched_todo.get("research_question") or "").strip() or None,
1996
+ "experimental_design": str(matched_todo.get("experimental_design") or "").strip() or None,
1997
+ "branch": str(manifest_slice.get("branch") or "").strip() or None,
1998
+ "worktree_root": str(manifest_slice.get("worktree_root") or "").strip() or None,
1999
+ "mapped": bool(
2000
+ str(matched_todo.get("section_id") or "").strip()
2001
+ and str(matched_todo.get("item_id") or "").strip()
2002
+ and str(matched_todo.get("paper_placement") or matched_todo.get("paper_role") or "").strip()
2003
+ ),
2004
+ "result_path": str(path),
2005
+ "result_excerpt": self._markdown_excerpt(path, max_lines=6),
2006
+ }
2007
+ )
2008
+ record = {
2009
+ "campaign_id": campaign_id,
2010
+ "title": str((todo_manifest.get("campaign_origin") or {}).get("reason") or campaign_id).strip() or campaign_id,
2011
+ "active_idea_id": str(campaign_manifest.get("active_idea_id") or "").strip() or None,
2012
+ "parent_run_id": str(campaign_manifest.get("parent_run_id") or "").strip() or None,
2013
+ "parent_branch": str(campaign_manifest.get("parent_branch") or "").strip() or None,
2014
+ "paper_line_id": str(campaign_manifest.get("paper_line_id") or "").strip() or None,
2015
+ "paper_line_branch": str(campaign_manifest.get("paper_line_branch") or "").strip() or None,
2016
+ "paper_line_root": str(campaign_manifest.get("paper_line_root") or "").strip() or None,
2017
+ "selected_outline_ref": str(campaign_manifest.get("selected_outline_ref") or todo_manifest.get("selected_outline_ref") or "").strip() or None,
2018
+ "todo_manifest_path": str(todo_manifest_path) if todo_manifest_path.exists() else None,
2019
+ "campaign_path": str(campaign_md_path) if campaign_md_path.exists() else None,
2020
+ "summary_path": str(summary_md_path) if summary_md_path.exists() else None,
2021
+ "summary_excerpt": self._markdown_excerpt(summary_md_path, max_lines=10),
2022
+ "updated_at": str(campaign_manifest.get("updated_at") or "").strip() or None,
2023
+ "slice_count": len(slices),
2024
+ "completed_slice_count": sum(1 for item in slices if str(item.get("status") or "") == "completed"),
2025
+ "mapped_slice_count": sum(1 for item in slices if bool(item.get("mapped"))),
2026
+ "pending_slice_count": sum(1 for item in slices if str(item.get("status") or "") != "completed"),
2027
+ "slices": slices,
2028
+ "_rank": (
2029
+ len(slices),
2030
+ max(
2031
+ self._path_mtime(summary_md_path),
2032
+ self._path_mtime(campaign_md_path),
2033
+ self._path_mtime(todo_manifest_path),
2034
+ self._path_mtime(campaigns_root / f"{campaign_id}.json"),
2035
+ self._path_mtime(campaign_dir),
2036
+ ),
2037
+ ),
2038
+ }
2039
+ current = campaigns_by_id.get(campaign_id)
2040
+ if current is None or record["_rank"] >= current["_rank"]:
2041
+ campaigns_by_id[campaign_id] = record
2042
+
2043
+ if not campaigns_by_id:
516
2044
  return None
517
- return max(
518
- attachments,
2045
+ campaigns = []
2046
+ total_slices = 0
2047
+ total_completed = 0
2048
+ total_mapped = 0
2049
+ for item in sorted(
2050
+ campaigns_by_id.values(),
2051
+ key=lambda payload: (payload["_rank"][1], payload["campaign_id"]),
2052
+ reverse=True,
2053
+ ):
2054
+ total_slices += int(item.get("slice_count") or 0)
2055
+ total_completed += int(item.get("completed_slice_count") or 0)
2056
+ total_mapped += int(item.get("mapped_slice_count") or 0)
2057
+ campaigns.append({key: value for key, value in item.items() if key != "_rank"})
2058
+ return {
2059
+ "campaign_count": len(campaigns),
2060
+ "slice_count": total_slices,
2061
+ "completed_slice_count": total_completed,
2062
+ "mapped_slice_count": total_mapped,
2063
+ "campaigns": campaigns,
2064
+ }
2065
+
2066
+ def _idea_lines_payload(
2067
+ self,
2068
+ quest_root: Path,
2069
+ *,
2070
+ paper_lines: list[dict[str, Any]],
2071
+ analysis_inventory: dict[str, Any] | None,
2072
+ ) -> tuple[list[dict[str, Any]], str | None]:
2073
+ artifacts = self._collect_artifacts(quest_root)
2074
+ research_state = self.read_research_state(quest_root)
2075
+ active_idea_id = str(research_state.get("active_idea_id") or "").strip() or None
2076
+ active_ref: str | None = None
2077
+ lines_by_id: dict[str, dict[str, Any]] = {}
2078
+
2079
+ def ensure_line(idea_id: str) -> dict[str, Any]:
2080
+ current = lines_by_id.get(idea_id)
2081
+ if current is None:
2082
+ current = {
2083
+ "idea_line_id": idea_id,
2084
+ "idea_id": idea_id,
2085
+ "idea_branch": None,
2086
+ "idea_title": None,
2087
+ "lineage_intent": None,
2088
+ "parent_branch": None,
2089
+ "latest_main_run_id": None,
2090
+ "latest_main_run_branch": None,
2091
+ "paper_line_id": None,
2092
+ "paper_branch": None,
2093
+ "selected_outline_ref": None,
2094
+ "analysis_campaign_count": 0,
2095
+ "analysis_slice_count": 0,
2096
+ "completed_analysis_slice_count": 0,
2097
+ "mapped_analysis_slice_count": 0,
2098
+ "required_count": 0,
2099
+ "ready_required_count": 0,
2100
+ "unmapped_count": 0,
2101
+ "open_supplementary_count": 0,
2102
+ "draft_status": None,
2103
+ "bundle_status": None,
2104
+ "updated_at": "",
2105
+ "paths": {
2106
+ "idea_md": None,
2107
+ "idea_draft": None,
2108
+ "paper_line_state": None,
2109
+ },
2110
+ }
2111
+ lines_by_id[idea_id] = current
2112
+ return current
2113
+
2114
+ def updated_rank(value: object) -> str:
2115
+ return str(value or "").strip()
2116
+
2117
+ for artifact in artifacts:
2118
+ kind = str(artifact.get("kind") or "").strip()
2119
+ payload = artifact.get("payload") if isinstance(artifact.get("payload"), dict) else {}
2120
+ if not payload:
2121
+ continue
2122
+ idea_id = str(payload.get("idea_id") or "").strip()
2123
+ if not idea_id:
2124
+ continue
2125
+ entry = ensure_line(idea_id)
2126
+ if kind == "ideas":
2127
+ current_rank = updated_rank(entry.get("updated_at"))
2128
+ candidate_rank = updated_rank(payload.get("updated_at") or payload.get("created_at"))
2129
+ if candidate_rank >= current_rank:
2130
+ details = dict(payload.get("details") or {}) if isinstance(payload.get("details"), dict) else {}
2131
+ paths = dict(payload.get("paths") or {}) if isinstance(payload.get("paths"), dict) else {}
2132
+ entry["idea_branch"] = str(payload.get("branch") or "").strip() or entry.get("idea_branch")
2133
+ entry["idea_title"] = str(details.get("title") or payload.get("title") or "").strip() or entry.get("idea_title")
2134
+ entry["lineage_intent"] = str(payload.get("lineage_intent") or details.get("lineage_intent") or "").strip() or entry.get("lineage_intent")
2135
+ entry["parent_branch"] = str(payload.get("parent_branch") or details.get("parent_branch") or "").strip() or entry.get("parent_branch")
2136
+ entry["updated_at"] = candidate_rank or entry.get("updated_at")
2137
+ entry["paths"] = {
2138
+ **dict(entry.get("paths") or {}),
2139
+ "idea_md": str(paths.get("idea_md") or "").strip() or dict(entry.get("paths") or {}).get("idea_md"),
2140
+ "idea_draft": str(paths.get("idea_draft_md") or details.get("idea_draft_path") or "").strip()
2141
+ or dict(entry.get("paths") or {}).get("idea_draft"),
2142
+ }
2143
+ elif kind == "runs":
2144
+ branch = str(payload.get("branch") or "").strip()
2145
+ run_id = str(payload.get("run_id") or "").strip()
2146
+ run_kind = str(payload.get("run_kind") or "").strip().lower()
2147
+ if not run_id or branch.startswith("analysis/") or branch.startswith("paper/") or run_kind.startswith("analysis"):
2148
+ continue
2149
+ current_rank = updated_rank(entry.get("latest_main_run_updated_at"))
2150
+ candidate_rank = updated_rank(payload.get("updated_at") or payload.get("created_at"))
2151
+ if candidate_rank >= current_rank:
2152
+ entry["latest_main_run_id"] = run_id
2153
+ entry["latest_main_run_branch"] = branch or entry.get("latest_main_run_branch")
2154
+ entry["latest_main_run_updated_at"] = candidate_rank
2155
+ entry["updated_at"] = max(updated_rank(entry.get("updated_at")), candidate_rank)
2156
+
2157
+ for line in paper_lines:
2158
+ idea_id = str(line.get("source_idea_id") or "").strip()
2159
+ if not idea_id:
2160
+ continue
2161
+ entry = ensure_line(idea_id)
2162
+ current_rank = updated_rank(entry.get("paper_line_updated_at"))
2163
+ candidate_rank = updated_rank(line.get("updated_at"))
2164
+ if candidate_rank >= current_rank:
2165
+ entry["paper_line_id"] = str(line.get("paper_line_id") or "").strip() or entry.get("paper_line_id")
2166
+ entry["paper_branch"] = str(line.get("paper_branch") or "").strip() or entry.get("paper_branch")
2167
+ entry["selected_outline_ref"] = str(line.get("selected_outline_ref") or "").strip() or entry.get("selected_outline_ref")
2168
+ entry["required_count"] = int(line.get("required_count") or 0)
2169
+ entry["ready_required_count"] = int(line.get("ready_required_count") or 0)
2170
+ entry["unmapped_count"] = int(line.get("unmapped_count") or 0)
2171
+ entry["open_supplementary_count"] = int(line.get("open_supplementary_count") or 0)
2172
+ entry["draft_status"] = str(line.get("draft_status") or "").strip() or None
2173
+ entry["bundle_status"] = str(line.get("bundle_status") or "").strip() or None
2174
+ entry["paper_line_updated_at"] = candidate_rank
2175
+ entry["updated_at"] = max(updated_rank(entry.get("updated_at")), candidate_rank)
2176
+ entry["paths"] = {
2177
+ **dict(entry.get("paths") or {}),
2178
+ "paper_line_state": str(((line.get("paths") or {}) if isinstance(line.get("paths"), dict) else {}).get("paper_line_state") or "").strip()
2179
+ or dict(entry.get("paths") or {}).get("paper_line_state"),
2180
+ }
2181
+
2182
+ campaigns = list((analysis_inventory or {}).get("campaigns") or []) if isinstance(analysis_inventory, dict) else []
2183
+ for campaign in campaigns:
2184
+ if not isinstance(campaign, dict):
2185
+ continue
2186
+ matched_idea_id = str(campaign.get("active_idea_id") or "").strip()
2187
+ if not matched_idea_id:
2188
+ matched_run_id = str(campaign.get("parent_run_id") or "").strip()
2189
+ matched_branch = str(campaign.get("parent_branch") or "").strip()
2190
+ for candidate in lines_by_id.values():
2191
+ if matched_run_id and matched_run_id == str(candidate.get("latest_main_run_id") or "").strip():
2192
+ matched_idea_id = str(candidate.get("idea_id") or "").strip()
2193
+ break
2194
+ if matched_branch and matched_branch in {
2195
+ str(candidate.get("idea_branch") or "").strip(),
2196
+ str(candidate.get("latest_main_run_branch") or "").strip(),
2197
+ }:
2198
+ matched_idea_id = str(candidate.get("idea_id") or "").strip()
2199
+ break
2200
+ if not matched_idea_id:
2201
+ continue
2202
+ entry = ensure_line(matched_idea_id)
2203
+ entry["analysis_campaign_count"] = int(entry.get("analysis_campaign_count") or 0) + 1
2204
+ entry["analysis_slice_count"] = int(entry.get("analysis_slice_count") or 0) + int(campaign.get("slice_count") or 0)
2205
+ entry["completed_analysis_slice_count"] = int(entry.get("completed_analysis_slice_count") or 0) + int(
2206
+ campaign.get("completed_slice_count") or 0
2207
+ )
2208
+ entry["mapped_analysis_slice_count"] = int(entry.get("mapped_analysis_slice_count") or 0) + int(
2209
+ campaign.get("mapped_slice_count") or 0
2210
+ )
2211
+ if not entry.get("paper_line_id") and str(campaign.get("paper_line_id") or "").strip():
2212
+ entry["paper_line_id"] = str(campaign.get("paper_line_id") or "").strip()
2213
+ entry["paper_branch"] = str(campaign.get("paper_line_branch") or "").strip() or entry.get("paper_branch")
2214
+ entry["selected_outline_ref"] = str(campaign.get("selected_outline_ref") or "").strip() or entry.get("selected_outline_ref")
2215
+ entry["updated_at"] = max(
2216
+ updated_rank(entry.get("updated_at")),
2217
+ updated_rank(campaign.get("updated_at")),
2218
+ )
2219
+
2220
+ lines = sorted(
2221
+ lines_by_id.values(),
519
2222
  key=lambda item: (
520
- str(item.get("attached_at") or ""),
521
- str(item.get("source_baseline_id") or ""),
2223
+ 0 if str(item.get("idea_id") or "").strip() == active_idea_id else 1,
2224
+ str(item.get("updated_at") or ""),
2225
+ str(item.get("idea_line_id") or ""),
2226
+ ),
2227
+ )
2228
+ for item in lines:
2229
+ if not item.get("open_supplementary_count"):
2230
+ pending = max(
2231
+ 0,
2232
+ int(item.get("analysis_slice_count") or 0) - int(item.get("completed_analysis_slice_count") or 0),
2233
+ )
2234
+ item["open_supplementary_count"] = pending
2235
+ item.pop("latest_main_run_updated_at", None)
2236
+ item.pop("paper_line_updated_at", None)
2237
+ if active_idea_id and active_idea_id in lines_by_id:
2238
+ active_ref = active_idea_id
2239
+ elif lines:
2240
+ active_ref = str(lines[0].get("idea_line_id") or "").strip() or None
2241
+ return lines, active_ref
2242
+
2243
+ def _paper_contract_health_payload(
2244
+ self,
2245
+ *,
2246
+ paper_contract: dict[str, Any] | None,
2247
+ paper_evidence: dict[str, Any] | None,
2248
+ analysis_inventory: dict[str, Any] | None,
2249
+ paper_lines: list[dict[str, Any]],
2250
+ active_paper_line_ref: str | None,
2251
+ ) -> dict[str, Any] | None:
2252
+ if not isinstance(paper_contract, dict) or not paper_contract:
2253
+ return None
2254
+ evidence_items = [
2255
+ dict(item) for item in ((paper_evidence or {}).get("items") or []) if isinstance(item, dict)
2256
+ ]
2257
+ ledger_by_item = {
2258
+ str(item.get("item_id") or "").strip(): item
2259
+ for item in evidence_items
2260
+ if str(item.get("item_id") or "").strip()
2261
+ }
2262
+ unresolved_required_items: list[dict[str, Any]] = []
2263
+ ready_section_count = 0
2264
+ for section in paper_contract.get("sections") or []:
2265
+ if not isinstance(section, dict):
2266
+ continue
2267
+ required_items = [str(item).strip() for item in (section.get("required_items") or []) if str(item).strip()]
2268
+ section_ready = True
2269
+ for item_id in required_items:
2270
+ ledger_item = ledger_by_item.get(item_id)
2271
+ status = str((ledger_item or {}).get("status") or "").strip().lower()
2272
+ if status not in {"ready", "completed", "analyzed", "written", "recorded", "supported"}:
2273
+ unresolved_required_items.append(
2274
+ {
2275
+ "section_id": str(section.get("section_id") or "").strip() or None,
2276
+ "section_title": str(section.get("title") or "").strip() or None,
2277
+ "item_id": item_id,
2278
+ "status": str((ledger_item or {}).get("status") or "").strip() or None,
2279
+ }
2280
+ )
2281
+ section_ready = False
2282
+ if required_items and section_ready:
2283
+ ready_section_count += 1
2284
+
2285
+ selected_outline_ref = str(paper_contract.get("selected_outline_ref") or "").strip() or None
2286
+ active_line = next(
2287
+ (
2288
+ dict(item)
2289
+ for item in paper_lines
2290
+ if isinstance(item, dict)
2291
+ and str(item.get("paper_line_id") or "").strip()
2292
+ and str(item.get("paper_line_id") or "").strip() == str(active_paper_line_ref or "").strip()
522
2293
  ),
2294
+ dict(paper_lines[0]) if paper_lines else {},
2295
+ )
2296
+ active_line_id = str(active_line.get("paper_line_id") or "").strip() or None
2297
+ active_line_branch = str(active_line.get("paper_branch") or "").strip() or None
2298
+
2299
+ campaigns = [dict(item) for item in ((analysis_inventory or {}).get("campaigns") or []) if isinstance(item, dict)]
2300
+ relevant_campaigns: list[dict[str, Any]] = []
2301
+ for campaign in campaigns:
2302
+ campaign_outline = str(campaign.get("selected_outline_ref") or "").strip() or None
2303
+ campaign_line_id = str(campaign.get("paper_line_id") or "").strip() or None
2304
+ campaign_line_branch = str(campaign.get("paper_line_branch") or "").strip() or None
2305
+ if active_line_id and campaign_line_id == active_line_id:
2306
+ relevant_campaigns.append(campaign)
2307
+ continue
2308
+ if active_line_branch and campaign_line_branch == active_line_branch:
2309
+ relevant_campaigns.append(campaign)
2310
+ continue
2311
+ if selected_outline_ref and campaign_outline == selected_outline_ref:
2312
+ relevant_campaigns.append(campaign)
2313
+
2314
+ unmapped_completed_items: list[dict[str, Any]] = []
2315
+ blocking_pending_slices: list[dict[str, Any]] = []
2316
+ for campaign in relevant_campaigns:
2317
+ for slice_item in campaign.get("slices") or []:
2318
+ if not isinstance(slice_item, dict):
2319
+ continue
2320
+ status = str(slice_item.get("status") or "").strip().lower()
2321
+ if status == "completed" and not bool(slice_item.get("mapped")):
2322
+ unmapped_completed_items.append(
2323
+ {
2324
+ "campaign_id": str(campaign.get("campaign_id") or "").strip() or None,
2325
+ "slice_id": str(slice_item.get("slice_id") or "").strip() or None,
2326
+ "item_id": str(slice_item.get("item_id") or "").strip() or None,
2327
+ "section_id": str(slice_item.get("section_id") or "").strip() or None,
2328
+ "title": str(slice_item.get("title") or "").strip() or None,
2329
+ }
2330
+ )
2331
+ if status in {"", "pending"}:
2332
+ paper_role = str(slice_item.get("paper_role") or "").strip().lower()
2333
+ tier = str(slice_item.get("tier") or "").strip().lower()
2334
+ if paper_role == "main_text" or tier == "main_required":
2335
+ blocking_pending_slices.append(
2336
+ {
2337
+ "campaign_id": str(campaign.get("campaign_id") or "").strip() or None,
2338
+ "slice_id": str(slice_item.get("slice_id") or "").strip() or None,
2339
+ "item_id": str(slice_item.get("item_id") or "").strip() or None,
2340
+ "section_id": str(slice_item.get("section_id") or "").strip() or None,
2341
+ "title": str(slice_item.get("title") or "").strip() or None,
2342
+ }
2343
+ )
2344
+
2345
+ contract_ok = not unresolved_required_items and not unmapped_completed_items
2346
+ writing_ready = contract_ok and not blocking_pending_slices
2347
+ draft_path = str((paper_contract.get("paths") or {}).get("draft") or "").strip()
2348
+ draft_status = str(active_line.get("draft_status") or "").strip() or ("present" if draft_path else "missing")
2349
+ bundle_status = str(active_line.get("bundle_status") or "").strip() or (
2350
+ "present" if str((paper_contract.get("paths") or {}).get("bundle_manifest") or "").strip() else "missing"
523
2351
  )
2352
+ bundle_manifest = (
2353
+ dict(paper_contract.get("bundle_manifest") or {})
2354
+ if isinstance(paper_contract.get("bundle_manifest"), dict)
2355
+ else {}
2356
+ )
2357
+ submission_checklist_path = str(((paper_contract.get("paths") or {}).get("submission_checklist") or "")).strip()
2358
+ submission_checklist = read_json(Path(submission_checklist_path), {}) if submission_checklist_path else {}
2359
+ submission_checklist = submission_checklist if isinstance(submission_checklist, dict) else {}
2360
+ overall_status = str(submission_checklist.get("overall_status") or bundle_manifest.get("status") or "").strip().lower()
2361
+ delivered_at = str(
2362
+ bundle_manifest.get("paper_delivered_to_user_at")
2363
+ or bundle_manifest.get("delivered_at")
2364
+ or submission_checklist.get("paper_delivered_to_user_at")
2365
+ or ""
2366
+ ).strip() or None
2367
+ closure_state = "bundle_not_ready"
2368
+ delivery_state = "not_ready"
2369
+ keep_bundle_fixed_by_default = False
2370
+ if bundle_status == "present":
2371
+ closure_state = "delivery_ready"
2372
+ delivery_state = "bundle_ready"
2373
+ if delivered_at or "delivered" in overall_status:
2374
+ delivery_state = "delivered"
2375
+ closure_state = "delivered_continue_research" if "continue" in overall_status else "delivered_parked"
2376
+ keep_bundle_fixed_by_default = True
2377
+
2378
+ if unmapped_completed_items:
2379
+ recommended_next_stage = "write"
2380
+ recommended_action = "sync_paper_contract"
2381
+ elif unresolved_required_items or blocking_pending_slices:
2382
+ recommended_next_stage = "analysis-campaign"
2383
+ recommended_action = "complete_required_supplementary"
2384
+ elif draft_status != "present":
2385
+ recommended_next_stage = "write"
2386
+ recommended_action = "draft_paper"
2387
+ elif bundle_status != "present":
2388
+ recommended_next_stage = "write"
2389
+ recommended_action = "prepare_bundle"
2390
+ else:
2391
+ recommended_next_stage = "finalize"
2392
+ recommended_action = "finalize_paper_line"
2393
+
2394
+ blocking_reasons: list[str] = []
2395
+ if unmapped_completed_items:
2396
+ blocking_reasons.append("completed analysis remains unmapped into the paper contract")
2397
+ if unresolved_required_items:
2398
+ blocking_reasons.append("required outline items are still unresolved")
2399
+ if blocking_pending_slices:
2400
+ blocking_reasons.append("main-text supplementary slices are still pending")
2401
+
2402
+ return {
2403
+ "paper_line_id": active_line_id,
2404
+ "paper_branch": active_line_branch,
2405
+ "selected_outline_ref": selected_outline_ref,
2406
+ "contract_ok": contract_ok,
2407
+ "writing_ready": writing_ready,
2408
+ "finalize_ready": writing_ready and bundle_status == "present",
2409
+ "closure_state": closure_state,
2410
+ "delivery_state": delivery_state,
2411
+ "delivered_at": delivered_at,
2412
+ "keep_bundle_fixed_by_default": keep_bundle_fixed_by_default,
2413
+ "required_count": sum(
2414
+ len(section.get("required_items") or [])
2415
+ for section in (paper_contract.get("sections") or [])
2416
+ if isinstance(section, dict)
2417
+ ),
2418
+ "ready_required_count": max(
2419
+ 0,
2420
+ sum(
2421
+ len(section.get("required_items") or [])
2422
+ for section in (paper_contract.get("sections") or [])
2423
+ if isinstance(section, dict)
2424
+ )
2425
+ - len(unresolved_required_items),
2426
+ ),
2427
+ "section_count": len([section for section in (paper_contract.get("sections") or []) if isinstance(section, dict)]),
2428
+ "ready_section_count": ready_section_count,
2429
+ "ledger_item_count": len(evidence_items),
2430
+ "unresolved_required_count": len(unresolved_required_items),
2431
+ "unmapped_completed_count": len(unmapped_completed_items),
2432
+ "open_supplementary_count": int(active_line.get("open_supplementary_count") or 0),
2433
+ "blocking_open_supplementary_count": len(blocking_pending_slices),
2434
+ "draft_status": draft_status,
2435
+ "bundle_status": bundle_status,
2436
+ "blocking_reasons": blocking_reasons,
2437
+ "recommended_next_stage": recommended_next_stage,
2438
+ "recommended_action": recommended_action,
2439
+ "unresolved_required_items": unresolved_required_items[:12],
2440
+ "unmapped_completed_items": unmapped_completed_items[:12],
2441
+ "blocking_pending_slices": blocking_pending_slices[:12],
2442
+ }
524
2443
 
525
2444
  @staticmethod
526
2445
  def _latest_metric_from_payload(payload: dict[str, Any]) -> dict[str, Any] | None:
@@ -551,14 +2470,8 @@ class QuestService:
551
2470
  def _quest_id_state_lock(self):
552
2471
  lock_path = self._quest_id_lock_path()
553
2472
  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)
2473
+ with advisory_file_lock(lock_path):
2474
+ yield
562
2475
 
563
2476
  @contextmanager
564
2477
  def _runtime_state_lock(self, quest_root: Path):
@@ -568,14 +2481,8 @@ class QuestService:
568
2481
  with thread_lock:
569
2482
  lock_path = self._runtime_state_lock_path(quest_root)
570
2483
  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)
2484
+ with advisory_file_lock(lock_path):
2485
+ yield
579
2486
 
580
2487
  def _scan_next_numeric_quest_id(self) -> int:
581
2488
  max_numeric_id = 0
@@ -878,9 +2785,26 @@ class QuestService:
878
2785
  "requested_baseline_ref": quest_yaml.get("requested_baseline_ref"),
879
2786
  "startup_contract": quest_yaml.get("startup_contract"),
880
2787
  "runner": quest_yaml.get("default_runner", "codex"),
2788
+ "active_workspace_root": str(workspace_root),
2789
+ "research_head_branch": research_state.get("research_head_branch"),
2790
+ "research_head_worktree_root": research_state.get("research_head_worktree_root"),
2791
+ "current_workspace_branch": research_state.get("current_workspace_branch"),
2792
+ "current_workspace_root": research_state.get("current_workspace_root"),
2793
+ "active_idea_id": research_state.get("active_idea_id"),
881
2794
  "active_baseline_id": active_baseline_id,
882
2795
  "active_baseline_variant_id": active_baseline_variant_id,
883
2796
  "active_run_id": runtime_state.get("active_run_id"),
2797
+ "continuation_policy": runtime_state.get("continuation_policy") or "auto",
2798
+ "continuation_anchor": runtime_state.get("continuation_anchor"),
2799
+ "continuation_reason": runtime_state.get("continuation_reason"),
2800
+ "continuation_updated_at": runtime_state.get("continuation_updated_at"),
2801
+ "last_resume_source": runtime_state.get("last_resume_source"),
2802
+ "last_resume_at": runtime_state.get("last_resume_at"),
2803
+ "last_recovery_abandoned_run_id": runtime_state.get("last_recovery_abandoned_run_id"),
2804
+ "last_recovery_summary": runtime_state.get("last_recovery_summary"),
2805
+ "last_stage_fingerprint": runtime_state.get("last_stage_fingerprint"),
2806
+ "last_stage_fingerprint_at": runtime_state.get("last_stage_fingerprint_at"),
2807
+ "same_fingerprint_auto_turn_count": int(runtime_state.get("same_fingerprint_auto_turn_count") or 0),
884
2808
  "pending_decisions": pending_decisions,
885
2809
  "waiting_interaction_id": waiting_interaction_id,
886
2810
  "default_reply_interaction_id": default_reply_interaction_id,
@@ -1089,7 +3013,7 @@ class QuestService:
1089
3013
  return entries
1090
3014
 
1091
3015
  def snapshot_fast(self, quest_id: str) -> dict:
1092
- return self._snapshot(quest_id)
3016
+ return self.summary_compact(quest_id)
1093
3017
 
1094
3018
  def snapshot(self, quest_id: str) -> dict:
1095
3019
  return self._snapshot(quest_id)
@@ -1206,6 +3130,22 @@ class QuestService:
1206
3130
  bash_service = BashExecService(self.home)
1207
3131
  bash_summary = bash_service.summary(quest_root)
1208
3132
  latest_bash_session = bash_summary.get("latest_session")
3133
+ paper_contract = self._paper_contract_payload(quest_root, workspace_root)
3134
+ paper_evidence = self._paper_evidence_payload(quest_root, workspace_root)
3135
+ analysis_inventory = self._analysis_inventory_payload(quest_root, workspace_root)
3136
+ paper_lines, active_paper_line_ref = self._paper_lines_payload(quest_root, workspace_root)
3137
+ idea_lines, active_idea_line_ref = self._idea_lines_payload(
3138
+ quest_root,
3139
+ paper_lines=paper_lines,
3140
+ analysis_inventory=analysis_inventory,
3141
+ )
3142
+ paper_contract_health = self._paper_contract_health_payload(
3143
+ paper_contract=paper_contract,
3144
+ paper_evidence=paper_evidence,
3145
+ analysis_inventory=analysis_inventory,
3146
+ paper_lines=paper_lines,
3147
+ active_paper_line_ref=active_paper_line_ref,
3148
+ )
1209
3149
  paths = {
1210
3150
  "brief": str(workspace_root / "brief.md"),
1211
3151
  "plan": str(workspace_root / "plan.md"),
@@ -1277,11 +3217,27 @@ class QuestService:
1277
3217
  "paper_parent_branch": research_state.get("paper_parent_branch"),
1278
3218
  "paper_parent_worktree_root": research_state.get("paper_parent_worktree_root"),
1279
3219
  "paper_parent_run_id": research_state.get("paper_parent_run_id"),
3220
+ "idea_lines": idea_lines,
3221
+ "active_idea_line_ref": active_idea_line_ref,
3222
+ "paper_lines": paper_lines,
3223
+ "active_paper_line_ref": active_paper_line_ref,
3224
+ "paper_contract_health": paper_contract_health,
1280
3225
  "next_pending_slice_id": research_state.get("next_pending_slice_id"),
1281
3226
  "workspace_mode": research_state.get("workspace_mode") or "quest",
1282
3227
  "active_baseline_id": active_baseline_id,
1283
3228
  "active_baseline_variant_id": active_baseline_variant_id,
1284
3229
  "active_run_id": runtime_state.get("active_run_id"),
3230
+ "continuation_policy": runtime_state.get("continuation_policy") or "auto",
3231
+ "continuation_anchor": runtime_state.get("continuation_anchor"),
3232
+ "continuation_reason": runtime_state.get("continuation_reason"),
3233
+ "continuation_updated_at": runtime_state.get("continuation_updated_at"),
3234
+ "last_resume_source": runtime_state.get("last_resume_source"),
3235
+ "last_resume_at": runtime_state.get("last_resume_at"),
3236
+ "last_recovery_abandoned_run_id": runtime_state.get("last_recovery_abandoned_run_id"),
3237
+ "last_recovery_summary": runtime_state.get("last_recovery_summary"),
3238
+ "last_stage_fingerprint": runtime_state.get("last_stage_fingerprint"),
3239
+ "last_stage_fingerprint_at": runtime_state.get("last_stage_fingerprint_at"),
3240
+ "same_fingerprint_auto_turn_count": int(runtime_state.get("same_fingerprint_auto_turn_count") or 0),
1285
3241
  "pending_decisions": pending_decisions,
1286
3242
  "active_interactions": active_interactions,
1287
3243
  "recent_reply_threads": recent_reply_threads,
@@ -1320,6 +3276,9 @@ class QuestService:
1320
3276
  "artifact_count": len(artifacts),
1321
3277
  "recent_artifacts": artifacts[-5:],
1322
3278
  "recent_runs": recent_runs[-5:],
3279
+ "paper_contract": paper_contract,
3280
+ "paper_evidence": paper_evidence,
3281
+ "analysis_inventory": analysis_inventory,
1323
3282
  "guidance": guidance,
1324
3283
  }
1325
3284
  with self._snapshot_cache_lock:
@@ -1860,97 +3819,7 @@ class QuestService:
1860
3819
  return self._read_cached_jsonl(self._quest_root(quest_id) / ".ds" / "conversations" / "main.jsonl")[-limit:]
1861
3820
 
1862
3821
  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
- }
3822
+ return self._projected_payload(quest_id, "details")
1954
3823
 
1955
3824
  def events(
1956
3825
  self,
@@ -2077,23 +3946,129 @@ class QuestService:
2077
3946
  def metrics_timeline(self, quest_id: str) -> dict:
2078
3947
  quest_root = self._quest_root(quest_id)
2079
3948
  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
- )
3949
+ state = self._json_compatible_state(self._metrics_timeline_state(quest_root, workspace_root))
3950
+ cache_path = self._metrics_timeline_cache_path(quest_root)
3951
+ cache_schema_version = 2
3952
+ cached = self._read_cached_json(cache_path, {})
3953
+ if (
3954
+ isinstance(cached, dict)
3955
+ and int(cached.get("schema_version") or 0) == cache_schema_version
3956
+ and self._json_compatible_state(cached.get("state")) == state
3957
+ and isinstance(cached.get("payload"), dict)
3958
+ ):
3959
+ return dict(cached.get("payload") or {})
3960
+
3961
+ with advisory_file_lock(self._metrics_timeline_cache_lock_path(quest_root)):
3962
+ cached = read_json(cache_path, {})
3963
+ if (
3964
+ isinstance(cached, dict)
3965
+ and int(cached.get("schema_version") or 0) == cache_schema_version
3966
+ and self._json_compatible_state(cached.get("state")) == state
3967
+ and isinstance(cached.get("payload"), dict)
3968
+ ):
3969
+ return dict(cached.get("payload") or {})
3970
+
3971
+ attachment = self._active_baseline_attachment(quest_root, workspace_root)
3972
+ baseline_entry = dict(attachment.get("entry") or {}) if isinstance(attachment, dict) else None
3973
+ selected_variant_id = (
3974
+ str(attachment.get("source_variant_id") or "").strip() or None if isinstance(attachment, dict) else None
3975
+ )
3976
+ if not baseline_entry:
3977
+ latest_baseline_payload = None
3978
+ for item in reversed(self._collect_artifacts_raw(quest_root)):
3979
+ if str(item.get("kind") or "").strip() != "baselines":
3980
+ continue
3981
+ payload = item.get("payload") or {}
3982
+ if not isinstance(payload, dict):
3983
+ continue
3984
+ if str(payload.get("status") or "").strip().lower() != "confirmed":
3985
+ continue
3986
+ latest_baseline_payload = payload
3987
+ break
3988
+ if isinstance(latest_baseline_payload, dict) and latest_baseline_payload:
3989
+ baseline_entry = dict(latest_baseline_payload)
3990
+ selected_variant_id = (
3991
+ str(latest_baseline_payload.get("baseline_variant_id") or "").strip() or None
3992
+ )
3993
+ run_records = [
3994
+ item.get("payload") or {}
3995
+ for item in self._collect_run_artifacts_raw(quest_root, run_kind="main_experiment")
3996
+ if isinstance(item.get("payload"), dict)
3997
+ ]
3998
+ payload = build_metrics_timeline(
3999
+ quest_id=quest_id,
4000
+ run_records=run_records,
4001
+ baseline_entry=baseline_entry,
4002
+ selected_variant_id=selected_variant_id,
4003
+ )
4004
+ write_json(
4005
+ cache_path,
4006
+ {
4007
+ "schema_version": cache_schema_version,
4008
+ "generated_at": utc_now(),
4009
+ "state": state,
4010
+ "payload": payload,
4011
+ },
4012
+ )
4013
+ return payload
4014
+
4015
+ def baseline_compare(self, quest_id: str) -> dict:
4016
+ quest_root = self._quest_root(quest_id)
4017
+ workspace_root = self.active_workspace_root(quest_root)
4018
+ state = self._json_compatible_state(self._baseline_compare_state(quest_root, workspace_root))
4019
+ cache_path = self._baseline_compare_cache_path(quest_root)
4020
+ cache_schema_version = 1
4021
+ cached = self._read_cached_json(cache_path, {})
4022
+ if (
4023
+ isinstance(cached, dict)
4024
+ and int(cached.get("schema_version") or 0) == cache_schema_version
4025
+ and self._json_compatible_state(cached.get("state")) == state
4026
+ and isinstance(cached.get("payload"), dict)
4027
+ ):
4028
+ return dict(cached.get("payload") or {})
4029
+
4030
+ with advisory_file_lock(self._baseline_compare_cache_lock_path(quest_root)):
4031
+ cached = read_json(cache_path, {})
4032
+ if (
4033
+ isinstance(cached, dict)
4034
+ and int(cached.get("schema_version") or 0) == cache_schema_version
4035
+ and self._json_compatible_state(cached.get("state")) == state
4036
+ and isinstance(cached.get("payload"), dict)
4037
+ ):
4038
+ return dict(cached.get("payload") or {})
4039
+
4040
+ quest_data = self.read_quest_yaml(quest_root)
4041
+ confirmed_ref = (
4042
+ dict(quest_data.get("confirmed_baseline_ref") or {})
4043
+ if isinstance(quest_data.get("confirmed_baseline_ref"), dict)
4044
+ else {}
4045
+ )
4046
+ attachment = self._active_baseline_attachment(quest_root, workspace_root)
4047
+ active_baseline_id = (
4048
+ str(confirmed_ref.get("baseline_id") or "").strip()
4049
+ or (str(attachment.get("source_baseline_id") or "").strip() if isinstance(attachment, dict) else "")
4050
+ or None
4051
+ )
4052
+ active_variant_id = (
4053
+ str(confirmed_ref.get("variant_id") or "").strip()
4054
+ or (str(attachment.get("source_variant_id") or "").strip() if isinstance(attachment, dict) else "")
4055
+ or None
4056
+ )
4057
+ payload = build_baseline_compare_payload(
4058
+ quest_id=quest_id,
4059
+ baseline_entries=self._baseline_compare_entries(quest_root, workspace_root),
4060
+ active_baseline_id=active_baseline_id,
4061
+ active_variant_id=active_variant_id,
4062
+ )
4063
+ write_json(
4064
+ cache_path,
4065
+ {
4066
+ "schema_version": cache_schema_version,
4067
+ "state": state,
4068
+ "payload": payload,
4069
+ },
4070
+ )
4071
+ return payload
2097
4072
 
2098
4073
  def list_documents(self, quest_id: str) -> list[dict]:
2099
4074
  quest_root = self._quest_root(quest_id)
@@ -2689,6 +4664,17 @@ class QuestService:
2689
4664
  "last_tool_activity_at": None,
2690
4665
  "last_tool_activity_name": None,
2691
4666
  "tool_calls_since_last_artifact_interact": 0,
4667
+ "continuation_policy": "auto",
4668
+ "continuation_anchor": None,
4669
+ "continuation_reason": None,
4670
+ "continuation_updated_at": None,
4671
+ "last_resume_source": None,
4672
+ "last_resume_at": None,
4673
+ "last_recovery_abandoned_run_id": None,
4674
+ "last_recovery_summary": None,
4675
+ "last_stage_fingerprint": None,
4676
+ "last_stage_fingerprint_at": None,
4677
+ "same_fingerprint_auto_turn_count": 0,
2692
4678
  "pending_user_message_count": pending_count,
2693
4679
  "last_delivered_batch_id": None,
2694
4680
  "last_delivered_at": None,
@@ -2754,6 +4740,20 @@ class QuestService:
2754
4740
  merged = {**defaults, **payload}
2755
4741
  merged["pending_user_message_count"] = int(merged.get("pending_user_message_count") or 0)
2756
4742
  merged["tool_calls_since_last_artifact_interact"] = int(merged.get("tool_calls_since_last_artifact_interact") or 0)
4743
+ merged["continuation_policy"] = self._normalize_continuation_policy(
4744
+ merged.get("continuation_policy"),
4745
+ default=str(defaults.get("continuation_policy") or "auto"),
4746
+ )
4747
+ merged["continuation_anchor"] = str(merged.get("continuation_anchor") or "").strip() or None
4748
+ merged["continuation_reason"] = str(merged.get("continuation_reason") or "").strip() or None
4749
+ merged["continuation_updated_at"] = str(merged.get("continuation_updated_at") or "").strip() or None
4750
+ merged["last_resume_source"] = str(merged.get("last_resume_source") or "").strip() or None
4751
+ merged["last_resume_at"] = str(merged.get("last_resume_at") or "").strip() or None
4752
+ merged["last_recovery_abandoned_run_id"] = str(merged.get("last_recovery_abandoned_run_id") or "").strip() or None
4753
+ merged["last_recovery_summary"] = str(merged.get("last_recovery_summary") or "").strip() or None
4754
+ merged["last_stage_fingerprint"] = str(merged.get("last_stage_fingerprint") or "").strip() or None
4755
+ merged["last_stage_fingerprint_at"] = str(merged.get("last_stage_fingerprint_at") or "").strip() or None
4756
+ merged["same_fingerprint_auto_turn_count"] = int(merged.get("same_fingerprint_auto_turn_count") or 0)
2757
4757
  merged["retry_state"] = dict(merged.get("retry_state") or {}) if isinstance(merged.get("retry_state"), dict) else None
2758
4758
  return merged
2759
4759
 
@@ -2773,6 +4773,17 @@ class QuestService:
2773
4773
  last_tool_activity_at: str | None | object = _UNSET,
2774
4774
  last_tool_activity_name: str | None | object = _UNSET,
2775
4775
  tool_calls_since_last_artifact_interact: int | object = _UNSET,
4776
+ continuation_policy: str | object = _UNSET,
4777
+ continuation_anchor: str | None | object = _UNSET,
4778
+ continuation_reason: str | None | object = _UNSET,
4779
+ continuation_updated_at: str | None | object = _UNSET,
4780
+ last_resume_source: str | None | object = _UNSET,
4781
+ last_resume_at: str | None | object = _UNSET,
4782
+ last_recovery_abandoned_run_id: str | None | object = _UNSET,
4783
+ last_recovery_summary: str | None | object = _UNSET,
4784
+ last_stage_fingerprint: str | None | object = _UNSET,
4785
+ last_stage_fingerprint_at: str | None | object = _UNSET,
4786
+ same_fingerprint_auto_turn_count: int | object = _UNSET,
2776
4787
  pending_user_message_count: int | object = _UNSET,
2777
4788
  last_delivered_batch_id: str | None | object = _UNSET,
2778
4789
  last_delivered_at: str | None | object = _UNSET,
@@ -2810,6 +4821,43 @@ class QuestService:
2810
4821
  state["last_tool_activity_name"] = str(last_tool_activity_name).strip() if last_tool_activity_name else None
2811
4822
  if tool_calls_since_last_artifact_interact is not _UNSET:
2812
4823
  state["tool_calls_since_last_artifact_interact"] = max(0, int(tool_calls_since_last_artifact_interact))
4824
+ continuation_changed = False
4825
+ if continuation_policy is not _UNSET:
4826
+ state["continuation_policy"] = self._normalize_continuation_policy(continuation_policy)
4827
+ continuation_changed = True
4828
+ if continuation_anchor is not _UNSET:
4829
+ normalized_anchor = str(continuation_anchor or "").strip() or None
4830
+ if normalized_anchor is not None:
4831
+ from ..prompts.builder import STANDARD_SKILLS
4832
+
4833
+ if normalized_anchor not in STANDARD_SKILLS:
4834
+ allowed = ", ".join(STANDARD_SKILLS)
4835
+ raise ValueError(
4836
+ f"Unsupported continuation anchor `{normalized_anchor}`. Allowed values: {allowed}."
4837
+ )
4838
+ state["continuation_anchor"] = normalized_anchor
4839
+ continuation_changed = True
4840
+ if continuation_reason is not _UNSET:
4841
+ state["continuation_reason"] = str(continuation_reason or "").strip() or None
4842
+ continuation_changed = True
4843
+ if continuation_updated_at is not _UNSET:
4844
+ state["continuation_updated_at"] = str(continuation_updated_at or "").strip() or None
4845
+ elif continuation_changed:
4846
+ state["continuation_updated_at"] = now
4847
+ if last_resume_source is not _UNSET:
4848
+ state["last_resume_source"] = str(last_resume_source or "").strip() or None
4849
+ if last_resume_at is not _UNSET:
4850
+ state["last_resume_at"] = str(last_resume_at or "").strip() or None
4851
+ if last_recovery_abandoned_run_id is not _UNSET:
4852
+ state["last_recovery_abandoned_run_id"] = str(last_recovery_abandoned_run_id or "").strip() or None
4853
+ if last_recovery_summary is not _UNSET:
4854
+ state["last_recovery_summary"] = str(last_recovery_summary or "").strip() or None
4855
+ if last_stage_fingerprint is not _UNSET:
4856
+ state["last_stage_fingerprint"] = str(last_stage_fingerprint or "").strip() or None
4857
+ if last_stage_fingerprint_at is not _UNSET:
4858
+ state["last_stage_fingerprint_at"] = str(last_stage_fingerprint_at or "").strip() or None
4859
+ if same_fingerprint_auto_turn_count is not _UNSET:
4860
+ state["same_fingerprint_auto_turn_count"] = max(0, int(same_fingerprint_auto_turn_count or 0))
2813
4861
  if pending_user_message_count is not _UNSET:
2814
4862
  state["pending_user_message_count"] = max(0, int(pending_user_message_count))
2815
4863
  if last_delivered_batch_id is not _UNSET:
@@ -2836,8 +4884,29 @@ class QuestService:
2836
4884
  quest_data.pop("active_run_id", None)
2837
4885
  quest_data["updated_at"] = now
2838
4886
  write_yaml(quest_root / "quest.yaml", quest_data)
4887
+ self.schedule_projection_refresh(quest_root, kinds=("details",))
2839
4888
  return state
2840
4889
 
4890
+ @staticmethod
4891
+ def _normalize_continuation_policy(value: object, *, default: str = "auto") -> str:
4892
+ normalized = str(value or "").strip().lower() or default
4893
+ return normalized if normalized in CONTINUATION_POLICIES else default
4894
+
4895
+ def set_continuation_state(
4896
+ self,
4897
+ quest_root: Path,
4898
+ *,
4899
+ policy: str,
4900
+ anchor: str | None = None,
4901
+ reason: str | None = None,
4902
+ ) -> dict[str, Any]:
4903
+ return self.update_runtime_state(
4904
+ quest_root=quest_root,
4905
+ continuation_policy=policy,
4906
+ continuation_anchor=anchor,
4907
+ continuation_reason=reason,
4908
+ )
4909
+
2841
4910
  def _enqueue_user_message(self, quest_root: Path, record: dict[str, Any]) -> dict[str, Any]:
2842
4911
  queue_payload = self._read_message_queue(quest_root)
2843
4912
  source = str(record.get("source") or "local")
@@ -3055,11 +5124,14 @@ class QuestService:
3055
5124
  artifact_id: str | None,
3056
5125
  kind: str,
3057
5126
  message: str,
5127
+ summary_preview: str | None = None,
5128
+ dedupe_key: str | None = None,
3058
5129
  response_phase: str | None = None,
3059
5130
  reply_mode: str | None = None,
3060
5131
  surface_actions: list[dict[str, Any]] | None = None,
3061
5132
  connector_hints: dict[str, Any] | None = None,
3062
5133
  created_at: str | None = None,
5134
+ counts_as_visible: bool = True,
3063
5135
  ) -> dict[str, Any]:
3064
5136
  timestamp = created_at or utc_now()
3065
5137
  payload = {
@@ -3070,6 +5142,8 @@ class QuestService:
3070
5142
  "artifact_id": artifact_id,
3071
5143
  "kind": kind,
3072
5144
  "message": message,
5145
+ "summary_preview": str(summary_preview or "").strip() or None,
5146
+ "dedupe_key": str(dedupe_key or "").strip() or None,
3073
5147
  "response_phase": response_phase,
3074
5148
  "reply_mode": reply_mode,
3075
5149
  "surface_actions": [dict(item) for item in (surface_actions or []) if isinstance(item, dict)],
@@ -3077,15 +5151,17 @@ class QuestService:
3077
5151
  "created_at": timestamp,
3078
5152
  }
3079
5153
  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
- )
5154
+ runtime_updates: dict[str, Any] = {
5155
+ "quest_root": quest_root,
5156
+ "active_interaction_id": interaction_id or artifact_id,
5157
+ "last_tool_activity_at": timestamp,
5158
+ "last_tool_activity_name": "artifact.interact",
5159
+ "tool_calls_since_last_artifact_interact": 0,
5160
+ "pending_user_message_count": len((self._read_message_queue(quest_root).get("pending") or [])),
5161
+ }
5162
+ if counts_as_visible:
5163
+ runtime_updates["last_artifact_interact_at"] = timestamp
5164
+ self.update_runtime_state(**runtime_updates)
3089
5165
  return payload
3090
5166
 
3091
5167
  def record_tool_activity(
@@ -3133,13 +5209,25 @@ class QuestService:
3133
5209
  runtime_state = self._read_runtime_state(quest_root)
3134
5210
  last_artifact_interact_at = str(runtime_state.get("last_artifact_interact_at") or "").strip() or None
3135
5211
  last_tool_activity_at = str(runtime_state.get("last_tool_activity_at") or "").strip() or None
5212
+ tool_count = int(runtime_state.get("tool_calls_since_last_artifact_interact") or 0)
5213
+ silence_seconds = self._seconds_since_iso_timestamp(last_artifact_interact_at)
5214
+ inspection_due = bool(
5215
+ tool_count >= 25
5216
+ or (
5217
+ tool_count > 0
5218
+ and silence_seconds is not None
5219
+ and silence_seconds >= 30 * 60
5220
+ )
5221
+ )
3136
5222
  return {
3137
5223
  "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),
5224
+ "seconds_since_last_artifact_interact": silence_seconds,
5225
+ "tool_calls_since_last_artifact_interact": tool_count,
3140
5226
  "last_tool_activity_at": last_tool_activity_at,
3141
5227
  "seconds_since_last_tool_activity": self._seconds_since_iso_timestamp(last_tool_activity_at),
3142
5228
  "last_tool_activity_name": str(runtime_state.get("last_tool_activity_name") or "").strip() or None,
5229
+ "inspection_due": inspection_due,
5230
+ "user_update_due": False,
3143
5231
  }
3144
5232
 
3145
5233
  def latest_artifact_interaction_records(self, quest_root: Path, limit: int = 10) -> list[dict[str, Any]]:
@@ -3935,9 +6023,11 @@ def _tool_output(event: dict, item: dict) -> str:
3935
6023
  item.get("result"),
3936
6024
  item.get("output"),
3937
6025
  item.get("content"),
6026
+ item.get("error"),
3938
6027
  event.get("result"),
3939
6028
  event.get("output"),
3940
6029
  event.get("content"),
6030
+ event.get("error"),
3941
6031
  item.get("aggregated_output"),
3942
6032
  event.get("aggregated_output"),
3943
6033
  ):
@@ -3951,11 +6041,13 @@ def _tool_output(event: dict, item: dict) -> str:
3951
6041
  item.get("output"),
3952
6042
  item.get("result"),
3953
6043
  item.get("content"),
6044
+ item.get("error"),
3954
6045
  event.get("aggregated_output"),
3955
6046
  event.get("changes"),
3956
6047
  event.get("output"),
3957
6048
  event.get("result"),
3958
6049
  event.get("content"),
6050
+ event.get("error"),
3959
6051
  ):
3960
6052
  text = _compact_text(value, limit=1200)
3961
6053
  if text: