@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
@@ -11,6 +11,7 @@ from typing import Any
11
11
  from urllib.error import URLError
12
12
  from urllib.request import Request, urlopen
13
13
 
14
+ from .bash_exec.shells import build_exec_shell_launch, build_terminal_shell_launch
14
15
  from .config import ConfigManager
15
16
  from .home import ensure_home_layout
16
17
  from .runtime_tools import RuntimeToolService
@@ -321,6 +322,55 @@ def _check_bundles(repo_root: Path) -> dict[str, Any]:
321
322
  )
322
323
 
323
324
 
325
+ def _check_shell_backend() -> dict[str, Any]:
326
+ exec_launch = build_exec_shell_launch("echo ok")
327
+ terminal_launch = build_terminal_shell_launch(Path("doctor-terminal-probe"))
328
+
329
+ def resolve_binary(binary: str) -> str | None:
330
+ candidate = str(binary or "").strip()
331
+ if not candidate:
332
+ return None
333
+ if os.path.isabs(candidate) or os.path.sep in candidate or (os.path.altsep and os.path.altsep in candidate):
334
+ return candidate if Path(candidate).exists() else None
335
+ return which(candidate)
336
+
337
+ details = {
338
+ "exec_shell": exec_launch.shell_name,
339
+ "exec_shell_family": exec_launch.family,
340
+ "exec_argv": exec_launch.argv,
341
+ "terminal_shell": terminal_launch.shell_name,
342
+ "terminal_shell_family": terminal_launch.family,
343
+ "terminal_argv": terminal_launch.argv,
344
+ }
345
+ warnings: list[str] = []
346
+ guidance: list[str] = []
347
+ errors: list[str] = []
348
+
349
+ exec_binary = resolve_binary(exec_launch.argv[0])
350
+ terminal_binary = resolve_binary(terminal_launch.argv[0])
351
+ details["exec_resolved_binary"] = exec_binary
352
+ details["terminal_resolved_binary"] = terminal_binary
353
+
354
+ if sys.platform == "win32":
355
+ warnings.append("Native Windows support is currently experimental; WSL2 remains the most battle-tested path.")
356
+ if not exec_binary:
357
+ errors.append("DeepScientist could not resolve a Windows command shell for bash_exec.")
358
+ guidance.append("Install PowerShell (`pwsh`) or ensure `powershell.exe` is available on PATH.")
359
+ if not terminal_binary:
360
+ errors.append("DeepScientist could not resolve a Windows interactive shell backend.")
361
+ guidance.append("Ensure `powershell.exe` is available on PATH for the interactive terminal surface.")
362
+ return _make_check(
363
+ check_id="shell_backend",
364
+ label="Shell backend",
365
+ ok=len(errors) == 0,
366
+ summary="DeepScientist resolved platform shell backends for command and terminal sessions." if len(errors) == 0 else "DeepScientist could not resolve a required shell backend.",
367
+ warnings=warnings,
368
+ errors=errors,
369
+ guidance=guidance,
370
+ details=details,
371
+ )
372
+
373
+
324
374
  def _check_latex_runtime(home: Path) -> dict[str, Any]:
325
375
  runtime = RuntimeToolService(home).status("tinytex")
326
376
  pdflatex = runtime.get("binaries", {}).get("pdflatex") or {}
@@ -436,6 +486,7 @@ def run_doctor(home: Path, *, repo_root: Path) -> dict[str, Any]:
436
486
  _check_python_runtime(),
437
487
  _check_home_writable(home),
438
488
  _check_uv(home),
489
+ _check_shell_backend(),
439
490
  _check_git(config_manager),
440
491
  _check_config_validation(config_manager),
441
492
  _check_runner_support(config_manager),
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Iterator, TextIO
7
+
8
+ if os.name == "nt": # pragma: no cover - exercised on Windows
9
+ import msvcrt
10
+ else: # pragma: no cover - exercised on POSIX
11
+ import fcntl
12
+
13
+
14
+ def _ensure_lockable_file(handle: TextIO) -> None:
15
+ handle.seek(0, os.SEEK_END)
16
+ if handle.tell() > 0:
17
+ handle.seek(0)
18
+ return
19
+ handle.write("\0")
20
+ handle.flush()
21
+ handle.seek(0)
22
+
23
+
24
+ def _lock_handle(handle: TextIO) -> None:
25
+ if os.name == "nt": # pragma: no cover - exercised on Windows
26
+ _ensure_lockable_file(handle)
27
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
28
+ return
29
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
30
+
31
+
32
+ def _unlock_handle(handle: TextIO) -> None:
33
+ if os.name == "nt": # pragma: no cover - exercised on Windows
34
+ handle.seek(0)
35
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
36
+ return
37
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
38
+
39
+
40
+ @contextmanager
41
+ def advisory_file_lock(path: Path) -> Iterator[TextIO]:
42
+ path.parent.mkdir(parents=True, exist_ok=True)
43
+ with path.open("a+", encoding="utf-8") as handle:
44
+ _lock_handle(handle)
45
+ try:
46
+ yield handle
47
+ finally:
48
+ _unlock_handle(handle)
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
  from typing import Any
6
6
 
7
7
  from ..artifact.metrics import extract_latest_metric
8
- from ..shared import read_json, run_command
8
+ from ..shared import read_json, run_command, slugify
9
9
  from .service import branch_exists, current_branch, head_commit
10
10
 
11
11
 
@@ -68,6 +68,12 @@ def list_branch_canvas(repo: Path, *, quest_id: str) -> dict[str, Any]:
68
68
  "run_id": state.get("run_id"),
69
69
  "run_kind": state.get("run_kind"),
70
70
  "idea_id": state.get("idea_id"),
71
+ "paper_line_id": state.get("paper_line_id"),
72
+ "paper_line_branch": state.get("paper_line_branch"),
73
+ "selected_outline_ref": state.get("selected_outline_ref"),
74
+ "source_branch": state.get("source_branch"),
75
+ "source_run_id": state.get("source_run_id"),
76
+ "source_idea_id": state.get("source_idea_id"),
71
77
  "parent_branch_recorded": state.get("parent_branch"),
72
78
  "worktree_root": state.get("worktree_root"),
73
79
  "latest_metric": state.get("latest_metric"),
@@ -356,9 +362,158 @@ def _collect_branch_state(repo: Path) -> dict[str, dict[str, Any]]:
356
362
  for item in state.get("recent_artifacts", []):
357
363
  if isinstance(item, dict):
358
364
  item.pop("_sort_key", None)
365
+ for workspace_root in _canvas_workspace_roots(repo):
366
+ state_path = workspace_root / "paper" / "paper_line_state.json"
367
+ if not state_path.exists():
368
+ continue
369
+ payload = read_json(state_path, {})
370
+ if not isinstance(payload, dict) or not payload:
371
+ continue
372
+ paper_branch = str(payload.get("paper_branch") or "").strip() or current_branch(workspace_root)
373
+ if not paper_branch:
374
+ continue
375
+ state = branch_state[paper_branch]
376
+ state.setdefault("branch", paper_branch)
377
+ state["worktree_root"] = str(workspace_root)
378
+ state["paper_line_id"] = str(payload.get("paper_line_id") or "").strip() or state.get("paper_line_id")
379
+ state["paper_line_branch"] = paper_branch
380
+ state["selected_outline_ref"] = str(payload.get("selected_outline_ref") or "").strip() or state.get("selected_outline_ref")
381
+ state["source_branch"] = str(payload.get("source_branch") or "").strip() or state.get("source_branch")
382
+ state["source_run_id"] = str(payload.get("source_run_id") or "").strip() or state.get("source_run_id")
383
+ state["source_idea_id"] = str(payload.get("source_idea_id") or "").strip() or state.get("source_idea_id")
384
+ state["updated_at"] = str(payload.get("updated_at") or state.get("updated_at") or "")
385
+ if not state.get("parent_branch") and state.get("source_branch"):
386
+ state["parent_branch"] = state.get("source_branch")
387
+ for workspace_root in _canvas_workspace_roots(repo):
388
+ paper_root = workspace_root / "paper"
389
+ if not paper_root.exists():
390
+ continue
391
+ state_path = paper_root / "paper_line_state.json"
392
+ if state_path.exists():
393
+ continue
394
+ selected_outline = read_json(paper_root / "selected_outline.json", {})
395
+ bundle_manifest = read_json(paper_root / "paper_bundle_manifest.json", {})
396
+ selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
397
+ bundle_manifest = bundle_manifest if isinstance(bundle_manifest, dict) else {}
398
+ if not selected_outline and not bundle_manifest:
399
+ continue
400
+ paper_branch = str(bundle_manifest.get("paper_branch") or "").strip() or current_branch(workspace_root)
401
+ if not paper_branch:
402
+ continue
403
+ selected_outline_ref = str(
404
+ selected_outline.get("outline_id") or bundle_manifest.get("selected_outline_ref") or ""
405
+ ).strip() or None
406
+ source_run_id = str(bundle_manifest.get("source_run_id") or "").strip() or None
407
+ state = branch_state[paper_branch]
408
+ state.setdefault("branch", paper_branch)
409
+ state["worktree_root"] = str(workspace_root)
410
+ state["paper_line_id"] = state.get("paper_line_id") or slugify(
411
+ "::".join([paper_branch or "paper", selected_outline_ref or "outline", source_run_id or "run"]),
412
+ "paper-line",
413
+ )
414
+ state["paper_line_branch"] = paper_branch
415
+ state["selected_outline_ref"] = selected_outline_ref or state.get("selected_outline_ref")
416
+ state["source_branch"] = str(bundle_manifest.get("source_branch") or "").strip() or state.get("source_branch")
417
+ state["source_run_id"] = source_run_id or state.get("source_run_id")
418
+ state["source_idea_id"] = str(bundle_manifest.get("source_idea_id") or "").strip() or state.get("source_idea_id")
419
+ if not state.get("parent_branch") and state.get("source_branch"):
420
+ state["parent_branch"] = state.get("source_branch")
421
+ campaigns_root = repo / ".ds" / "analysis_campaigns"
422
+ if campaigns_root.exists():
423
+ for path in sorted(campaigns_root.glob("*.json")):
424
+ manifest = read_json(path, {})
425
+ if not isinstance(manifest, dict) or not manifest:
426
+ continue
427
+ campaign_id = str(manifest.get("campaign_id") or path.stem).strip() or path.stem
428
+ paper_line_id = str(manifest.get("paper_line_id") or "").strip() or None
429
+ paper_line_branch = str(manifest.get("paper_line_branch") or "").strip() or None
430
+ analysis_parent_branch = str(manifest.get("parent_branch") or "").strip() or None
431
+ selected_outline_ref = str(manifest.get("selected_outline_ref") or "").strip() or None
432
+ source_idea_id = str(manifest.get("active_idea_id") or "").strip() or None
433
+ if not paper_line_branch:
434
+ paper_line_branch = _infer_paper_line_branch_for_campaign(
435
+ manifest,
436
+ branch_state=branch_state,
437
+ )
438
+ for item in manifest.get("slices") or []:
439
+ if not isinstance(item, dict):
440
+ continue
441
+ branch_name = str(item.get("branch") or "").strip()
442
+ if not branch_name:
443
+ continue
444
+ state = branch_state[branch_name]
445
+ state.setdefault("branch", branch_name)
446
+ state["campaign_id"] = campaign_id
447
+ state["paper_line_id"] = paper_line_id or state.get("paper_line_id")
448
+ state["paper_line_branch"] = paper_line_branch or state.get("paper_line_branch")
449
+ state["analysis_parent_branch"] = analysis_parent_branch or state.get("analysis_parent_branch")
450
+ state["selected_outline_ref"] = selected_outline_ref or state.get("selected_outline_ref")
451
+ state["source_idea_id"] = source_idea_id or state.get("source_idea_id")
452
+ if item.get("worktree_root"):
453
+ state["worktree_root"] = item.get("worktree_root")
359
454
  return branch_state
360
455
 
361
456
 
457
+ def _canvas_workspace_roots(repo: Path) -> list[Path]:
458
+ roots: list[Path] = [repo]
459
+ research_state = read_json(repo / ".ds" / "research_state.json", {})
460
+ preferred_raw = str((research_state or {}).get("research_head_worktree_root") or "").strip()
461
+ if preferred_raw:
462
+ preferred = Path(preferred_raw)
463
+ if preferred.exists():
464
+ roots.append(preferred)
465
+ worktrees_root = repo / ".ds" / "worktrees"
466
+ if worktrees_root.exists():
467
+ roots.extend(path for path in sorted(worktrees_root.iterdir()) if path.is_dir())
468
+ deduped: list[Path] = []
469
+ seen: set[str] = set()
470
+ for root in roots:
471
+ key = str(root.resolve())
472
+ if key in seen:
473
+ continue
474
+ seen.add(key)
475
+ deduped.append(root)
476
+ return deduped
477
+
478
+
479
+ def _infer_paper_line_branch_for_campaign(
480
+ manifest: dict[str, Any],
481
+ *,
482
+ branch_state: dict[str, dict[str, Any]],
483
+ ) -> str | None:
484
+ selected_outline_ref = str(manifest.get("selected_outline_ref") or "").strip() or None
485
+ source_idea_id = str(manifest.get("active_idea_id") or "").strip() or None
486
+ source_run_id = str(manifest.get("parent_run_id") or "").strip() or None
487
+ source_branch = str(manifest.get("parent_branch") or "").strip() or None
488
+ ranked: list[tuple[int, str]] = []
489
+ for branch_name, state in branch_state.items():
490
+ if not branch_name.startswith("paper/"):
491
+ continue
492
+ score = 0
493
+ candidate_outline = str(state.get("selected_outline_ref") or "").strip() or None
494
+ candidate_idea = str(state.get("source_idea_id") or "").strip() or None
495
+ candidate_run = str(state.get("source_run_id") or "").strip() or None
496
+ candidate_branch = str(state.get("source_branch") or "").strip() or None
497
+ if selected_outline_ref:
498
+ if candidate_outline != selected_outline_ref:
499
+ continue
500
+ score += 2
501
+ if source_idea_id and candidate_idea == source_idea_id:
502
+ score += 4
503
+ if source_run_id and candidate_run == source_run_id:
504
+ score += 3
505
+ if source_branch and candidate_branch == source_branch:
506
+ score += 2
507
+ if score > 0:
508
+ ranked.append((score, branch_name))
509
+ if not ranked:
510
+ return None
511
+ ranked.sort(key=lambda item: (item[0], item[1]), reverse=True)
512
+ if len(ranked) > 1 and ranked[0][0] == ranked[1][0]:
513
+ return None
514
+ return ranked[0][1]
515
+
516
+
362
517
  def _resolve_run_result_payload(repo: Path, record: dict[str, Any]) -> dict[str, Any]:
363
518
  details = dict(record.get("details") or {}) if isinstance(record.get("details"), dict) else {}
364
519
  paths = dict(record.get("paths") or {}) if isinstance(record.get("paths"), dict) else {}
@@ -469,9 +624,17 @@ def _infer_parent_ref(
469
624
  ) -> str | None:
470
625
  if ref == default_ref:
471
626
  return None
627
+ if classifications[ref]["branch_kind"] == "analysis":
628
+ paper_line_branch = str(state.get("paper_line_branch") or "").strip()
629
+ if paper_line_branch and paper_line_branch in refs and paper_line_branch != ref:
630
+ return paper_line_branch
472
631
  parent_branch = str(state.get("parent_branch") or "").strip()
473
632
  if parent_branch and parent_branch in refs and parent_branch != ref:
474
633
  return parent_branch
634
+ if classifications[ref]["branch_kind"] == "paper":
635
+ source_branch = str(state.get("source_branch") or "").strip()
636
+ if source_branch and source_branch in refs and source_branch != ref:
637
+ return source_branch
475
638
  if ref.startswith("idea/"):
476
639
  return default_ref
477
640
  if state.get("idea_id"):
@@ -479,6 +642,9 @@ def _infer_parent_ref(
479
642
  if candidate in refs:
480
643
  return candidate
481
644
  if classifications[ref]["branch_kind"] == "analysis":
645
+ analysis_parent_branch = str(state.get("analysis_parent_branch") or "").strip()
646
+ if analysis_parent_branch and analysis_parent_branch in refs and analysis_parent_branch != ref:
647
+ return analysis_parent_branch
482
648
  major_refs = [
483
649
  candidate
484
650
  for candidate, meta in classifications.items()
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
  from typing import Any
7
7
 
8
8
  from mcp.server.fastmcp import FastMCP
9
+ from mcp.types import ToolAnnotations
9
10
 
10
11
  from ..artifact import ArtifactService
11
12
  from ..artifact.metrics import MetricContractValidationError
@@ -54,6 +55,16 @@ ARTIFACT_STATE_CHANGE_WATCHDOG_NOTES = {
54
55
  }
55
56
 
56
57
 
58
+ def _read_only_tool_annotations(*, title: str | None = None) -> ToolAnnotations:
59
+ return ToolAnnotations(
60
+ title=title,
61
+ readOnlyHint=True,
62
+ destructiveHint=False,
63
+ idempotentHint=True,
64
+ openWorldHint=False,
65
+ )
66
+
67
+
57
68
  def _metric_validation_error_payload(exc: MetricContractValidationError) -> dict[str, Any]:
58
69
  return exc.as_payload()
59
70
 
@@ -62,7 +73,7 @@ def _progress_watchdog_note(tool_call_count: int) -> str:
62
73
  return (
63
74
  "By the way, you have gone "
64
75
  f"{tool_call_count} tool calls without notifying the user via artifact.interact(...). "
65
- "Please report your latest progress now."
76
+ "Inspect whether the user-visible state actually changed; only send a progress update if there is a real new checkpoint, blocker, or route change."
66
77
  )
67
78
 
68
79
 
@@ -71,7 +82,7 @@ def _visibility_watchdog_note(seconds_since_last_update: int) -> str:
71
82
  return (
72
83
  "By the way, it has been "
73
84
  f"{minutes} minutes since the last user-visible artifact.interact(...). "
74
- "Send one concise progress update now before continuing with background work."
85
+ "Inspect the current run or task state now. Only send a new user-visible update if the frontier materially changed or the user explicitly needs a fresh checkpoint."
75
86
  )
76
87
 
77
88
 
@@ -114,11 +125,16 @@ def _attach_interaction_watchdog(
114
125
  state_change_note: str | None = None,
115
126
  ) -> dict[str, Any]:
116
127
  enriched = dict(payload)
117
- enriched["interaction_watchdog"] = dict(watchdog or {})
128
+ interaction_watchdog = dict(watchdog or {})
118
129
  notes = _collect_interaction_watchdog_notes(
119
130
  watchdog,
120
131
  state_change_note=state_change_note,
121
132
  )
133
+ interaction_watchdog["user_update_due"] = bool(
134
+ interaction_watchdog.get("user_update_due")
135
+ or any(str(item.get("kind") or "") == "state_change" for item in notes)
136
+ )
137
+ enriched["interaction_watchdog"] = interaction_watchdog
122
138
  if not notes:
123
139
  return enriched
124
140
  enriched["watchdog_notes"] = notes
@@ -377,6 +393,7 @@ def build_memory_server(context: McpContext) -> FastMCP:
377
393
  "Read a memory card by id or path. "
378
394
  "Use after list_recent or search surfaced a specific card worth reusing now."
379
395
  ),
396
+ annotations=_read_only_tool_annotations(title="Read memory card"),
380
397
  )
381
398
  def read(
382
399
  card_id: str | None = None,
@@ -394,6 +411,7 @@ def build_memory_server(context: McpContext) -> FastMCP:
394
411
  "Search memory cards by metadata or body text. "
395
412
  "Use before broad literature search, retries, route decisions, or repeated debugging."
396
413
  ),
414
+ annotations=_read_only_tool_annotations(title="Search memory cards"),
397
415
  )
398
416
  def search(
399
417
  query: str,
@@ -413,6 +431,7 @@ def build_memory_server(context: McpContext) -> FastMCP:
413
431
  "List the most recently updated memory cards. "
414
432
  "Use to recover quest context at turn start, after resume, or after a long pause."
415
433
  ),
434
+ annotations=_read_only_tool_annotations(title="List recent memory cards"),
416
435
  )
417
436
  def list_recent(
418
437
  scope: str = "quest",
@@ -557,19 +576,26 @@ def build_artifact_server(context: McpContext) -> FastMCP:
557
576
  name="submit_idea",
558
577
  description=(
559
578
  "Create or revise the active research idea. "
560
- "Normal research flow should use mode=create together with lineage_intent=continue_line or branch_alternative, so each durable idea submission becomes a new branch/worktree and a new user-visible research node. "
579
+ "Normal research flow should use mode=create together with submission_mode='line' and lineage_intent=continue_line or branch_alternative, so each durable idea submission becomes a new branch/worktree and a new user-visible research node. "
580
+ "submission_mode='candidate' records a candidate idea brief without opening a new branch yet. "
561
581
  "mode=revise is maintenance-only for refining the current active idea.md in place. "
562
582
  "When foundation_ref is omitted, lineage_intent infers the parent and default foundation from the active research line."
563
583
  ),
564
584
  )
565
585
  def submit_idea(
566
586
  mode: str = "create",
587
+ submission_mode: str = "line",
567
588
  idea_id: str | None = None,
568
589
  lineage_intent: str | None = None,
569
590
  title: str = "",
570
591
  problem: str = "",
571
592
  hypothesis: str = "",
572
593
  mechanism: str = "",
594
+ method_brief: str = "",
595
+ selection_scores: dict[str, Any] | None = None,
596
+ mechanism_family: str = "",
597
+ change_layer: str = "",
598
+ source_lens: str = "",
573
599
  expected_gain: str = "",
574
600
  evidence_paths: list[str] | None = None,
575
601
  risks: list[str] | None = None,
@@ -578,17 +604,24 @@ def build_artifact_server(context: McpContext) -> FastMCP:
578
604
  foundation_reason: str = "",
579
605
  next_target: str = "experiment",
580
606
  draft_markdown: str = "",
607
+ source_candidate_id: str | None = None,
581
608
  comment: str | dict[str, Any] | None = None,
582
609
  ) -> dict[str, Any]:
583
610
  return service.submit_idea(
584
611
  context.require_quest_root(),
585
612
  mode=mode,
613
+ submission_mode=submission_mode,
586
614
  idea_id=idea_id,
587
615
  lineage_intent=lineage_intent,
588
616
  title=title,
589
617
  problem=problem,
590
618
  hypothesis=hypothesis,
591
619
  mechanism=mechanism,
620
+ method_brief=method_brief,
621
+ selection_scores=selection_scores,
622
+ mechanism_family=mechanism_family,
623
+ change_layer=change_layer,
624
+ source_lens=source_lens,
592
625
  expected_gain=expected_gain,
593
626
  evidence_paths=evidence_paths,
594
627
  risks=risks,
@@ -597,6 +630,7 @@ def build_artifact_server(context: McpContext) -> FastMCP:
597
630
  foundation_reason=foundation_reason,
598
631
  next_target=next_target,
599
632
  draft_markdown=draft_markdown,
633
+ source_candidate_id=source_candidate_id,
600
634
  )
601
635
 
602
636
  @server.tool(
@@ -605,6 +639,7 @@ def build_artifact_server(context: McpContext) -> FastMCP:
605
639
  "List research branches with branch number, active idea, foundation info, and corresponding main-experiment results. "
606
640
  "Use before creating the next idea when you need to compare possible foundations."
607
641
  ),
642
+ annotations=_read_only_tool_annotations(title="List research branches"),
608
643
  )
609
644
  def list_research_branches(comment: str | dict[str, Any] | None = None) -> dict[str, Any]:
610
645
  return service.list_research_branches(context.require_quest_root())
@@ -615,16 +650,135 @@ def build_artifact_server(context: McpContext) -> FastMCP:
615
650
  "Resolve the current canonical research ids and refs. "
616
651
  "Use this before supplementary work when you need the active idea, latest main run, active campaign, outline, or reply-thread ids without guessing."
617
652
  ),
653
+ annotations=_read_only_tool_annotations(title="Resolve runtime refs"),
618
654
  )
619
655
  def resolve_runtime_refs(comment: str | dict[str, Any] | None = None) -> dict[str, Any]:
620
656
  return service.resolve_runtime_refs(context.require_quest_root())
621
657
 
658
+ @server.tool(
659
+ name="get_paper_contract_health",
660
+ description=(
661
+ "Inspect whether the active paper line is actually unblocked for writing or finalize work. "
662
+ "Use detail='summary' for a compact decision surface or detail='full' for exact blocking items."
663
+ ),
664
+ annotations=_read_only_tool_annotations(title="Get paper contract health"),
665
+ )
666
+ def get_paper_contract_health(
667
+ detail: str = "summary",
668
+ comment: str | dict[str, Any] | None = None,
669
+ ) -> dict[str, Any]:
670
+ return service.get_paper_contract_health(
671
+ context.require_quest_root(),
672
+ detail=detail,
673
+ )
674
+
675
+ @server.tool(
676
+ name="get_quest_state",
677
+ description=(
678
+ "Read the current quest runtime state without mutating anything. "
679
+ "Use detail='summary' for a compact operational view or detail='full' for recent artifacts, runs, and active interactions."
680
+ ),
681
+ annotations=_read_only_tool_annotations(title="Get quest state"),
682
+ )
683
+ def get_quest_state(
684
+ detail: str = "summary",
685
+ comment: str | dict[str, Any] | None = None,
686
+ ) -> dict[str, Any]:
687
+ return service.get_quest_state(
688
+ context.require_quest_root(),
689
+ detail=detail,
690
+ )
691
+
692
+ @server.tool(
693
+ name="get_global_status",
694
+ description=(
695
+ "Read a concise quest-global status summary for direct user questions such as overall progress, paper readiness, or the latest measured result. "
696
+ "Use detail='brief' for a compact answer surface or detail='full' for more structured context."
697
+ ),
698
+ annotations=_read_only_tool_annotations(title="Get global status"),
699
+ )
700
+ def get_global_status(
701
+ detail: str = "brief",
702
+ locale: str = "zh",
703
+ comment: str | dict[str, Any] | None = None,
704
+ ) -> dict[str, Any]:
705
+ return service.get_global_status(
706
+ context.require_quest_root(),
707
+ detail=detail,
708
+ locale=locale,
709
+ )
710
+
711
+ @server.tool(
712
+ name="get_method_scoreboard",
713
+ description=(
714
+ "Read or refresh the quest-level method scoreboard so overall experiment history and the current incumbent line are explicit."
715
+ ),
716
+ )
717
+ def get_method_scoreboard(comment: str | dict[str, Any] | None = None) -> dict[str, Any]:
718
+ return service.refresh_method_scoreboard(context.require_quest_root())
719
+
720
+ @server.tool(
721
+ name="get_optimization_frontier",
722
+ description=(
723
+ "Read a compact optimization-frontier summary for algorithm-first quests. "
724
+ "It summarizes candidate briefs, promoted lines, recent implementation candidates, stagnant branches, fusion opportunities, and the recommended next mode."
725
+ ),
726
+ annotations=_read_only_tool_annotations(title="Get optimization frontier"),
727
+ )
728
+ def get_optimization_frontier(
729
+ comment: str | dict[str, Any] | None = None,
730
+ ) -> dict[str, Any]:
731
+ return service.get_optimization_frontier(
732
+ context.require_quest_root(),
733
+ )
734
+
735
+ @server.tool(
736
+ name="read_quest_documents",
737
+ description=(
738
+ "Read durable quest documents such as brief, plan, status, summary, and active user requirements. "
739
+ "Use mode='excerpt' for compact recovery or mode='full' when exact document wording matters."
740
+ ),
741
+ annotations=_read_only_tool_annotations(title="Read quest documents"),
742
+ )
743
+ def read_quest_documents(
744
+ names: list[str] | None = None,
745
+ mode: str = "excerpt",
746
+ max_lines: int = 12,
747
+ comment: str | dict[str, Any] | None = None,
748
+ ) -> dict[str, Any]:
749
+ return service.read_quest_documents(
750
+ context.require_quest_root(),
751
+ names=names,
752
+ mode=mode,
753
+ max_lines=max_lines,
754
+ )
755
+
756
+ @server.tool(
757
+ name="get_conversation_context",
758
+ description=(
759
+ "Read a recent window of quest conversation history. "
760
+ "Use this when earlier user/assistant continuity matters and the current prompt intentionally keeps only a compact turn launcher."
761
+ ),
762
+ annotations=_read_only_tool_annotations(title="Get conversation context"),
763
+ )
764
+ def get_conversation_context(
765
+ limit: int = 12,
766
+ include_attachments: bool = False,
767
+ comment: str | dict[str, Any] | None = None,
768
+ ) -> dict[str, Any]:
769
+ return service.get_conversation_context(
770
+ context.require_quest_root(),
771
+ limit=limit,
772
+ include_attachments=include_attachments,
773
+ )
774
+
622
775
  @server.tool(
623
776
  name="get_analysis_campaign",
624
777
  description=(
625
778
  "Get one analysis campaign manifest with todo items, slice status, and next pending slice. "
626
779
  "Pass campaign_id='active' or omit it to recover the active campaign."
627
780
  ),
781
+ annotations=_read_only_tool_annotations(title="Get analysis campaign"),
628
782
  )
629
783
  def get_analysis_campaign(
630
784
  campaign_id: str | None = "active",
@@ -762,6 +916,7 @@ def build_artifact_server(context: McpContext) -> FastMCP:
762
916
  "List candidate/revised paper outlines and the selected outline reference. "
763
917
  "Use this before writing-facing analysis campaigns or when you need a valid outline_id."
764
918
  ),
919
+ annotations=_read_only_tool_annotations(title="List paper outlines"),
765
920
  )
766
921
  def list_paper_outlines(comment: str | dict[str, Any] | None = None) -> dict[str, Any]:
767
922
  return service.list_paper_outlines(context.require_quest_root())
@@ -957,7 +1112,14 @@ def build_artifact_server(context: McpContext) -> FastMCP:
957
1112
  def render_git_graph(comment: str | dict[str, Any] | None = None) -> dict[str, Any]:
958
1113
  return service.render_git_graph(context.require_quest_root())
959
1114
 
960
- @server.tool(name="interact", description="Send a structured user-facing update and optionally fetch new inbound messages.")
1115
+ @server.tool(
1116
+ name="interact",
1117
+ description=(
1118
+ "Send a structured user-facing interaction and optionally fetch new inbound messages. "
1119
+ "Use kind='answer' for direct user questions, kind='progress' for long-running checkpoint updates, "
1120
+ "kind='milestone' for material state changes, and kind='decision_request' only for true blocking decisions."
1121
+ ),
1122
+ )
961
1123
  def interact(
962
1124
  kind: str = "progress",
963
1125
  message: str = "",
@@ -977,6 +1139,9 @@ def build_artifact_server(context: McpContext) -> FastMCP:
977
1139
  reply_schema: dict[str, Any] | None = None,
978
1140
  reply_to_interaction_id: str | None = None,
979
1141
  supersede_open_requests: bool = True,
1142
+ dedupe_key: str | None = None,
1143
+ suppress_if_unchanged: bool | None = None,
1144
+ min_interval_seconds: int | None = None,
980
1145
  comment: str | dict[str, Any] | None = None,
981
1146
  ) -> dict[str, Any]:
982
1147
  result = service.interact(
@@ -999,6 +1164,9 @@ def build_artifact_server(context: McpContext) -> FastMCP:
999
1164
  reply_schema=reply_schema,
1000
1165
  reply_to_interaction_id=reply_to_interaction_id,
1001
1166
  supersede_open_requests=supersede_open_requests,
1167
+ dedupe_key=dedupe_key,
1168
+ suppress_if_unchanged=suppress_if_unchanged,
1169
+ min_interval_seconds=min_interval_seconds,
1002
1170
  )
1003
1171
  result["interaction_watchdog"] = quest_service.artifact_interaction_watchdog_status(context.require_quest_root())
1004
1172
  return result