@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
@@ -1,7 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import copy
4
+ import json
3
5
  import re
4
6
  import shutil
7
+ import threading
8
+ import time
5
9
  from pathlib import Path, PurePosixPath
6
10
  from typing import Any
7
11
 
@@ -31,6 +35,7 @@ from ..shared import (
31
35
  read_yaml,
32
36
  resolve_within,
33
37
  run_command,
38
+ sha256_text,
34
39
  slugify,
35
40
  utc_now,
36
41
  write_json,
@@ -40,6 +45,7 @@ from ..shared import (
40
45
  from ..quest import QuestService
41
46
  from ..memory.frontmatter import dump_markdown_document, load_markdown_document
42
47
  from .arxiv import fetch_arxiv_metadata, read_arxiv_content
48
+ from .charts import render_main_experiment_metric_timeline_chart
43
49
  from .guidance import build_guidance_for_record, guidance_summary
44
50
  from .metrics import (
45
51
  baseline_metric_lines,
@@ -98,6 +104,8 @@ class ArtifactService:
98
104
  self.baselines = BaselineRegistry(home)
99
105
  self.quest_service = QuestService(home)
100
106
  self.arxiv_library = ArxivLibraryService()
107
+ self._optimization_frontier_cache_lock = threading.Lock()
108
+ self._optimization_frontier_cache: dict[str, dict[str, Any]] = {}
101
109
 
102
110
  @staticmethod
103
111
  def _notification_text(value: object) -> str | None:
@@ -225,38 +233,124 @@ class ArtifactService:
225
233
  return branch
226
234
  return fallback or "current head"
227
235
 
236
+ @staticmethod
237
+ def _clean_text(value: object) -> str | None:
238
+ text = " ".join(str(value or "").split()).strip()
239
+ if not text:
240
+ return None
241
+ return text
242
+
243
+ @staticmethod
244
+ def _summary_preview_text(value: object, *, limit: int = 220) -> str | None:
245
+ text = ArtifactService._clean_text(value)
246
+ if not text:
247
+ return None
248
+ if len(text) <= limit:
249
+ return text
250
+ return text[: max(0, limit - 1)].rstrip() + "…"
251
+
228
252
  def _build_idea_interaction_message(
229
253
  self,
230
254
  *,
255
+ quest_root: Path,
231
256
  action: str,
232
257
  idea_id: str,
233
258
  title: str | None,
234
- problem: str | None,
235
- hypothesis: str | None,
236
259
  mechanism: str | None,
260
+ method_brief: str | None,
237
261
  foundation_label: str | None,
238
262
  branch_name: str,
263
+ change_layer: str | None,
264
+ source_lens: str | None,
265
+ expected_gain: str | None,
239
266
  next_target: str | None,
240
- idea_md_rel_path: str | None,
241
- draft_md_rel_path: str | None,
242
267
  ) -> str:
243
- lead = "is now active" if action == "create" else "was revised"
244
- lines = [f"Idea `{idea_id}` {lead} on branch `{branch_name}`."]
245
- self._append_notification_section(lines, "Title", title)
246
- self._append_notification_section(lines, "Problem", problem)
247
- self._append_notification_section(lines, "Hypothesis", hypothesis)
248
- self._append_notification_section(lines, "Mechanism", mechanism)
249
- if foundation_label:
250
- self._append_notification_section(lines, "Foundation", foundation_label)
251
- if next_target:
252
- self._append_notification_section(lines, "Next route", self._format_route_label(next_target) or next_target)
253
- self._append_notification_file_section(
254
- lines,
255
- [
256
- ("Idea doc", idea_md_rel_path),
257
- ("Draft", draft_md_rel_path),
258
- ],
268
+ normalized_title = self._clean_text(title or idea_id) or idea_id
269
+ design_text = self._clean_text(method_brief or mechanism) or self.quest_service.localized_copy(
270
+ quest_root=quest_root,
271
+ zh="核心设计还未写清楚",
272
+ en="the core design is not written clearly yet",
259
273
  )
274
+ compared_target = self._clean_text(foundation_label) or self.quest_service.localized_copy(
275
+ quest_root=quest_root,
276
+ zh="当前方案",
277
+ en="the current approach",
278
+ )
279
+ delta_parts: list[str] = []
280
+ if change_layer:
281
+ delta_parts.append(
282
+ self.quest_service.localized_copy(
283
+ quest_root=quest_root,
284
+ zh=f"重点改 `{self._clean_text(change_layer) or change_layer}`",
285
+ en=f"changes `{self._clean_text(change_layer) or change_layer}` first",
286
+ )
287
+ )
288
+ if source_lens:
289
+ delta_parts.append(
290
+ self.quest_service.localized_copy(
291
+ quest_root=quest_root,
292
+ zh=f"引入 `{self._clean_text(source_lens) or source_lens}` 的设计视角",
293
+ en=f"adds a `{self._clean_text(source_lens) or source_lens}` design angle",
294
+ )
295
+ )
296
+ if not delta_parts:
297
+ delta_parts.append(
298
+ self.quest_service.localized_copy(
299
+ quest_root=quest_root,
300
+ zh=f"把 `{design_text}` 直接加到主设计里",
301
+ en=f"directly adds `{design_text}` into the main design",
302
+ )
303
+ )
304
+ delta_text = self._clean_text(";".join(delta_parts)) or delta_parts[0]
305
+ expected_text = self._clean_text(expected_gain)
306
+ next_target_text = self._format_route_label(next_target) if next_target else None
307
+ if action == "candidate":
308
+ headline = self.quest_service.localized_copy(
309
+ quest_root=quest_root,
310
+ zh=f"收到 idea 候选:{normalized_title}",
311
+ en=f"Idea candidate recorded: {normalized_title}",
312
+ )
313
+ elif action == "revise":
314
+ headline = self.quest_service.localized_copy(
315
+ quest_root=quest_root,
316
+ zh=f"已更新 idea:{normalized_title}({branch_name})",
317
+ en=f"Idea updated: {normalized_title} ({branch_name})",
318
+ )
319
+ else:
320
+ headline = self.quest_service.localized_copy(
321
+ quest_root=quest_root,
322
+ zh=f"新 idea:{normalized_title}({branch_name})",
323
+ en=f"New idea: {normalized_title} ({branch_name})",
324
+ )
325
+ lines = [
326
+ headline,
327
+ self.quest_service.localized_copy(
328
+ quest_root=quest_root,
329
+ zh=f"创新点:{design_text}",
330
+ en=f"Innovation: {design_text}",
331
+ ),
332
+ self.quest_service.localized_copy(
333
+ quest_root=quest_root,
334
+ zh=f"相对 {compared_target}:{delta_text}",
335
+ en=f"Compared with {compared_target}: {delta_text}",
336
+ ),
337
+ ]
338
+ if expected_text:
339
+ lines.append(
340
+ self.quest_service.localized_copy(
341
+ quest_root=quest_root,
342
+ zh=f"预期收益:{expected_text}",
343
+ en=f"Expected gain: {expected_text}",
344
+ )
345
+ )
346
+ elif next_target_text:
347
+ lines.append(
348
+ self.quest_service.localized_copy(
349
+ quest_root=quest_root,
350
+ zh=f"下一步:{next_target_text}",
351
+ en=f"Next step: {next_target_text}",
352
+ )
353
+ )
260
354
  return "\n".join(lines)
261
355
 
262
356
  def _build_main_experiment_interaction_message(
@@ -313,6 +407,139 @@ class ArtifactService:
313
407
  )
314
408
  return "\n".join(lines)
315
409
 
410
+ def _main_experiment_chart_dir(self, workspace_root: Path, *, run_id: str) -> Path:
411
+ return ensure_dir(workspace_root / "experiments" / "main" / run_id / "connector-charts")
412
+
413
+ def _generate_main_experiment_metric_charts(
414
+ self,
415
+ quest_root: Path,
416
+ *,
417
+ workspace_root: Path,
418
+ run_id: str,
419
+ ) -> list[dict[str, Any]]:
420
+ timeline = self.quest_service.metrics_timeline(self._quest_id(quest_root))
421
+ chart_dir = self._main_experiment_chart_dir(workspace_root, run_id=run_id)
422
+ charts: list[dict[str, Any]] = []
423
+ for series in timeline.get("series") or []:
424
+ if not isinstance(series, dict):
425
+ continue
426
+ points = [dict(item) for item in (series.get("points") or []) if isinstance(item, dict)]
427
+ if not points:
428
+ continue
429
+ latest_point = points[-1]
430
+ if str(latest_point.get("run_id") or "").strip() != run_id:
431
+ continue
432
+ metric_id = str(series.get("metric_id") or "").strip()
433
+ if not metric_id:
434
+ continue
435
+ output_path = chart_dir / f"{slugify(metric_id, 'metric')}-timeline.png"
436
+ chart_payload = render_main_experiment_metric_timeline_chart(
437
+ series=series,
438
+ output_path=output_path,
439
+ )
440
+ charts.append(chart_payload)
441
+ return charts
442
+
443
+ def _auto_metric_chart_targets(
444
+ self,
445
+ quest_root: Path,
446
+ *,
447
+ connectors: dict[str, Any],
448
+ ) -> list[tuple[str, str]]:
449
+ targets: list[tuple[str, str]] = []
450
+ for target in self._bound_conversations(quest_root):
451
+ channel_name = self._normalize_channel_name(target)
452
+ if channel_name not in {"qq", "weixin"}:
453
+ continue
454
+ channel_config = connectors.get(channel_name) if isinstance(connectors.get(channel_name), dict) else {}
455
+ if not bool(channel_config.get("enabled", False)):
456
+ continue
457
+ if not bool(channel_config.get("auto_send_main_experiment_png", True)):
458
+ continue
459
+ targets.append((target, channel_name))
460
+ return targets
461
+
462
+ def _send_main_experiment_metric_charts(
463
+ self,
464
+ quest_root: Path,
465
+ *,
466
+ run_id: str,
467
+ title: str,
468
+ charts: list[dict[str, Any]],
469
+ ) -> dict[str, Any]:
470
+ connectors = self._connectors_config()
471
+ targets = self._auto_metric_chart_targets(quest_root, connectors=connectors)
472
+ if not charts or not targets:
473
+ return {
474
+ "enabled": False,
475
+ "chart_count": len(charts),
476
+ "target_count": len(targets),
477
+ "targets": [target for target, _ in targets],
478
+ "deliveries": [],
479
+ }
480
+
481
+ deliveries: list[dict[str, Any]] = []
482
+ for chart_index, chart in enumerate(charts):
483
+ label = str(chart.get("label") or chart.get("metric_id") or "metric").strip() or "metric"
484
+ path = str(chart.get("path") or "").strip()
485
+ if not path:
486
+ continue
487
+ message = f"Main experiment metric chart · {label}"
488
+ attachments = [
489
+ {
490
+ "kind": "path",
491
+ "path": path,
492
+ "label": label,
493
+ "content_type": "image/png",
494
+ "connector_delivery": {
495
+ "qq": {
496
+ "media_kind": "image",
497
+ "allow_internal_auto_media": True,
498
+ },
499
+ "weixin": {
500
+ "media_kind": "image",
501
+ },
502
+ },
503
+ }
504
+ ]
505
+ for target, channel_name in targets:
506
+ payload = {
507
+ "quest_root": str(quest_root),
508
+ "quest_id": self._quest_id(quest_root),
509
+ "conversation_id": target,
510
+ "kind": "main_experiment_metric_chart",
511
+ "message": message,
512
+ "response_phase": "push",
513
+ "importance": "info",
514
+ "attachments": attachments,
515
+ }
516
+ delivery_result = self._deliver_to_channel(
517
+ channel_name,
518
+ payload,
519
+ connectors=connectors,
520
+ )
521
+ deliveries.append(
522
+ {
523
+ "target": target,
524
+ "channel": channel_name,
525
+ "metric_id": chart.get("metric_id"),
526
+ "label": label,
527
+ "path": path,
528
+ "delivery": delivery_result,
529
+ }
530
+ )
531
+ if chart_index < len(charts) - 1:
532
+ time.sleep(2.0)
533
+ return {
534
+ "enabled": True,
535
+ "run_id": run_id,
536
+ "title": title,
537
+ "chart_count": len(charts),
538
+ "target_count": len(targets),
539
+ "targets": [target for target, _ in targets],
540
+ "deliveries": deliveries,
541
+ }
542
+
316
543
  def _build_outline_interaction_message(
317
544
  self,
318
545
  *,
@@ -460,6 +687,7 @@ class ArtifactService:
460
687
  writing_plan_rel_path: str | None,
461
688
  references_rel_path: str | None,
462
689
  claim_evidence_map_rel_path: str | None,
690
+ evidence_ledger_rel_path: str | None,
463
691
  compile_report_rel_path: str | None,
464
692
  pdf_rel_path: str | None,
465
693
  latex_root_rel_path: str | None,
@@ -486,6 +714,7 @@ class ArtifactService:
486
714
  ("Writing plan", writing_plan_rel_path),
487
715
  ("References", references_rel_path),
488
716
  ("Claim-evidence map", claim_evidence_map_rel_path),
717
+ ("Evidence ledger", evidence_ledger_rel_path),
489
718
  ("Compile report", compile_report_rel_path),
490
719
  ("PDF", pdf_rel_path),
491
720
  ("LaTeX root", latex_root_rel_path),
@@ -847,6 +1076,11 @@ class ArtifactService:
847
1076
  next_target: str,
848
1077
  branch: str,
849
1078
  worktree_root: Path,
1079
+ method_brief: str = "",
1080
+ selection_scores: dict[str, Any] | None = None,
1081
+ mechanism_family: str = "",
1082
+ change_layer: str = "",
1083
+ source_lens: str = "",
850
1084
  foundation_ref: dict[str, Any] | None = None,
851
1085
  foundation_reason: str = "",
852
1086
  lineage_intent: str | None = None,
@@ -854,9 +1088,16 @@ class ArtifactService:
854
1088
  ) -> str:
855
1089
  normalized_foundation = dict(foundation_ref or {})
856
1090
  normalized_lineage_intent = str(lineage_intent or "").strip().lower() or None
1091
+ normalized_method_brief = str(method_brief or "").strip()
1092
+ normalized_selection_scores = self._normalize_selection_scores(selection_scores)
1093
+ normalized_mechanism_family = str(mechanism_family or "").strip() or None
1094
+ normalized_change_layer = str(change_layer or "").strip() or None
1095
+ normalized_source_lens = str(source_lens or "").strip() or None
857
1096
  tags = [f"branch:{branch}", f"next:{next_target}"]
858
1097
  if normalized_lineage_intent:
859
1098
  tags.append(f"lineage:{normalized_lineage_intent}")
1099
+ if normalized_mechanism_family:
1100
+ tags.append(f"family:{slugify(normalized_mechanism_family, 'family')}")
860
1101
  metadata = {
861
1102
  "id": idea_id,
862
1103
  "type": "ideas",
@@ -867,6 +1108,11 @@ class ArtifactService:
867
1108
  "branch": branch,
868
1109
  "worktree_root": str(worktree_root),
869
1110
  "next_target": next_target,
1111
+ "method_brief": normalized_method_brief or None,
1112
+ "selection_scores": normalized_selection_scores or None,
1113
+ "mechanism_family": normalized_mechanism_family,
1114
+ "change_layer": normalized_change_layer,
1115
+ "source_lens": normalized_source_lens,
870
1116
  "foundation_ref": normalized_foundation or None,
871
1117
  "foundation_reason": foundation_reason.strip() or None,
872
1118
  "lineage_intent": normalized_lineage_intent,
@@ -889,10 +1135,24 @@ class ArtifactService:
889
1135
  "",
890
1136
  mechanism.strip() or "TBD",
891
1137
  "",
1138
+ "## Method Brief",
1139
+ "",
1140
+ normalized_method_brief or "Not recorded",
1141
+ "",
892
1142
  "## Expected Gain",
893
1143
  "",
894
1144
  expected_gain.strip() or "TBD",
895
1145
  "",
1146
+ "## Selection Scores",
1147
+ "",
1148
+ *self._selection_score_lines(normalized_selection_scores),
1149
+ "",
1150
+ "## Diversity Tags",
1151
+ "",
1152
+ f"- Mechanism family: {normalized_mechanism_family or 'Not recorded'}",
1153
+ f"- Change layer: {normalized_change_layer or 'Not recorded'}",
1154
+ f"- Source lens: {normalized_source_lens or 'Not recorded'}",
1155
+ "",
896
1156
  "## Decision Reason",
897
1157
  "",
898
1158
  decision_reason.strip() or "TBD",
@@ -956,6 +1216,11 @@ class ArtifactService:
956
1216
  next_target: str,
957
1217
  branch: str,
958
1218
  worktree_root: Path,
1219
+ method_brief: str = "",
1220
+ selection_scores: dict[str, Any] | None = None,
1221
+ mechanism_family: str = "",
1222
+ change_layer: str = "",
1223
+ source_lens: str = "",
959
1224
  foundation_ref: dict[str, Any] | None = None,
960
1225
  foundation_reason: str = "",
961
1226
  lineage_intent: str | None = None,
@@ -964,6 +1229,11 @@ class ArtifactService:
964
1229
  ) -> str:
965
1230
  normalized_foundation = dict(foundation_ref or {})
966
1231
  normalized_lineage_intent = str(lineage_intent or "").strip().lower() or None
1232
+ normalized_method_brief = str(method_brief or "").strip()
1233
+ normalized_selection_scores = self._normalize_selection_scores(selection_scores)
1234
+ normalized_mechanism_family = str(mechanism_family or "").strip() or None
1235
+ normalized_change_layer = str(change_layer or "").strip() or None
1236
+ normalized_source_lens = str(source_lens or "").strip() or None
967
1237
  metadata = {
968
1238
  "id": f"{idea_id}-draft",
969
1239
  "type": "ideas",
@@ -975,6 +1245,11 @@ class ArtifactService:
975
1245
  "branch": branch,
976
1246
  "worktree_root": str(worktree_root),
977
1247
  "next_target": next_target,
1248
+ "method_brief": normalized_method_brief or None,
1249
+ "selection_scores": normalized_selection_scores or None,
1250
+ "mechanism_family": normalized_mechanism_family,
1251
+ "change_layer": normalized_change_layer,
1252
+ "source_lens": normalized_source_lens,
978
1253
  "foundation_ref": normalized_foundation or None,
979
1254
  "foundation_reason": foundation_reason.strip() or None,
980
1255
  "lineage_intent": normalized_lineage_intent,
@@ -1000,6 +1275,11 @@ class ArtifactService:
1000
1275
  if evidence_paths
1001
1276
  else "- None recorded yet."
1002
1277
  )
1278
+ selection_score_lines = (
1279
+ "\n".join(self._selection_score_lines(normalized_selection_scores))
1280
+ if normalized_selection_scores
1281
+ else "- Not recorded"
1282
+ )
1003
1283
  body = "\n".join(
1004
1284
  [
1005
1285
  f"# {title}",
@@ -1020,6 +1300,20 @@ class ArtifactService:
1020
1300
  "",
1021
1301
  mechanism.strip() or "TBD",
1022
1302
  "",
1303
+ "## Method Brief",
1304
+ "",
1305
+ normalized_method_brief or "Not recorded",
1306
+ "",
1307
+ "## Selection Scores",
1308
+ "",
1309
+ selection_score_lines,
1310
+ "",
1311
+ "## Diversity Tags",
1312
+ "",
1313
+ f"- Mechanism family: {normalized_mechanism_family or 'Not recorded'}",
1314
+ f"- Change layer: {normalized_change_layer or 'Not recorded'}",
1315
+ f"- Source lens: {normalized_source_lens or 'Not recorded'}",
1316
+ "",
1023
1317
  "## Code-Level Change Plan",
1024
1318
  "",
1025
1319
  mechanism.strip() or "TBD",
@@ -1047,64 +1341,369 @@ class ArtifactService:
1047
1341
  next_target.strip() or "experiment",
1048
1342
  "",
1049
1343
  ]
1050
- )
1344
+ )
1051
1345
  return dump_markdown_document(metadata, body.rstrip() + "\n")
1052
1346
 
1053
- def _analysis_manifest_path(self, quest_root: Path, campaign_id: str) -> Path:
1054
- return ensure_dir(quest_root / ".ds" / "analysis_campaigns") / f"{campaign_id}.json"
1055
-
1056
- def _read_analysis_manifest(self, quest_root: Path, campaign_id: str) -> dict[str, Any]:
1057
- path = self._analysis_manifest_path(quest_root, campaign_id)
1058
- payload = read_json(path, {})
1059
- if not isinstance(payload, dict) or not payload:
1060
- raise FileNotFoundError(f"Unknown analysis campaign `{campaign_id}`.")
1061
- return payload
1062
-
1063
- def _write_analysis_manifest(self, quest_root: Path, campaign_id: str, payload: dict[str, Any]) -> dict[str, Any]:
1064
- path = self._analysis_manifest_path(quest_root, campaign_id)
1065
- normalized = {**payload, "campaign_id": campaign_id, "updated_at": utc_now()}
1066
- write_json(path, normalized)
1067
- return normalized
1068
-
1069
- def _analysis_baseline_inventory_path(self, quest_root: Path) -> Path:
1070
- return ensure_dir(quest_root / "artifacts" / "baselines") / "analysis_inventory.json"
1071
-
1072
- def _read_analysis_baseline_inventory(self, quest_root: Path) -> dict[str, Any]:
1073
- path = self._analysis_baseline_inventory_path(quest_root)
1074
- payload = read_json(path, {})
1075
- if not isinstance(payload, dict):
1076
- payload = {}
1077
- entries = payload.get("entries") if isinstance(payload.get("entries"), list) else []
1078
- return {
1079
- "schema_version": 1,
1080
- "entries": [dict(item) for item in entries if isinstance(item, dict)],
1081
- "updated_at": payload.get("updated_at"),
1082
- }
1347
+ def _idea_candidate_root(self, quest_root: Path, idea_id: str) -> Path:
1348
+ return ensure_dir(quest_root / "memory" / "ideas" / "_candidates" / idea_id)
1083
1349
 
1084
- def _write_analysis_baseline_inventory(self, quest_root: Path, payload: dict[str, Any]) -> dict[str, Any]:
1085
- path = self._analysis_baseline_inventory_path(quest_root)
1086
- normalized_entries = payload.get("entries") if isinstance(payload.get("entries"), list) else []
1087
- normalized = {
1088
- "schema_version": 1,
1089
- "entries": [dict(item) for item in normalized_entries if isinstance(item, dict)],
1350
+ def _build_candidate_idea_markdown(
1351
+ self,
1352
+ *,
1353
+ idea_id: str,
1354
+ quest_id: str,
1355
+ title: str,
1356
+ problem: str,
1357
+ hypothesis: str,
1358
+ mechanism: str,
1359
+ expected_gain: str,
1360
+ risks: list[str],
1361
+ evidence_paths: list[str],
1362
+ decision_reason: str,
1363
+ next_target: str,
1364
+ candidate_root: Path,
1365
+ method_brief: str = "",
1366
+ selection_scores: dict[str, Any] | None = None,
1367
+ mechanism_family: str = "",
1368
+ change_layer: str = "",
1369
+ source_lens: str = "",
1370
+ foundation_ref: dict[str, Any] | None = None,
1371
+ foundation_reason: str = "",
1372
+ lineage_intent: str | None = None,
1373
+ ) -> str:
1374
+ normalized_foundation = dict(foundation_ref or {})
1375
+ normalized_lineage_intent = str(lineage_intent or "").strip().lower() or None
1376
+ normalized_method_brief = str(method_brief or "").strip()
1377
+ normalized_selection_scores = self._normalize_selection_scores(selection_scores)
1378
+ normalized_mechanism_family = str(mechanism_family or "").strip() or None
1379
+ normalized_change_layer = str(change_layer or "").strip() or None
1380
+ normalized_source_lens = str(source_lens or "").strip() or None
1381
+ metadata = {
1382
+ "id": idea_id,
1383
+ "type": "ideas",
1384
+ "kind": "idea_candidate",
1385
+ "title": title,
1386
+ "quest_id": quest_id,
1387
+ "scope": "quest",
1388
+ "submission_mode": "candidate",
1389
+ "candidate_root": str(candidate_root),
1390
+ "next_target": next_target,
1391
+ "method_brief": normalized_method_brief or None,
1392
+ "selection_scores": normalized_selection_scores or None,
1393
+ "mechanism_family": normalized_mechanism_family,
1394
+ "change_layer": normalized_change_layer,
1395
+ "source_lens": normalized_source_lens,
1396
+ "foundation_ref": normalized_foundation or None,
1397
+ "foundation_reason": foundation_reason.strip() or None,
1398
+ "lineage_intent": normalized_lineage_intent,
1399
+ "created_at": utc_now(),
1090
1400
  "updated_at": utc_now(),
1401
+ "tags": [
1402
+ "idea-candidate",
1403
+ f"next:{next_target}",
1404
+ *( [f"lineage:{normalized_lineage_intent}"] if normalized_lineage_intent else []),
1405
+ ],
1091
1406
  }
1092
- write_json(path, normalized)
1093
- return normalized
1407
+ body_lines = [
1408
+ f"# {title}",
1409
+ "",
1410
+ "## Candidate Summary",
1411
+ "",
1412
+ decision_reason.strip() or "This candidate is recorded for later ranking or promotion.",
1413
+ "",
1414
+ "## Problem",
1415
+ "",
1416
+ problem.strip() or "TBD",
1417
+ "",
1418
+ "## Hypothesis",
1419
+ "",
1420
+ hypothesis.strip() or "TBD",
1421
+ "",
1422
+ "## Mechanism",
1423
+ "",
1424
+ mechanism.strip() or "TBD",
1425
+ "",
1426
+ "## Method Brief",
1427
+ "",
1428
+ normalized_method_brief or "Not recorded",
1429
+ "",
1430
+ "## Expected Gain",
1431
+ "",
1432
+ expected_gain.strip() or "TBD",
1433
+ "",
1434
+ "## Selection Scores",
1435
+ "",
1436
+ *self._selection_score_lines(normalized_selection_scores),
1437
+ "",
1438
+ "## Diversity Tags",
1439
+ "",
1440
+ f"- Mechanism family: {normalized_mechanism_family or 'Not recorded'}",
1441
+ f"- Change layer: {normalized_change_layer or 'Not recorded'}",
1442
+ f"- Source lens: {normalized_source_lens or 'Not recorded'}",
1443
+ "",
1444
+ "## Risks",
1445
+ "",
1446
+ ]
1447
+ if risks:
1448
+ body_lines.extend([f"- {item}" for item in risks])
1449
+ else:
1450
+ body_lines.append("- None recorded yet.")
1451
+ body_lines.extend(["", "## Evidence Paths", ""])
1452
+ if evidence_paths:
1453
+ body_lines.extend([f"- `{item}`" for item in evidence_paths])
1454
+ else:
1455
+ body_lines.append("- None recorded yet.")
1456
+ body_lines.extend(["", "## Foundation", ""])
1457
+ if normalized_foundation:
1458
+ body_lines.extend(
1459
+ [
1460
+ f"- Lineage intent: `{normalized_lineage_intent or 'manual'}`",
1461
+ f"- Kind: `{normalized_foundation.get('kind') or 'unknown'}`",
1462
+ f"- Ref: `{normalized_foundation.get('ref') or 'none'}`",
1463
+ f"- Branch: `{normalized_foundation.get('branch') or 'none'}`",
1464
+ f"- Reason: {foundation_reason.strip() or 'No explicit reason recorded.'}",
1465
+ ]
1466
+ )
1467
+ else:
1468
+ body_lines.append("- Default current head foundation.")
1469
+ body_lines.extend(
1470
+ [
1471
+ "",
1472
+ "## Next Target",
1473
+ "",
1474
+ next_target.strip() or "experiment",
1475
+ "",
1476
+ ]
1477
+ )
1478
+ return dump_markdown_document(metadata, "\n".join(body_lines).rstrip() + "\n")
1094
1479
 
1095
- def _normalize_baseline_root_rel_path(
1480
+ def _build_candidate_idea_draft_markdown(
1096
1481
  self,
1097
- quest_root: Path,
1098
- baseline_root_rel_path: str | None,
1099
1482
  *,
1100
- baseline_id: str | None = None,
1101
- ) -> tuple[str | None, str | None]:
1102
- raw = str(baseline_root_rel_path or "").strip()
1103
- if not raw:
1104
- return None, None
1105
- candidate = Path(raw)
1106
- resolved = candidate.resolve() if candidate.is_absolute() else resolve_within(quest_root, raw)
1107
- if not resolved.exists():
1483
+ idea_id: str,
1484
+ quest_id: str,
1485
+ title: str,
1486
+ problem: str,
1487
+ hypothesis: str,
1488
+ mechanism: str,
1489
+ expected_gain: str,
1490
+ risks: list[str],
1491
+ evidence_paths: list[str],
1492
+ decision_reason: str,
1493
+ next_target: str,
1494
+ candidate_root: Path,
1495
+ method_brief: str = "",
1496
+ selection_scores: dict[str, Any] | None = None,
1497
+ mechanism_family: str = "",
1498
+ change_layer: str = "",
1499
+ source_lens: str = "",
1500
+ foundation_ref: dict[str, Any] | None = None,
1501
+ foundation_reason: str = "",
1502
+ lineage_intent: str | None = None,
1503
+ draft_markdown: str = "",
1504
+ ) -> str:
1505
+ normalized_foundation = dict(foundation_ref or {})
1506
+ normalized_lineage_intent = str(lineage_intent or "").strip().lower() or None
1507
+ normalized_method_brief = str(method_brief or "").strip()
1508
+ normalized_selection_scores = self._normalize_selection_scores(selection_scores)
1509
+ normalized_mechanism_family = str(mechanism_family or "").strip() or None
1510
+ normalized_change_layer = str(change_layer or "").strip() or None
1511
+ normalized_source_lens = str(source_lens or "").strip() or None
1512
+ metadata = {
1513
+ "id": f"{idea_id}-candidate-draft",
1514
+ "type": "ideas",
1515
+ "kind": "idea_candidate_draft",
1516
+ "title": f"{title} Candidate Draft",
1517
+ "idea_id": idea_id,
1518
+ "quest_id": quest_id,
1519
+ "scope": "quest",
1520
+ "submission_mode": "candidate",
1521
+ "candidate_root": str(candidate_root),
1522
+ "next_target": next_target,
1523
+ "method_brief": normalized_method_brief or None,
1524
+ "selection_scores": normalized_selection_scores or None,
1525
+ "mechanism_family": normalized_mechanism_family,
1526
+ "change_layer": normalized_change_layer,
1527
+ "source_lens": normalized_source_lens,
1528
+ "foundation_ref": normalized_foundation or None,
1529
+ "foundation_reason": foundation_reason.strip() or None,
1530
+ "lineage_intent": normalized_lineage_intent,
1531
+ "created_at": utc_now(),
1532
+ "updated_at": utc_now(),
1533
+ "tags": [
1534
+ "idea-candidate-draft",
1535
+ f"next:{next_target}",
1536
+ *( [f"lineage:{normalized_lineage_intent}"] if normalized_lineage_intent else []),
1537
+ ],
1538
+ }
1539
+ body = str(draft_markdown or "").strip()
1540
+ if not body:
1541
+ risk_lines = "\n".join(f"- {item}" for item in risks) if risks else "- None recorded yet."
1542
+ evidence_lines = "\n".join(f"- `{item}`" for item in evidence_paths) if evidence_paths else "- None recorded yet."
1543
+ selection_score_lines = (
1544
+ "\n".join(self._selection_score_lines(normalized_selection_scores))
1545
+ if normalized_selection_scores
1546
+ else "- Not recorded"
1547
+ )
1548
+ foundation_label = (
1549
+ normalized_foundation.get("label")
1550
+ or normalized_foundation.get("branch")
1551
+ or normalized_foundation.get("ref")
1552
+ or "current head"
1553
+ )
1554
+ body = "\n".join(
1555
+ [
1556
+ f"# {title}",
1557
+ "",
1558
+ "## Executive Summary",
1559
+ "",
1560
+ decision_reason.strip() or "This candidate draft records a possible optimization line before promotion.",
1561
+ "",
1562
+ "## Limitation / Bottleneck",
1563
+ "",
1564
+ problem.strip() or "TBD",
1565
+ "",
1566
+ "## Selected Claim",
1567
+ "",
1568
+ hypothesis.strip() or "TBD",
1569
+ "",
1570
+ "## Theory and Method",
1571
+ "",
1572
+ mechanism.strip() or "TBD",
1573
+ "",
1574
+ "## Method Brief",
1575
+ "",
1576
+ normalized_method_brief or "Not recorded",
1577
+ "",
1578
+ "## Selection Scores",
1579
+ "",
1580
+ selection_score_lines,
1581
+ "",
1582
+ "## Diversity Tags",
1583
+ "",
1584
+ f"- Mechanism family: {normalized_mechanism_family or 'Not recorded'}",
1585
+ f"- Change layer: {normalized_change_layer or 'Not recorded'}",
1586
+ f"- Source lens: {normalized_source_lens or 'Not recorded'}",
1587
+ "",
1588
+ "## Code-Level Change Plan",
1589
+ "",
1590
+ mechanism.strip() or "TBD",
1591
+ "",
1592
+ "## Evaluation / Falsification Plan",
1593
+ "",
1594
+ expected_gain.strip() or "TBD",
1595
+ "",
1596
+ "## Risks / Caveats / Implementation Notes",
1597
+ "",
1598
+ risk_lines,
1599
+ "",
1600
+ "## Evidence / References",
1601
+ "",
1602
+ evidence_lines,
1603
+ "",
1604
+ "## Foundation Choice",
1605
+ "",
1606
+ f"- Lineage intent: `{normalized_lineage_intent or 'manual'}`",
1607
+ f"- Foundation: `{foundation_label}`",
1608
+ f"- Reason: {foundation_reason.strip() or 'Use the current active foundation.'}",
1609
+ "",
1610
+ "## Next Target",
1611
+ "",
1612
+ next_target.strip() or "experiment",
1613
+ "",
1614
+ ]
1615
+ )
1616
+ return dump_markdown_document(metadata, body.rstrip() + "\n")
1617
+
1618
+ @staticmethod
1619
+ def _normalize_selection_scores(value: object) -> dict[str, Any] | None:
1620
+ if not isinstance(value, dict):
1621
+ return None
1622
+ normalized: dict[str, Any] = {}
1623
+ for raw_key, raw_value in value.items():
1624
+ key = str(raw_key or "").strip()
1625
+ if not key:
1626
+ continue
1627
+ if isinstance(raw_value, bool):
1628
+ normalized[key] = raw_value
1629
+ continue
1630
+ numeric = to_number(raw_value)
1631
+ if numeric is not None:
1632
+ normalized[key] = int(numeric) if float(numeric).is_integer() else float(numeric)
1633
+ continue
1634
+ text = str(raw_value or "").strip()
1635
+ if text:
1636
+ normalized[key] = text
1637
+ return normalized or None
1638
+
1639
+ @staticmethod
1640
+ def _selection_score_lines(selection_scores: dict[str, Any] | None) -> list[str]:
1641
+ if not selection_scores:
1642
+ return ["- Not recorded"]
1643
+ lines: list[str] = []
1644
+ for key, value in selection_scores.items():
1645
+ if isinstance(value, float):
1646
+ rendered = f"{value:.4f}".rstrip("0").rstrip(".")
1647
+ else:
1648
+ rendered = str(value)
1649
+ lines.append(f"- {key}: {rendered}")
1650
+ return lines
1651
+
1652
+ def _analysis_manifest_path(self, quest_root: Path, campaign_id: str) -> Path:
1653
+ return ensure_dir(quest_root / ".ds" / "analysis_campaigns") / f"{campaign_id}.json"
1654
+
1655
+ def _read_analysis_manifest(self, quest_root: Path, campaign_id: str) -> dict[str, Any]:
1656
+ path = self._analysis_manifest_path(quest_root, campaign_id)
1657
+ payload = read_json(path, {})
1658
+ if not isinstance(payload, dict) or not payload:
1659
+ raise FileNotFoundError(f"Unknown analysis campaign `{campaign_id}`.")
1660
+ return payload
1661
+
1662
+ def _write_analysis_manifest(self, quest_root: Path, campaign_id: str, payload: dict[str, Any]) -> dict[str, Any]:
1663
+ path = self._analysis_manifest_path(quest_root, campaign_id)
1664
+ normalized = {**payload, "campaign_id": campaign_id, "updated_at": utc_now()}
1665
+ write_json(path, normalized)
1666
+ return normalized
1667
+
1668
+ def _analysis_baseline_inventory_path(self, quest_root: Path) -> Path:
1669
+ return ensure_dir(quest_root / "artifacts" / "baselines") / "analysis_inventory.json"
1670
+
1671
+ def _read_analysis_baseline_inventory(self, quest_root: Path) -> dict[str, Any]:
1672
+ path = self._analysis_baseline_inventory_path(quest_root)
1673
+ payload = read_json(path, {})
1674
+ if not isinstance(payload, dict):
1675
+ payload = {}
1676
+ entries = payload.get("entries") if isinstance(payload.get("entries"), list) else []
1677
+ return {
1678
+ "schema_version": 1,
1679
+ "entries": [dict(item) for item in entries if isinstance(item, dict)],
1680
+ "updated_at": payload.get("updated_at"),
1681
+ }
1682
+
1683
+ def _write_analysis_baseline_inventory(self, quest_root: Path, payload: dict[str, Any]) -> dict[str, Any]:
1684
+ path = self._analysis_baseline_inventory_path(quest_root)
1685
+ normalized_entries = payload.get("entries") if isinstance(payload.get("entries"), list) else []
1686
+ normalized = {
1687
+ "schema_version": 1,
1688
+ "entries": [dict(item) for item in normalized_entries if isinstance(item, dict)],
1689
+ "updated_at": utc_now(),
1690
+ }
1691
+ write_json(path, normalized)
1692
+ return normalized
1693
+
1694
+ def _normalize_baseline_root_rel_path(
1695
+ self,
1696
+ quest_root: Path,
1697
+ baseline_root_rel_path: str | None,
1698
+ *,
1699
+ baseline_id: str | None = None,
1700
+ ) -> tuple[str | None, str | None]:
1701
+ raw = str(baseline_root_rel_path or "").strip()
1702
+ if not raw:
1703
+ return None, None
1704
+ candidate = Path(raw)
1705
+ resolved = candidate.resolve() if candidate.is_absolute() else resolve_within(quest_root, raw)
1706
+ if not resolved.exists():
1108
1707
  raise FileNotFoundError(f"Baseline root does not exist: {resolved}")
1109
1708
  try:
1110
1709
  relative = resolved.relative_to(quest_root.resolve()).as_posix()
@@ -1309,6 +1908,180 @@ class ArtifactService:
1309
1908
  def _paper_bundle_manifest_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
1310
1909
  return self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "paper_bundle_manifest.json"
1311
1910
 
1911
+ def _paper_evidence_ledger_path(self, quest_root: Path) -> Path:
1912
+ return ensure_dir(quest_root / "paper") / "evidence_ledger.json"
1913
+
1914
+ def _paper_evidence_ledger_markdown_path(self, quest_root: Path) -> Path:
1915
+ return ensure_dir(quest_root / "paper") / "evidence_ledger.md"
1916
+
1917
+ def _paper_experiment_matrix_json_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
1918
+ return self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "paper_experiment_matrix.json"
1919
+
1920
+ def _paper_line_state_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
1921
+ return self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "paper_line_state.json"
1922
+
1923
+ def _paper_outline_root(
1924
+ self,
1925
+ quest_root: Path,
1926
+ *,
1927
+ workspace_root: Path | None = None,
1928
+ create: bool = False,
1929
+ ) -> Path:
1930
+ root = self._paper_root(quest_root, workspace_root=workspace_root, create=create) / "outline"
1931
+ return ensure_dir(root) if create else root
1932
+
1933
+ def _paper_outline_manifest_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
1934
+ return self._paper_outline_root(quest_root, workspace_root=workspace_root, create=True) / "manifest.json"
1935
+
1936
+ def _paper_outline_sections_root(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
1937
+ return ensure_dir(self._paper_outline_root(quest_root, workspace_root=workspace_root, create=True) / "sections")
1938
+
1939
+ def _paper_outline_section_dir(
1940
+ self,
1941
+ quest_root: Path,
1942
+ section_id: str,
1943
+ *,
1944
+ workspace_root: Path | None = None,
1945
+ ) -> Path:
1946
+ return ensure_dir(self._paper_outline_sections_root(quest_root, workspace_root=workspace_root) / section_id)
1947
+
1948
+ def _paper_active_sync_roots(self, quest_root: Path, *, workspace_root: Path | None = None) -> list[Path]:
1949
+ roots: list[Path] = []
1950
+ seen: set[str] = set()
1951
+ base_roots = [quest_root] if workspace_root is None else [workspace_root, quest_root]
1952
+ for root in base_roots:
1953
+ paper_root = root / "paper"
1954
+ key = str(paper_root.resolve(strict=False))
1955
+ if key in seen:
1956
+ continue
1957
+ seen.add(key)
1958
+ roots.append(ensure_dir(paper_root))
1959
+ return roots
1960
+
1961
+ def _paper_sync_roots(self, quest_root: Path) -> list[Path]:
1962
+ roots: list[Path] = []
1963
+ seen: set[str] = set()
1964
+ for workspace_root in self.quest_service.workspace_roots(quest_root):
1965
+ paper_root = workspace_root / "paper"
1966
+ if not paper_root.exists() or not paper_root.is_dir():
1967
+ continue
1968
+ key = str(paper_root.resolve())
1969
+ if key in seen:
1970
+ continue
1971
+ seen.add(key)
1972
+ roots.append(paper_root)
1973
+ canonical = ensure_dir(quest_root / "paper")
1974
+ canonical_key = str(canonical.resolve())
1975
+ if canonical_key not in seen:
1976
+ roots.append(canonical)
1977
+ return roots
1978
+
1979
+ def _paper_line_id(
1980
+ self,
1981
+ *,
1982
+ paper_branch: str | None,
1983
+ outline_id: str | None,
1984
+ source_run_id: str | None,
1985
+ ) -> str:
1986
+ seed = "::".join(
1987
+ [
1988
+ str(paper_branch or "").strip() or "paper",
1989
+ str(outline_id or "").strip() or "outline",
1990
+ str(source_run_id or "").strip() or "run",
1991
+ ]
1992
+ )
1993
+ return slugify(seed, "paper-line")
1994
+
1995
+ @staticmethod
1996
+ def _paper_ready_status(status: object) -> bool:
1997
+ normalized = str(status or "").strip().lower()
1998
+ return normalized in {"ready", "completed", "analyzed", "written", "recorded", "supported"}
1999
+
2000
+ def _normalize_outline_evidence_contract(self, payload: object) -> dict[str, Any]:
2001
+ if not isinstance(payload, dict):
2002
+ payload = {}
2003
+ return {
2004
+ "main_text_items_must_be_ready": bool(payload.get("main_text_items_must_be_ready", True)),
2005
+ "appendix_items_may_be_ready_or_reference_only": bool(
2006
+ payload.get("appendix_items_may_be_ready_or_reference_only", True)
2007
+ ),
2008
+ "record_results_back_into_outline": bool(payload.get("record_results_back_into_outline", True)),
2009
+ "result_table_required": bool(payload.get("result_table_required", True)),
2010
+ }
2011
+
2012
+ def _normalize_outline_result_table(self, values: object) -> list[dict[str, Any]]:
2013
+ rows: list[dict[str, Any]] = []
2014
+ for raw in values or [] if isinstance(values, list) else []:
2015
+ if not isinstance(raw, dict):
2016
+ continue
2017
+ row = {
2018
+ "item_id": str(raw.get("item_id") or "").strip() or None,
2019
+ "title": str(raw.get("title") or "").strip() or None,
2020
+ "kind": str(raw.get("kind") or "").strip() or None,
2021
+ "paper_role": str(raw.get("paper_role") or raw.get("paper_placement") or "").strip() or None,
2022
+ "status": str(raw.get("status") or "").strip() or None,
2023
+ "claim_links": self._normalize_string_list(raw.get("claim_links")),
2024
+ "setup_note": str(raw.get("setup_note") or raw.get("setup") or "").strip() or None,
2025
+ "metric_summary": str(raw.get("metric_summary") or "").strip() or None,
2026
+ "result_summary": str(raw.get("result_summary") or "").strip() or None,
2027
+ "impact_summary": str(raw.get("impact_summary") or raw.get("claim_impact") or "").strip() or None,
2028
+ "source_paths": self._normalize_string_list(raw.get("source_paths")),
2029
+ "updated_at": str(raw.get("updated_at") or "").strip() or None,
2030
+ }
2031
+ if row["item_id"] or row["title"] or row["result_summary"]:
2032
+ rows.append(row)
2033
+ return rows
2034
+
2035
+ def _normalize_outline_sections(
2036
+ self,
2037
+ values: object,
2038
+ *,
2039
+ experimental_designs: list[str] | None = None,
2040
+ ) -> list[dict[str, Any]]:
2041
+ sections: list[dict[str, Any]] = []
2042
+ if isinstance(values, list):
2043
+ for index, raw in enumerate(values, start=1):
2044
+ if not isinstance(raw, dict):
2045
+ continue
2046
+ title = str(raw.get("title") or raw.get("name") or raw.get("section_id") or "").strip()
2047
+ section_id = str(raw.get("section_id") or "").strip() or slugify(title or f"section-{index}", f"section-{index}")
2048
+ if not title:
2049
+ title = section_id
2050
+ sections.append(
2051
+ {
2052
+ "section_id": section_id,
2053
+ "title": title,
2054
+ "paper_role": str(raw.get("paper_role") or raw.get("paper_placement") or "main_text").strip() or "main_text",
2055
+ "claims": self._normalize_string_list(raw.get("claims")),
2056
+ "required_items": self._normalize_string_list(raw.get("required_items")),
2057
+ "optional_items": self._normalize_string_list(raw.get("optional_items")),
2058
+ "status": str(raw.get("status") or "planned").strip() or "planned",
2059
+ "note": str(raw.get("note") or "").strip() or None,
2060
+ "result_table": self._normalize_outline_result_table(raw.get("result_table")),
2061
+ }
2062
+ )
2063
+ if sections:
2064
+ return sections
2065
+ generated: list[dict[str, Any]] = []
2066
+ for index, item in enumerate(experimental_designs or [], start=1):
2067
+ title = str(item or "").strip()
2068
+ if not title:
2069
+ continue
2070
+ generated.append(
2071
+ {
2072
+ "section_id": slugify(title, f"section-{index}"),
2073
+ "title": title,
2074
+ "paper_role": "main_text",
2075
+ "claims": [],
2076
+ "required_items": [],
2077
+ "optional_items": [],
2078
+ "status": "planned",
2079
+ "note": None,
2080
+ "result_table": [],
2081
+ }
2082
+ )
2083
+ return generated
2084
+
1312
2085
  def _paper_baseline_inventory_path(self, quest_root: Path, *, workspace_root: Path | None = None) -> Path:
1313
2086
  return self._paper_root(quest_root, workspace_root=workspace_root, create=True) / "baseline_inventory.json"
1314
2087
 
@@ -1549,7 +2322,7 @@ class ArtifactService:
1549
2322
  suffix = path.stem.removeprefix("outline-")
1550
2323
  if suffix.isdigit():
1551
2324
  max_index = max(max_index, int(suffix))
1552
- selected_outline = read_json(self._paper_selected_outline_path(quest_root), {})
2325
+ _, selected_outline = self._read_selected_outline_record(quest_root)
1553
2326
  selected_id = str((selected_outline or {}).get("outline_id") or "").strip()
1554
2327
  if selected_id.startswith("outline-") and selected_id.removeprefix("outline-").isdigit():
1555
2328
  max_index = max(max_index, int(selected_id.removeprefix("outline-")))
@@ -1582,12 +2355,19 @@ class ArtifactService:
1582
2355
  continue
1583
2356
  normalized_items.append(
1584
2357
  {
2358
+ "exp_id": str(raw.get("exp_id") or "").strip() or None,
1585
2359
  "todo_id": str(raw.get("todo_id") or raw.get("slice_id") or "").strip() or None,
1586
2360
  "slice_id": str(raw.get("slice_id") or "").strip() or None,
1587
2361
  "title": str(raw.get("title") or "").strip() or None,
1588
2362
  "status": str(raw.get("status") or "pending").strip() or "pending",
1589
2363
  "research_question": str(raw.get("research_question") or "").strip() or None,
1590
2364
  "experimental_design": str(raw.get("experimental_design") or "").strip() or None,
2365
+ "tier": str(raw.get("tier") or "").strip() or None,
2366
+ "paper_placement": str(raw.get("paper_placement") or "").strip() or None,
2367
+ "paper_role": str(raw.get("paper_role") or raw.get("paper_placement") or "").strip() or None,
2368
+ "section_id": str(raw.get("section_id") or "").strip() or None,
2369
+ "item_id": str(raw.get("item_id") or raw.get("exp_id") or raw.get("slice_id") or "").strip() or None,
2370
+ "claim_links": self._normalize_string_list(raw.get("claim_links")),
1591
2371
  "completion_condition": str(raw.get("completion_condition") or "").strip() or None,
1592
2372
  "why_now": str(raw.get("why_now") or "").strip() or None,
1593
2373
  "success_criteria": str(raw.get("success_criteria") or "").strip() or None,
@@ -1598,45 +2378,1023 @@ class ArtifactService:
1598
2378
  )
1599
2379
  return normalized_items
1600
2380
 
1601
- def _normalize_paper_outline_record(
2381
+ def _normalize_paper_outline_record(
2382
+ self,
2383
+ *,
2384
+ outline_id: str,
2385
+ title: str | None,
2386
+ note: str | None,
2387
+ story: str | None,
2388
+ ten_questions: list[object] | None,
2389
+ detailed_outline: dict[str, Any] | None,
2390
+ review_result: str | None,
2391
+ status: str,
2392
+ created_at: str | None = None,
2393
+ ) -> dict[str, Any]:
2394
+ normalized_detailed = dict(detailed_outline or {})
2395
+ experimental_designs = self._normalize_string_list(normalized_detailed.get("experimental_designs"))
2396
+ sections = self._normalize_outline_sections(
2397
+ normalized_detailed.get("sections"),
2398
+ experimental_designs=experimental_designs,
2399
+ )
2400
+ resolved_title = (
2401
+ str(title or normalized_detailed.get("title") or outline_id).strip()
2402
+ or outline_id
2403
+ )
2404
+ record = {
2405
+ "schema_version": 1,
2406
+ "outline_id": outline_id,
2407
+ "status": status,
2408
+ "title": resolved_title,
2409
+ "note": str(note or "").strip() or None,
2410
+ "story": str(story or "").strip() or None,
2411
+ "ten_questions": self._normalize_string_list(ten_questions),
2412
+ "detailed_outline": {
2413
+ "title": str(normalized_detailed.get("title") or resolved_title).strip() or resolved_title,
2414
+ "abstract": str(normalized_detailed.get("abstract") or "").strip() or None,
2415
+ "research_questions": self._normalize_string_list(normalized_detailed.get("research_questions")),
2416
+ "methodology": str(normalized_detailed.get("methodology") or "").strip() or None,
2417
+ "experimental_designs": experimental_designs,
2418
+ "contributions": self._normalize_string_list(normalized_detailed.get("contributions")),
2419
+ },
2420
+ "sections": sections,
2421
+ "evidence_contract": self._normalize_outline_evidence_contract(
2422
+ normalized_detailed.get("evidence_contract")
2423
+ ),
2424
+ "review_result": str(review_result or "").strip() or None,
2425
+ "created_at": created_at or utc_now(),
2426
+ "updated_at": utc_now(),
2427
+ }
2428
+ return record
2429
+
2430
+ def _render_outline_section_markdown(self, section: dict[str, Any]) -> str:
2431
+ lines = [
2432
+ f"# {str(section.get('title') or section.get('section_id') or 'Section').strip() or 'Section'}",
2433
+ "",
2434
+ f"- Section id: `{str(section.get('section_id') or 'unknown').strip() or 'unknown'}`",
2435
+ f"- Paper role: `{str(section.get('paper_role') or 'main_text').strip() or 'main_text'}`",
2436
+ f"- Status: `{str(section.get('status') or 'planned').strip() or 'planned'}`",
2437
+ "",
2438
+ "## Claims",
2439
+ "",
2440
+ ]
2441
+ claims = self._normalize_string_list(section.get("claims"))
2442
+ lines.extend([f"- `{item}`" for item in claims] or ["- None recorded."])
2443
+ lines.extend(["", "## Required Items", ""])
2444
+ required_items = self._normalize_string_list(section.get("required_items"))
2445
+ lines.extend([f"- `{item}`" for item in required_items] or ["- None recorded."])
2446
+ lines.extend(["", "## Optional Items", ""])
2447
+ optional_items = self._normalize_string_list(section.get("optional_items"))
2448
+ lines.extend([f"- `{item}`" for item in optional_items] or ["- None recorded."])
2449
+ lines.extend(["", "## Note", "", str(section.get("note") or "Not recorded."), ""])
2450
+ return "\n".join(lines).rstrip() + "\n"
2451
+
2452
+ def _render_outline_section_setup_markdown(self, section: dict[str, Any]) -> str:
2453
+ rows = [dict(item) for item in (section.get("result_table") or []) if isinstance(item, dict)]
2454
+ setup_notes = []
2455
+ for row in rows:
2456
+ setup_text = str(row.get("setup_note") or row.get("setup") or "").strip()
2457
+ if setup_text and setup_text not in setup_notes:
2458
+ setup_notes.append(setup_text)
2459
+ lines = [
2460
+ f"# Setup · {str(section.get('title') or section.get('section_id') or 'Section').strip() or 'Section'}",
2461
+ "",
2462
+ "## Recorded Setup Notes",
2463
+ "",
2464
+ ]
2465
+ lines.extend([f"- {item}" for item in setup_notes] or ["- None recorded yet."])
2466
+ lines.append("")
2467
+ return "\n".join(lines)
2468
+
2469
+ def _render_outline_section_findings_markdown(self, section: dict[str, Any]) -> str:
2470
+ rows = [dict(item) for item in (section.get("result_table") or []) if isinstance(item, dict)]
2471
+ lines = [
2472
+ f"# Findings · {str(section.get('title') or section.get('section_id') or 'Section').strip() or 'Section'}",
2473
+ "",
2474
+ "## Result Highlights",
2475
+ "",
2476
+ ]
2477
+ highlights = []
2478
+ for row in rows:
2479
+ result_summary = str(row.get("result_summary") or "").strip()
2480
+ metric_summary = str(row.get("metric_summary") or "").strip()
2481
+ title = str(row.get("title") or row.get("item_id") or "item").strip() or "item"
2482
+ if result_summary or metric_summary:
2483
+ suffix = f" ({metric_summary})" if metric_summary else ""
2484
+ highlights.append(f"- `{title}`: {result_summary or 'No summary recorded.'}{suffix}")
2485
+ lines.extend(highlights or ["- None recorded yet."])
2486
+ lines.append("")
2487
+ return "\n".join(lines)
2488
+
2489
+ def _render_outline_section_impact_markdown(self, section: dict[str, Any]) -> str:
2490
+ rows = [dict(item) for item in (section.get("result_table") or []) if isinstance(item, dict)]
2491
+ impacts = []
2492
+ for row in rows:
2493
+ impact_text = str(row.get("impact_summary") or row.get("claim_impact") or "").strip()
2494
+ title = str(row.get("title") or row.get("item_id") or "item").strip() or "item"
2495
+ if impact_text:
2496
+ impacts.append(f"- `{title}`: {impact_text}")
2497
+ lines = [
2498
+ f"# Impact · {str(section.get('title') or section.get('section_id') or 'Section').strip() or 'Section'}",
2499
+ "",
2500
+ "## Claim Links",
2501
+ "",
2502
+ ]
2503
+ claim_links = self._normalize_string_list(section.get("claims"))
2504
+ lines.extend([f"- `{item}`" for item in claim_links] or ["- None recorded."])
2505
+ lines.extend(["", "## Impact Notes", ""])
2506
+ lines.extend(impacts or ["- None recorded yet."])
2507
+ lines.append("")
2508
+ return "\n".join(lines)
2509
+
2510
+ def _outline_folder_exists(self, quest_root: Path, *, workspace_root: Path | None = None) -> bool:
2511
+ root = self._paper_outline_root(quest_root, workspace_root=workspace_root, create=False)
2512
+ return (root / "manifest.json").exists()
2513
+
2514
+ def _write_outline_folder_from_record(
2515
+ self,
2516
+ quest_root: Path,
2517
+ record: dict[str, Any],
2518
+ *,
2519
+ workspace_root: Path | None = None,
2520
+ ) -> None:
2521
+ sections = self._normalize_outline_sections(
2522
+ record.get("sections"),
2523
+ experimental_designs=self._normalize_string_list(
2524
+ ((record.get("detailed_outline") or {}) if isinstance(record.get("detailed_outline"), dict) else {}).get(
2525
+ "experimental_designs"
2526
+ )
2527
+ ),
2528
+ )
2529
+ manifest = {
2530
+ "schema_version": 1,
2531
+ "outline_id": str(record.get("outline_id") or "").strip() or None,
2532
+ "status": str(record.get("status") or "selected").strip() or "selected",
2533
+ "title": str(record.get("title") or "").strip() or None,
2534
+ "note": str(record.get("note") or "").strip() or None,
2535
+ "story": str(record.get("story") or "").strip() or None,
2536
+ "ten_questions": self._normalize_string_list(record.get("ten_questions")),
2537
+ "detailed_outline": (
2538
+ dict(record.get("detailed_outline") or {})
2539
+ if isinstance(record.get("detailed_outline"), dict)
2540
+ else {}
2541
+ ),
2542
+ "evidence_contract": self._normalize_outline_evidence_contract(record.get("evidence_contract")),
2543
+ "section_order": [
2544
+ str(section.get("section_id") or "").strip()
2545
+ for section in sections
2546
+ if str(section.get("section_id") or "").strip()
2547
+ ],
2548
+ "sections": [
2549
+ {
2550
+ "section_id": str(section.get("section_id") or "").strip() or None,
2551
+ "title": str(section.get("title") or "").strip() or None,
2552
+ "paper_role": str(section.get("paper_role") or "main_text").strip() or "main_text",
2553
+ "claims": self._normalize_string_list(section.get("claims")),
2554
+ "required_items": self._normalize_string_list(section.get("required_items")),
2555
+ "optional_items": self._normalize_string_list(section.get("optional_items")),
2556
+ "status": str(section.get("status") or "planned").strip() or "planned",
2557
+ "note": str(section.get("note") or "").strip() or None,
2558
+ }
2559
+ for section in sections
2560
+ ],
2561
+ "created_at": str(record.get("created_at") or "").strip() or utc_now(),
2562
+ "updated_at": utc_now(),
2563
+ }
2564
+ for paper_root in self._paper_active_sync_roots(quest_root, workspace_root=workspace_root):
2565
+ outline_root = ensure_dir(paper_root / "outline")
2566
+ write_json(outline_root / "manifest.json", manifest)
2567
+ sections_root = ensure_dir(outline_root / "sections")
2568
+ expected: set[str] = set()
2569
+ for section in sections:
2570
+ section_id = str(section.get("section_id") or "").strip()
2571
+ if not section_id:
2572
+ continue
2573
+ expected.add(section_id)
2574
+ section_dir = ensure_dir(sections_root / section_id)
2575
+ write_text(section_dir / "section.md", self._render_outline_section_markdown(section))
2576
+ write_json(
2577
+ section_dir / "result_table.json",
2578
+ {
2579
+ "schema_version": 1,
2580
+ "section_id": section_id,
2581
+ "title": str(section.get("title") or section_id).strip() or section_id,
2582
+ "rows": [dict(item) for item in (section.get("result_table") or []) if isinstance(item, dict)],
2583
+ "updated_at": utc_now(),
2584
+ },
2585
+ )
2586
+ write_text(section_dir / "experiment_setup.md", self._render_outline_section_setup_markdown(section))
2587
+ write_text(section_dir / "findings.md", self._render_outline_section_findings_markdown(section))
2588
+ write_text(section_dir / "impact.md", self._render_outline_section_impact_markdown(section))
2589
+ for existing in sorted(sections_root.iterdir()):
2590
+ if not existing.is_dir() or existing.name in expected:
2591
+ continue
2592
+ shutil.rmtree(existing)
2593
+
2594
+ def _read_outline_folder_to_record(
2595
+ self,
2596
+ quest_root: Path,
2597
+ *,
2598
+ workspace_root: Path | None = None,
2599
+ ) -> dict[str, Any]:
2600
+ outline_root = self._paper_outline_root(quest_root, workspace_root=workspace_root, create=False)
2601
+ manifest_path = outline_root / "manifest.json"
2602
+ manifest = read_json(manifest_path, {})
2603
+ if not isinstance(manifest, dict) or not manifest:
2604
+ return {}
2605
+ manifest_sections = [dict(item) for item in (manifest.get("sections") or []) if isinstance(item, dict)]
2606
+ section_order = [
2607
+ str(item).strip() for item in (manifest.get("section_order") or []) if str(item).strip()
2608
+ ]
2609
+ by_id = {
2610
+ str(item.get("section_id") or "").strip(): dict(item)
2611
+ for item in manifest_sections
2612
+ if str(item.get("section_id") or "").strip()
2613
+ }
2614
+ for section_id in section_order:
2615
+ by_id.setdefault(
2616
+ section_id,
2617
+ {
2618
+ "section_id": section_id,
2619
+ "title": section_id,
2620
+ "paper_role": "main_text",
2621
+ "claims": [],
2622
+ "required_items": [],
2623
+ "optional_items": [],
2624
+ "status": "planned",
2625
+ },
2626
+ )
2627
+ sections_root = outline_root / "sections"
2628
+ if sections_root.exists():
2629
+ for section_dir in sorted(sections_root.iterdir()):
2630
+ if not section_dir.is_dir():
2631
+ continue
2632
+ section_id = section_dir.name
2633
+ section = dict(by_id.get(section_id) or {})
2634
+ section.setdefault("section_id", section_id)
2635
+ section.setdefault("title", section_id)
2636
+ result_table_payload = read_json(section_dir / "result_table.json", {})
2637
+ rows = result_table_payload.get("rows") if isinstance(result_table_payload, dict) else []
2638
+ section["result_table"] = self._normalize_outline_result_table(rows)
2639
+ by_id[section_id] = section
2640
+ ordered_sections: list[dict[str, Any]] = []
2641
+ emitted: set[str] = set()
2642
+ for section_id in section_order:
2643
+ section = by_id.get(section_id)
2644
+ if section is None:
2645
+ continue
2646
+ ordered_sections.append(section)
2647
+ emitted.add(section_id)
2648
+ for section_id, section in by_id.items():
2649
+ if section_id in emitted:
2650
+ continue
2651
+ ordered_sections.append(section)
2652
+ record = {
2653
+ "schema_version": 1,
2654
+ "outline_id": str(manifest.get("outline_id") or "").strip() or None,
2655
+ "status": str(manifest.get("status") or "selected").strip() or "selected",
2656
+ "title": str(manifest.get("title") or "").strip() or None,
2657
+ "note": str(manifest.get("note") or "").strip() or None,
2658
+ "story": str(manifest.get("story") or "").strip() or None,
2659
+ "ten_questions": self._normalize_string_list(manifest.get("ten_questions")),
2660
+ "detailed_outline": (
2661
+ dict(manifest.get("detailed_outline") or {})
2662
+ if isinstance(manifest.get("detailed_outline"), dict)
2663
+ else {}
2664
+ ),
2665
+ "sections": ordered_sections,
2666
+ "evidence_contract": self._normalize_outline_evidence_contract(manifest.get("evidence_contract")),
2667
+ "created_at": str(manifest.get("created_at") or "").strip() or None,
2668
+ "updated_at": str(manifest.get("updated_at") or "").strip() or utc_now(),
2669
+ }
2670
+ return record
2671
+
2672
+ def _read_selected_outline_record(
2673
+ self,
2674
+ quest_root: Path,
2675
+ *,
2676
+ workspace_root: Path | None = None,
2677
+ ) -> tuple[Path | None, dict[str, Any]]:
2678
+ candidates: list[tuple[tuple[str, float], Path, dict[str, Any]]] = []
2679
+ for paper_root in self._paper_sync_roots(quest_root):
2680
+ outline_root = paper_root / "outline"
2681
+ manifest_path = outline_root / "manifest.json"
2682
+ if manifest_path.exists():
2683
+ record = self._read_outline_folder_to_record(quest_root, workspace_root=paper_root.parent)
2684
+ if record:
2685
+ candidates.append(
2686
+ (
2687
+ (
2688
+ str(record.get("updated_at") or record.get("created_at") or ""),
2689
+ manifest_path.stat().st_mtime if manifest_path.exists() else 0.0,
2690
+ ),
2691
+ manifest_path,
2692
+ record,
2693
+ )
2694
+ )
2695
+ continue
2696
+ selected_outline_path = paper_root / "selected_outline.json"
2697
+ if not selected_outline_path.exists():
2698
+ continue
2699
+ payload = read_json(selected_outline_path, {})
2700
+ if not isinstance(payload, dict) or not payload:
2701
+ continue
2702
+ candidates.append(
2703
+ (
2704
+ (
2705
+ str(payload.get("updated_at") or payload.get("created_at") or ""),
2706
+ selected_outline_path.stat().st_mtime if selected_outline_path.exists() else 0.0,
2707
+ ),
2708
+ selected_outline_path,
2709
+ payload,
2710
+ )
2711
+ )
2712
+ if not candidates:
2713
+ return None, {}
2714
+ candidates.sort(key=lambda item: item[0])
2715
+ _, path, payload = candidates[-1]
2716
+ return path, payload
2717
+
2718
+ def _compile_selected_outline_json(
2719
+ self,
2720
+ quest_root: Path,
2721
+ *,
2722
+ workspace_root: Path | None = None,
2723
+ ) -> dict[str, Any]:
2724
+ record = self._read_outline_folder_to_record(quest_root, workspace_root=workspace_root)
2725
+ if not record:
2726
+ return {}
2727
+ for paper_root in self._paper_active_sync_roots(quest_root, workspace_root=workspace_root):
2728
+ write_json(paper_root / "selected_outline.json", record)
2729
+ return record
2730
+
2731
+ def _read_paper_evidence_ledger(self, quest_root: Path) -> dict[str, Any]:
2732
+ candidates: list[tuple[tuple[str, float], dict[str, Any]]] = []
2733
+ for paper_root in self._paper_sync_roots(quest_root):
2734
+ path = paper_root / "evidence_ledger.json"
2735
+ if not path.exists():
2736
+ continue
2737
+ payload = read_json(path, {})
2738
+ if not isinstance(payload, dict) or not payload:
2739
+ continue
2740
+ candidates.append(
2741
+ (
2742
+ (
2743
+ str(payload.get("updated_at") or payload.get("created_at") or ""),
2744
+ path.stat().st_mtime if path.exists() else 0.0,
2745
+ ),
2746
+ payload,
2747
+ )
2748
+ )
2749
+ if not candidates:
2750
+ return {
2751
+ "schema_version": 1,
2752
+ "selected_outline_ref": None,
2753
+ "items": [],
2754
+ "updated_at": utc_now(),
2755
+ }
2756
+ candidates.sort(key=lambda item: item[0])
2757
+ payload = candidates[-1][1]
2758
+ items = [dict(item) for item in (payload.get("items") or []) if isinstance(item, dict)]
2759
+ return {
2760
+ "schema_version": 1,
2761
+ "selected_outline_ref": str(payload.get("selected_outline_ref") or "").strip() or None,
2762
+ "items": items,
2763
+ "updated_at": str(payload.get("updated_at") or payload.get("created_at") or "").strip() or utc_now(),
2764
+ }
2765
+
2766
+ def _paper_evidence_key_metrics(
2767
+ self,
2768
+ *,
2769
+ metric_rows: list[dict[str, Any]] | None = None,
2770
+ metrics_summary: dict[str, Any] | None = None,
2771
+ ) -> list[dict[str, Any]]:
2772
+ rows: list[dict[str, Any]] = []
2773
+ seen: set[str] = set()
2774
+ for row in metric_rows or []:
2775
+ if not isinstance(row, dict):
2776
+ continue
2777
+ metric_id = str(row.get("metric_id") or row.get("name") or "").strip()
2778
+ if not metric_id or metric_id in seen:
2779
+ continue
2780
+ seen.add(metric_id)
2781
+ rows.append(
2782
+ {
2783
+ "metric_id": metric_id,
2784
+ "value": row.get("value"),
2785
+ "direction": str(row.get("direction") or "").strip() or None,
2786
+ "decimals": row.get("decimals"),
2787
+ }
2788
+ )
2789
+ if len(rows) >= 6:
2790
+ return rows
2791
+ for metric_id, value in (metrics_summary or {}).items():
2792
+ text = str(metric_id or "").strip()
2793
+ if not text or text in seen:
2794
+ continue
2795
+ seen.add(text)
2796
+ rows.append({"metric_id": text, "value": value, "direction": None, "decimals": None})
2797
+ if len(rows) >= 6:
2798
+ break
2799
+ return rows
2800
+
2801
+ def _paper_metric_summary_text(self, key_metrics: list[dict[str, Any]] | None) -> str | None:
2802
+ parts: list[str] = []
2803
+ for item in key_metrics or []:
2804
+ if not isinstance(item, dict):
2805
+ continue
2806
+ metric_id = str(item.get("metric_id") or "").strip()
2807
+ if not metric_id:
2808
+ continue
2809
+ parts.append(
2810
+ f"{metric_id}={self._format_metric_value(item.get('value'), item.get('decimals'))}"
2811
+ )
2812
+ return "; ".join(parts) if parts else None
2813
+
2814
+ def _render_paper_evidence_ledger_markdown(self, payload: dict[str, Any]) -> str:
2815
+ items = [dict(item) for item in (payload.get("items") or []) if isinstance(item, dict)]
2816
+ lines = [
2817
+ "# Paper Evidence Ledger",
2818
+ "",
2819
+ f"- Selected outline: `{str(payload.get('selected_outline_ref') or 'none').strip() or 'none'}`",
2820
+ f"- Item count: `{len(items)}`",
2821
+ f"- Updated at: `{str(payload.get('updated_at') or utc_now()).strip() or utc_now()}`",
2822
+ "",
2823
+ "| Item | Kind | Section | Role | Status | Metrics | Source |",
2824
+ "|---|---|---|---|---|---|---|",
2825
+ ]
2826
+ for item in items:
2827
+ metrics_text = self._paper_metric_summary_text(
2828
+ [dict(metric) for metric in (item.get("key_metrics") or []) if isinstance(metric, dict)]
2829
+ ) or "-"
2830
+ source_paths = [str(path).strip() for path in (item.get("source_paths") or []) if str(path).strip()]
2831
+ source_text = ", ".join(f"`{path}`" for path in source_paths[:2]) or "-"
2832
+ lines.append(
2833
+ "| "
2834
+ + " | ".join(
2835
+ [
2836
+ f"`{str(item.get('item_id') or 'unknown').strip() or 'unknown'}`",
2837
+ str(item.get("kind") or "-"),
2838
+ str(item.get("section_id") or "-"),
2839
+ str(item.get("paper_role") or "-"),
2840
+ str(item.get("status") or "-"),
2841
+ metrics_text,
2842
+ source_text,
2843
+ ]
2844
+ )
2845
+ + " |"
2846
+ )
2847
+ if not items:
2848
+ lines.extend(["| - | - | - | - | - | - | - |", ""])
2849
+ else:
2850
+ lines.append("")
2851
+ for item in items:
2852
+ lines.extend(
2853
+ [
2854
+ f"## {str(item.get('item_id') or 'unknown').strip() or 'unknown'}",
2855
+ "",
2856
+ f"- Title: {str(item.get('title') or item.get('item_id') or 'Unknown').strip() or 'Unknown'}",
2857
+ f"- Kind: `{str(item.get('kind') or 'unknown').strip() or 'unknown'}`",
2858
+ f"- Section: `{str(item.get('section_id') or 'unmapped').strip() or 'unmapped'}`",
2859
+ f"- Role: `{str(item.get('paper_role') or 'unmapped').strip() or 'unmapped'}`",
2860
+ f"- Status: `{str(item.get('status') or 'unknown').strip() or 'unknown'}`",
2861
+ f"- Claims: {', '.join(str(value).strip() for value in (item.get('claim_links') or []) if str(value).strip()) or 'none'}",
2862
+ "",
2863
+ "### Setup",
2864
+ "",
2865
+ str(item.get("setup") or "Not recorded."),
2866
+ "",
2867
+ "### Result Summary",
2868
+ "",
2869
+ str(item.get("result_summary") or "Not recorded."),
2870
+ "",
2871
+ "### Source Paths",
2872
+ "",
2873
+ ]
2874
+ )
2875
+ if source_paths := [str(path).strip() for path in (item.get("source_paths") or []) if str(path).strip()]:
2876
+ lines.extend([f"- `{path}`" for path in source_paths])
2877
+ else:
2878
+ lines.append("- None recorded.")
2879
+ lines.append("")
2880
+ return "\n".join(lines).rstrip() + "\n"
2881
+
2882
+ def _write_paper_evidence_ledger(
2883
+ self,
2884
+ quest_root: Path,
2885
+ payload: dict[str, Any],
2886
+ *,
2887
+ workspace_root: Path | None = None,
2888
+ ) -> dict[str, Any]:
2889
+ normalized = {
2890
+ "schema_version": 1,
2891
+ "selected_outline_ref": str(payload.get("selected_outline_ref") or "").strip() or None,
2892
+ "items": [dict(item) for item in (payload.get("items") or []) if isinstance(item, dict)],
2893
+ "updated_at": utc_now(),
2894
+ }
2895
+ markdown = self._render_paper_evidence_ledger_markdown(normalized)
2896
+ for paper_root in self._paper_active_sync_roots(quest_root, workspace_root=workspace_root):
2897
+ write_json(paper_root / "evidence_ledger.json", normalized)
2898
+ write_text(paper_root / "evidence_ledger.md", markdown)
2899
+ return normalized
2900
+
2901
+ def _read_selected_outline_for_sync(
2902
+ self,
2903
+ quest_root: Path,
2904
+ *,
2905
+ workspace_root: Path | None = None,
2906
+ ) -> tuple[Path | None, dict[str, Any]]:
2907
+ return self._read_selected_outline_record(quest_root, workspace_root=workspace_root)
2908
+
2909
+ def _write_selected_outline_sync(
2910
+ self,
2911
+ quest_root: Path,
2912
+ payload: dict[str, Any],
2913
+ *,
2914
+ workspace_root: Path | None = None,
2915
+ ) -> None:
2916
+ outline_id = str(payload.get("outline_id") or "").strip()
2917
+ self._write_outline_folder_from_record(quest_root, payload, workspace_root=workspace_root)
2918
+ compiled = self._compile_selected_outline_json(quest_root, workspace_root=workspace_root) or dict(payload)
2919
+ canonical_path = ensure_dir(quest_root / "paper") / "selected_outline.json"
2920
+ write_json(canonical_path, compiled)
2921
+ for paper_root in self._paper_active_sync_roots(quest_root, workspace_root=workspace_root):
2922
+ path = paper_root / "selected_outline.json"
2923
+ if path.resolve() == canonical_path.resolve():
2924
+ continue
2925
+ existing = read_json(path, {}) if path.exists() else {}
2926
+ if path.exists():
2927
+ existing_outline_id = str(existing.get("outline_id") or "").strip() if isinstance(existing, dict) else ""
2928
+ if existing_outline_id and existing_outline_id != outline_id:
2929
+ continue
2930
+ write_json(path, compiled)
2931
+
2932
+ def _outline_status_from_rows(self, section: dict[str, Any]) -> str:
2933
+ rows = [dict(item) for item in (section.get("result_table") or []) if isinstance(item, dict)]
2934
+ by_item = {
2935
+ str(item.get("item_id") or "").strip(): str(item.get("status") or "").strip() or "pending"
2936
+ for item in rows
2937
+ if str(item.get("item_id") or "").strip()
2938
+ }
2939
+ required_items = self._normalize_string_list(section.get("required_items"))
2940
+ if required_items:
2941
+ ready_count = sum(1 for item_id in required_items if self._paper_ready_status(by_item.get(item_id)))
2942
+ present_count = sum(1 for item_id in required_items if item_id in by_item)
2943
+ if ready_count == len(required_items):
2944
+ return "ready"
2945
+ if ready_count > 0:
2946
+ return "partial"
2947
+ if present_count > 0:
2948
+ return "pending"
2949
+ return "planned"
2950
+ if any(self._paper_ready_status(item.get("status")) for item in rows):
2951
+ return "ready"
2952
+ if rows:
2953
+ return "pending"
2954
+ return str(section.get("status") or "planned").strip() or "planned"
2955
+
2956
+ def _sync_outline_sections(
2957
+ self,
2958
+ quest_root: Path,
2959
+ *,
2960
+ items: list[dict[str, Any]],
2961
+ workspace_root: Path | None = None,
2962
+ ) -> dict[str, Any] | None:
2963
+ outline_path, record = self._read_selected_outline_for_sync(quest_root, workspace_root=workspace_root)
2964
+ if outline_path is None or not record:
2965
+ return None
2966
+ detailed_outline = (
2967
+ dict(record.get("detailed_outline") or {})
2968
+ if isinstance(record.get("detailed_outline"), dict)
2969
+ else {}
2970
+ )
2971
+ sections = self._normalize_outline_sections(
2972
+ record.get("sections"),
2973
+ experimental_designs=self._normalize_string_list(detailed_outline.get("experimental_designs")),
2974
+ )
2975
+ if not sections:
2976
+ sections = self._normalize_outline_sections([], experimental_designs=["Main Results"])
2977
+ by_id = {
2978
+ str(section.get("section_id") or "").strip(): dict(section)
2979
+ for section in sections
2980
+ if str(section.get("section_id") or "").strip()
2981
+ }
2982
+ for raw in items:
2983
+ if not isinstance(raw, dict):
2984
+ continue
2985
+ item_id = str(raw.get("item_id") or "").strip()
2986
+ if not item_id:
2987
+ continue
2988
+ section_id = str(raw.get("section_id") or "").strip() or "main-results"
2989
+ section = dict(by_id.get(section_id) or {})
2990
+ if not section:
2991
+ section = {
2992
+ "section_id": section_id,
2993
+ "title": str(raw.get("section_title") or raw.get("title") or section_id).strip() or section_id,
2994
+ "paper_role": str(raw.get("paper_role") or "main_text").strip() or "main_text",
2995
+ "claims": [],
2996
+ "required_items": [],
2997
+ "optional_items": [],
2998
+ "status": "planned",
2999
+ "note": None,
3000
+ "result_table": [],
3001
+ }
3002
+ claims = self._normalize_string_list(section.get("claims"))
3003
+ for claim in self._normalize_string_list(raw.get("claim_links")):
3004
+ if claim not in claims:
3005
+ claims.append(claim)
3006
+ section["claims"] = claims
3007
+ paper_role = str(raw.get("paper_role") or section.get("paper_role") or "main_text").strip() or "main_text"
3008
+ section["paper_role"] = paper_role
3009
+ required_items = self._normalize_string_list(section.get("required_items"))
3010
+ optional_items = self._normalize_string_list(section.get("optional_items"))
3011
+ target_list = required_items if paper_role == "main_text" else optional_items
3012
+ if item_id not in target_list:
3013
+ target_list.append(item_id)
3014
+ if paper_role == "main_text":
3015
+ optional_items = [value for value in optional_items if value != item_id]
3016
+ else:
3017
+ required_items = [value for value in required_items if value != item_id]
3018
+ section["required_items"] = required_items
3019
+ section["optional_items"] = optional_items
3020
+ row = {
3021
+ "item_id": item_id,
3022
+ "title": str(raw.get("title") or item_id).strip() or item_id,
3023
+ "kind": str(raw.get("kind") or "").strip() or None,
3024
+ "paper_role": paper_role,
3025
+ "status": str(raw.get("status") or "pending").strip() or "pending",
3026
+ "claim_links": self._normalize_string_list(raw.get("claim_links")),
3027
+ "setup_note": str(raw.get("setup_note") or raw.get("setup") or "").strip() or None,
3028
+ "metric_summary": str(raw.get("metric_summary") or "").strip() or None,
3029
+ "result_summary": str(raw.get("result_summary") or "").strip() or None,
3030
+ "impact_summary": str(raw.get("impact_summary") or raw.get("claim_impact") or "").strip() or None,
3031
+ "source_paths": self._normalize_string_list(raw.get("source_paths")),
3032
+ "updated_at": utc_now(),
3033
+ }
3034
+ existing_rows = [
3035
+ dict(item) for item in (section.get("result_table") or []) if isinstance(item, dict)
3036
+ ]
3037
+ merged_rows: list[dict[str, Any]] = []
3038
+ replaced = False
3039
+ for existing in existing_rows:
3040
+ if str(existing.get("item_id") or "").strip() != item_id:
3041
+ merged_rows.append(existing)
3042
+ continue
3043
+ merged = dict(existing)
3044
+ for key, value in row.items():
3045
+ if value in (None, "", []):
3046
+ continue
3047
+ merged[key] = value
3048
+ merged_rows.append(merged)
3049
+ replaced = True
3050
+ if not replaced:
3051
+ merged_rows.append(row)
3052
+ section["result_table"] = merged_rows
3053
+ section["status"] = self._outline_status_from_rows(section)
3054
+ by_id[section_id] = section
3055
+ record["sections"] = list(by_id.values())
3056
+ record["evidence_contract"] = self._normalize_outline_evidence_contract(record.get("evidence_contract"))
3057
+ record["updated_at"] = utc_now()
3058
+ self._write_selected_outline_sync(quest_root, record, workspace_root=workspace_root)
3059
+ return record
3060
+
3061
+ @staticmethod
3062
+ def _paper_evidence_item_key(item: dict[str, Any]) -> tuple[str, str, str]:
3063
+ return (
3064
+ str(item.get("item_id") or "").strip(),
3065
+ str(item.get("campaign_id") or "").strip(),
3066
+ str(item.get("slice_id") or item.get("run_id") or "").strip(),
3067
+ )
3068
+
3069
+ def _upsert_paper_evidence_item(
3070
+ self,
3071
+ quest_root: Path,
3072
+ item: dict[str, Any],
3073
+ *,
3074
+ workspace_root: Path | None = None,
3075
+ ) -> dict[str, Any]:
3076
+ ledger = self._read_paper_evidence_ledger(quest_root)
3077
+ items = [dict(entry) for entry in (ledger.get("items") or []) if isinstance(entry, dict)]
3078
+ key = self._paper_evidence_item_key(item)
3079
+ merged_items: list[dict[str, Any]] = []
3080
+ replaced = False
3081
+ for existing in items:
3082
+ if self._paper_evidence_item_key(existing) != key:
3083
+ merged_items.append(existing)
3084
+ continue
3085
+ merged = dict(existing)
3086
+ for field, value in item.items():
3087
+ if value in (None, "", []):
3088
+ continue
3089
+ merged[field] = value
3090
+ merged["updated_at"] = utc_now()
3091
+ merged_items.append(merged)
3092
+ replaced = True
3093
+ if not replaced:
3094
+ merged_items.append({**item, "created_at": utc_now(), "updated_at": utc_now()})
3095
+ merged_items.sort(
3096
+ key=lambda payload: (
3097
+ str(payload.get("section_id") or ""),
3098
+ str(payload.get("item_id") or ""),
3099
+ str(payload.get("updated_at") or ""),
3100
+ )
3101
+ )
3102
+ written = self._write_paper_evidence_ledger(
3103
+ quest_root,
3104
+ {
3105
+ "selected_outline_ref": item.get("selected_outline_ref") or ledger.get("selected_outline_ref"),
3106
+ "items": merged_items,
3107
+ },
3108
+ workspace_root=workspace_root,
3109
+ )
3110
+ metric_summary = self._paper_metric_summary_text(
3111
+ [dict(metric) for metric in (item.get("key_metrics") or []) if isinstance(metric, dict)]
3112
+ )
3113
+ self._sync_outline_sections(
3114
+ quest_root,
3115
+ items=[
3116
+ {
3117
+ "item_id": item.get("item_id"),
3118
+ "title": item.get("title"),
3119
+ "kind": item.get("kind"),
3120
+ "paper_role": item.get("paper_role"),
3121
+ "status": item.get("status"),
3122
+ "claim_links": item.get("claim_links"),
3123
+ "section_id": item.get("section_id"),
3124
+ "setup": item.get("setup"),
3125
+ "metric_summary": metric_summary,
3126
+ "result_summary": item.get("result_summary"),
3127
+ "claim_impact": item.get("claim_impact"),
3128
+ "source_paths": item.get("source_paths"),
3129
+ }
3130
+ ],
3131
+ workspace_root=workspace_root,
3132
+ )
3133
+ self._write_paper_line_state(quest_root, workspace_root=workspace_root)
3134
+ return written
3135
+
3136
+ def _paper_bundle_gate_status(self, quest_root: Path, *, workspace_root: Path | None = None) -> dict[str, Any]:
3137
+ outline_path, selected_outline = self._read_selected_outline_for_sync(quest_root, workspace_root=workspace_root)
3138
+ selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
3139
+ detailed_outline = (
3140
+ dict(selected_outline.get("detailed_outline") or {})
3141
+ if isinstance(selected_outline.get("detailed_outline"), dict)
3142
+ else {}
3143
+ )
3144
+ sections = self._normalize_outline_sections(
3145
+ selected_outline.get("sections"),
3146
+ experimental_designs=self._normalize_string_list(detailed_outline.get("experimental_designs")),
3147
+ )
3148
+ ledger = self._read_paper_evidence_ledger(quest_root)
3149
+ ledger_items = [dict(item) for item in (ledger.get("items") or []) if isinstance(item, dict)]
3150
+ ledger_by_item = {
3151
+ str(item.get("item_id") or "").strip(): dict(item)
3152
+ for item in ledger_items
3153
+ if str(item.get("item_id") or "").strip()
3154
+ }
3155
+ unresolved_required_items: list[dict[str, Any]] = []
3156
+ ready_sections = 0
3157
+ for section in sections:
3158
+ required_items = self._normalize_string_list(section.get("required_items"))
3159
+ section_ready = True
3160
+ for item_id in required_items:
3161
+ ledger_item = ledger_by_item.get(item_id)
3162
+ if ledger_item is None or not self._paper_ready_status(ledger_item.get("status")):
3163
+ unresolved_required_items.append(
3164
+ {
3165
+ "section_id": section.get("section_id"),
3166
+ "section_title": section.get("title"),
3167
+ "item_id": item_id,
3168
+ "status": ledger_item.get("status") if isinstance(ledger_item, dict) else None,
3169
+ }
3170
+ )
3171
+ section_ready = False
3172
+ if section_ready and required_items:
3173
+ ready_sections += 1
3174
+ selected_outline_ref = str(selected_outline.get("outline_id") or ledger.get("selected_outline_ref") or "").strip() or None
3175
+ completed_analysis: list[dict[str, Any]] = []
3176
+ campaigns_root = quest_root / ".ds" / "analysis_campaigns"
3177
+ if campaigns_root.exists():
3178
+ for path in sorted(campaigns_root.glob("analysis-*.json")):
3179
+ manifest = read_json(path, {})
3180
+ if not isinstance(manifest, dict) or not manifest:
3181
+ continue
3182
+ manifest_outline_ref = str(manifest.get("selected_outline_ref") or "").strip() or None
3183
+ if selected_outline_ref and manifest_outline_ref != selected_outline_ref:
3184
+ continue
3185
+ for slice_item in manifest.get("slices") or []:
3186
+ if not isinstance(slice_item, dict):
3187
+ continue
3188
+ status = str(slice_item.get("status") or "").strip().lower()
3189
+ if status in {"", "pending"}:
3190
+ continue
3191
+ completed_analysis.append(
3192
+ {
3193
+ "campaign_id": str(manifest.get("campaign_id") or "").strip() or None,
3194
+ "slice_id": str(slice_item.get("slice_id") or "").strip() or None,
3195
+ "item_id": str(slice_item.get("item_id") or "").strip() or None,
3196
+ "section_id": str(slice_item.get("section_id") or "").strip() or None,
3197
+ "status": status,
3198
+ "title": str(slice_item.get("title") or slice_item.get("slice_id") or "").strip() or None,
3199
+ }
3200
+ )
3201
+ unmapped_completed_items: list[dict[str, Any]] = []
3202
+ for item in completed_analysis:
3203
+ item_id = str(item.get("item_id") or "").strip()
3204
+ if not item_id:
3205
+ unmapped_completed_items.append(item)
3206
+ continue
3207
+ ledger_item = ledger_by_item.get(item_id)
3208
+ if ledger_item is None or not str(ledger_item.get("section_id") or "").strip():
3209
+ unmapped_completed_items.append(item)
3210
+ return {
3211
+ "ok": not unresolved_required_items and not unmapped_completed_items,
3212
+ "outline_path": str(outline_path) if outline_path else None,
3213
+ "selected_outline_ref": selected_outline_ref,
3214
+ "section_count": len(sections),
3215
+ "ready_section_count": ready_sections,
3216
+ "ledger_item_count": len(ledger_items),
3217
+ "unresolved_required_items": unresolved_required_items,
3218
+ "unmapped_completed_items": unmapped_completed_items,
3219
+ }
3220
+
3221
+ def _paper_contract_health_payload(
3222
+ self,
3223
+ quest_root: Path,
3224
+ *,
3225
+ workspace_root: Path | None = None,
3226
+ pending_slices: int | None = None,
3227
+ ) -> dict[str, Any]:
3228
+ gate_status = self._paper_bundle_gate_status(quest_root, workspace_root=workspace_root)
3229
+ paper_root = self._paper_root(quest_root, workspace_root=workspace_root, create=True)
3230
+ bundle_manifest_path = paper_root / "paper_bundle_manifest.json"
3231
+ bundle_manifest = read_json(bundle_manifest_path, {}) if bundle_manifest_path.exists() else {}
3232
+ bundle_manifest = bundle_manifest if isinstance(bundle_manifest, dict) else {}
3233
+ submission_checklist_path = paper_root / "review" / "submission_checklist.json"
3234
+ submission_checklist = read_json(submission_checklist_path, {}) if submission_checklist_path.exists() else {}
3235
+ submission_checklist = submission_checklist if isinstance(submission_checklist, dict) else {}
3236
+ unresolved_required_items = [
3237
+ dict(item) for item in (gate_status.get("unresolved_required_items") or []) if isinstance(item, dict)
3238
+ ]
3239
+ unmapped_completed_items = [
3240
+ dict(item) for item in (gate_status.get("unmapped_completed_items") or []) if isinstance(item, dict)
3241
+ ]
3242
+ normalized_pending_slices = max(0, int(pending_slices or 0))
3243
+ blocking_reasons: list[str] = []
3244
+ if unmapped_completed_items:
3245
+ blocking_reasons.append("completed analysis remains unmapped into the paper contract")
3246
+ if unresolved_required_items:
3247
+ blocking_reasons.append("required outline items are still unresolved")
3248
+ if normalized_pending_slices > 0:
3249
+ blocking_reasons.append("paper-facing supplementary slices are still pending")
3250
+
3251
+ if unmapped_completed_items:
3252
+ recommended_next_stage = "write"
3253
+ recommended_action = "sync_paper_contract"
3254
+ elif unresolved_required_items or normalized_pending_slices > 0:
3255
+ recommended_next_stage = "analysis-campaign"
3256
+ recommended_action = "complete_required_supplementary"
3257
+ else:
3258
+ recommended_next_stage = "write"
3259
+ recommended_action = "continue_writing"
3260
+
3261
+ contract_ok = not unresolved_required_items and not unmapped_completed_items
3262
+ writing_ready = contract_ok and normalized_pending_slices == 0
3263
+ overall_status = str(submission_checklist.get("overall_status") or bundle_manifest.get("status") or "").strip().lower()
3264
+ delivered_at = str(
3265
+ bundle_manifest.get("paper_delivered_to_user_at")
3266
+ or bundle_manifest.get("delivered_at")
3267
+ or submission_checklist.get("paper_delivered_to_user_at")
3268
+ or ""
3269
+ ).strip() or None
3270
+ bundle_present = bundle_manifest_path.exists()
3271
+ delivery_state = "not_ready"
3272
+ closure_state = "bundle_not_ready"
3273
+ keep_bundle_fixed_by_default = False
3274
+ if bundle_present:
3275
+ delivery_state = "bundle_ready"
3276
+ closure_state = "delivery_ready"
3277
+ if delivered_at or "delivered" in overall_status:
3278
+ delivery_state = "delivered"
3279
+ closure_state = "delivered_continue_research" if "continue" in overall_status else "delivered_parked"
3280
+ keep_bundle_fixed_by_default = True
3281
+
3282
+ return {
3283
+ "contract_ok": contract_ok,
3284
+ "writing_ready": writing_ready,
3285
+ "finalize_ready": writing_ready and bundle_present,
3286
+ "bundle_present": bundle_present,
3287
+ "delivery_state": delivery_state,
3288
+ "closure_state": closure_state,
3289
+ "delivered_at": delivered_at,
3290
+ "keep_bundle_fixed_by_default": keep_bundle_fixed_by_default,
3291
+ "selected_outline_ref": gate_status.get("selected_outline_ref"),
3292
+ "section_count": int(gate_status.get("section_count") or 0),
3293
+ "ready_section_count": int(gate_status.get("ready_section_count") or 0),
3294
+ "ledger_item_count": int(gate_status.get("ledger_item_count") or 0),
3295
+ "unresolved_required_count": len(unresolved_required_items),
3296
+ "unmapped_completed_count": len(unmapped_completed_items),
3297
+ "open_supplementary_count": normalized_pending_slices,
3298
+ "blocking_reasons": blocking_reasons,
3299
+ "recommended_next_stage": recommended_next_stage,
3300
+ "recommended_action": recommended_action,
3301
+ "unresolved_required_items": unresolved_required_items[:12],
3302
+ "unmapped_completed_items": unmapped_completed_items[:12],
3303
+ }
3304
+
3305
+ def _write_paper_line_state(
1602
3306
  self,
3307
+ quest_root: Path,
1603
3308
  *,
1604
- outline_id: str,
1605
- title: str | None,
1606
- note: str | None,
1607
- story: str | None,
1608
- ten_questions: list[object] | None,
1609
- detailed_outline: dict[str, Any] | None,
1610
- review_result: str | None,
1611
- status: str,
1612
- created_at: str | None = None,
3309
+ workspace_root: Path | None = None,
3310
+ source_branch: str | None = None,
3311
+ source_run_id: str | None = None,
3312
+ source_idea_id: str | None = None,
1613
3313
  ) -> dict[str, Any]:
1614
- normalized_detailed = dict(detailed_outline or {})
1615
- resolved_title = (
1616
- str(title or normalized_detailed.get("title") or outline_id).strip()
1617
- or outline_id
3314
+ state = self.quest_service.read_research_state(quest_root)
3315
+ selected_outline_path, selected_outline = self._read_selected_outline_for_sync(
3316
+ quest_root,
3317
+ workspace_root=workspace_root,
1618
3318
  )
1619
- record = {
3319
+ selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
3320
+ detailed_outline = (
3321
+ dict(selected_outline.get("detailed_outline") or {})
3322
+ if isinstance(selected_outline.get("detailed_outline"), dict)
3323
+ else {}
3324
+ )
3325
+ sections = self._normalize_outline_sections(
3326
+ selected_outline.get("sections"),
3327
+ experimental_designs=self._normalize_string_list(detailed_outline.get("experimental_designs")),
3328
+ )
3329
+ paper_root = self._paper_root(quest_root, workspace_root=workspace_root, create=True)
3330
+ paper_branch = current_branch(paper_root.parent)
3331
+ ledger = self._read_paper_evidence_ledger(quest_root)
3332
+ draft_path = paper_root / "draft.md"
3333
+ bundle_path = paper_root / "paper_bundle_manifest.json"
3334
+ pending_slices = 0
3335
+ campaigns_root = quest_root / ".ds" / "analysis_campaigns"
3336
+ selected_outline_ref = str(selected_outline.get("outline_id") or ledger.get("selected_outline_ref") or "").strip() or None
3337
+ if campaigns_root.exists():
3338
+ for path in sorted(campaigns_root.glob("analysis-*.json")):
3339
+ manifest = read_json(path, {})
3340
+ if not isinstance(manifest, dict) or not manifest:
3341
+ continue
3342
+ manifest_outline_ref = str(manifest.get("selected_outline_ref") or "").strip() or None
3343
+ if selected_outline_ref and manifest_outline_ref != selected_outline_ref:
3344
+ continue
3345
+ pending_slices += sum(
3346
+ 1
3347
+ for item in (manifest.get("slices") or [])
3348
+ if isinstance(item, dict) and str(item.get("status") or "pending").strip() == "pending"
3349
+ )
3350
+ health = self._paper_contract_health_payload(
3351
+ quest_root,
3352
+ workspace_root=workspace_root,
3353
+ pending_slices=pending_slices,
3354
+ )
3355
+ required_count = sum(len(self._normalize_string_list(section.get("required_items"))) for section in sections)
3356
+ ready_required_count = required_count - int(health.get("unresolved_required_count") or 0)
3357
+ payload = {
1620
3358
  "schema_version": 1,
1621
- "outline_id": outline_id,
1622
- "status": status,
1623
- "title": resolved_title,
1624
- "note": str(note or "").strip() or None,
1625
- "story": str(story or "").strip() or None,
1626
- "ten_questions": self._normalize_string_list(ten_questions),
1627
- "detailed_outline": {
1628
- "title": str(normalized_detailed.get("title") or resolved_title).strip() or resolved_title,
1629
- "abstract": str(normalized_detailed.get("abstract") or "").strip() or None,
1630
- "research_questions": self._normalize_string_list(normalized_detailed.get("research_questions")),
1631
- "methodology": str(normalized_detailed.get("methodology") or "").strip() or None,
1632
- "experimental_designs": self._normalize_string_list(normalized_detailed.get("experimental_designs")),
1633
- "contributions": self._normalize_string_list(normalized_detailed.get("contributions")),
1634
- },
1635
- "review_result": str(review_result or "").strip() or None,
1636
- "created_at": created_at or utc_now(),
3359
+ "paper_line_id": self._paper_line_id(
3360
+ paper_branch=paper_branch,
3361
+ outline_id=selected_outline_ref,
3362
+ source_run_id=source_run_id or str(state.get("paper_parent_run_id") or "").strip() or None,
3363
+ ),
3364
+ "paper_branch": paper_branch,
3365
+ "paper_root": str(paper_root),
3366
+ "workspace_root": str(paper_root.parent),
3367
+ "source_branch": source_branch or str(state.get("paper_parent_branch") or "").strip() or None,
3368
+ "source_run_id": source_run_id or str(state.get("paper_parent_run_id") or "").strip() or None,
3369
+ "source_idea_id": source_idea_id or str(state.get("active_idea_id") or "").strip() or None,
3370
+ "selected_outline_ref": selected_outline_ref,
3371
+ "selected_outline_path": str(selected_outline_path) if selected_outline_path else None,
3372
+ "title": str(selected_outline.get("title") or "").strip() or None,
3373
+ "required_count": required_count,
3374
+ "ready_required_count": max(0, ready_required_count),
3375
+ "section_count": len(sections),
3376
+ "ready_section_count": int(health.get("ready_section_count") or 0),
3377
+ "unmapped_count": int(health.get("unmapped_completed_count") or 0),
3378
+ "open_supplementary_count": pending_slices,
3379
+ "contract_ok": bool(health.get("contract_ok")),
3380
+ "writing_ready": bool(health.get("writing_ready")),
3381
+ "finalize_ready": bool(health.get("finalize_ready")),
3382
+ "closure_state": str(health.get("closure_state") or "").strip() or None,
3383
+ "delivery_state": str(health.get("delivery_state") or "").strip() or None,
3384
+ "delivered_at": str(health.get("delivered_at") or "").strip() or None,
3385
+ "keep_bundle_fixed_by_default": bool(health.get("keep_bundle_fixed_by_default")),
3386
+ "unresolved_required_count": int(health.get("unresolved_required_count") or 0),
3387
+ "unmapped_completed_count": int(health.get("unmapped_completed_count") or 0),
3388
+ "blocking_reasons": list(health.get("blocking_reasons") or []),
3389
+ "recommended_next_stage": str(health.get("recommended_next_stage") or "").strip() or None,
3390
+ "recommended_action": str(health.get("recommended_action") or "").strip() or None,
3391
+ "draft_status": "present" if draft_path.exists() else "missing",
3392
+ "bundle_status": "present" if bundle_path.exists() else "missing",
1637
3393
  "updated_at": utc_now(),
1638
3394
  }
1639
- return record
3395
+ for paper_sync_root in self._paper_active_sync_roots(quest_root, workspace_root=workspace_root):
3396
+ write_json(paper_sync_root / "paper_line_state.json", payload)
3397
+ return payload
1640
3398
 
1641
3399
  def _active_baseline_attachment(self, quest_root: Path, workspace_root: Path | None = None) -> dict[str, Any] | None:
1642
3400
  target_root = self._workspace_root_for(quest_root, workspace_root)
@@ -2110,6 +3868,353 @@ class ArtifactService:
2110
3868
  )
2111
3869
  return records
2112
3870
 
3871
+ @staticmethod
3872
+ def _semantic_stage_fingerprint(snapshot: dict[str, Any]) -> str:
3873
+ paper_health = (
3874
+ dict(snapshot.get("paper_contract_health") or {})
3875
+ if isinstance(snapshot.get("paper_contract_health"), dict)
3876
+ else {}
3877
+ )
3878
+ payload = {
3879
+ "active_anchor": str(snapshot.get("active_anchor") or "").strip() or None,
3880
+ "active_run_id": str(snapshot.get("active_run_id") or "").strip() or None,
3881
+ "active_analysis_campaign_id": str(snapshot.get("active_analysis_campaign_id") or "").strip() or None,
3882
+ "next_pending_slice_id": str(snapshot.get("next_pending_slice_id") or "").strip() or None,
3883
+ "current_workspace_branch": str(snapshot.get("current_workspace_branch") or "").strip() or None,
3884
+ "continuation_policy": str(snapshot.get("continuation_policy") or "").strip() or None,
3885
+ "paper": {
3886
+ "closure_state": str(paper_health.get("closure_state") or "").strip() or None,
3887
+ "delivery_state": str(paper_health.get("delivery_state") or "").strip() or None,
3888
+ "blocking_reasons": list(paper_health.get("blocking_reasons") or []),
3889
+ "recommended_next_stage": str(paper_health.get("recommended_next_stage") or "").strip() or None,
3890
+ "recommended_action": str(paper_health.get("recommended_action") or "").strip() or None,
3891
+ "writing_ready": bool(paper_health.get("writing_ready")),
3892
+ "finalize_ready": bool(paper_health.get("finalize_ready")),
3893
+ "keep_bundle_fixed_by_default": bool(paper_health.get("keep_bundle_fixed_by_default")),
3894
+ },
3895
+ }
3896
+ return sha256_text(json.dumps(payload, ensure_ascii=False, sort_keys=True))
3897
+
3898
+ def _semantic_record_key(self, quest_root: Path, payload: dict[str, Any], *, workspace_root: Path | None = None) -> str | None:
3899
+ explicit = str(payload.get("semantic_key") or "").strip()
3900
+ if explicit:
3901
+ return explicit
3902
+ kind = str(payload.get("kind") or "").strip()
3903
+ if kind not in {"decision", "report"}:
3904
+ return None
3905
+ snapshot = self.quest_service.snapshot(self._quest_id(quest_root))
3906
+ fingerprint = self._semantic_stage_fingerprint(snapshot)
3907
+ stage = str(payload.get("stage") or payload.get("anchor") or snapshot.get("active_anchor") or "").strip() or "unknown"
3908
+ if kind == "decision":
3909
+ action = str(payload.get("action") or "").strip() or "none"
3910
+ verdict = str(payload.get("verdict") or "").strip() or "none"
3911
+ return f"decision:{stage}:{action}:{verdict}:{fingerprint}"
3912
+ report_type = str(payload.get("report_type") or payload.get("flow_type") or "").strip() or "report"
3913
+ protocol_step = str(payload.get("protocol_step") or "").strip() or "none"
3914
+ return f"report:{stage}:{report_type}:{protocol_step}:{fingerprint}"
3915
+
3916
+ def _latest_semantically_equivalent_artifact(
3917
+ self,
3918
+ quest_root: Path,
3919
+ *,
3920
+ kind: str,
3921
+ semantic_key: str,
3922
+ ) -> dict[str, Any] | None:
3923
+ candidates: list[dict[str, Any]] = []
3924
+ for item in self.quest_service._collect_artifacts(quest_root):
3925
+ payload = dict(item.get("payload") or {}) if isinstance(item.get("payload"), dict) else {}
3926
+ if not payload or str(payload.get("kind") or "").strip() != kind:
3927
+ continue
3928
+ if str(payload.get("semantic_key") or "").strip() != semantic_key:
3929
+ continue
3930
+ candidates.append(
3931
+ {
3932
+ "path": str(item.get("path") or "").strip() or None,
3933
+ "payload": payload,
3934
+ }
3935
+ )
3936
+ if not candidates:
3937
+ return None
3938
+ candidates.sort(
3939
+ key=lambda item: (
3940
+ str(((item.get("payload") or {}).get("updated_at") or "")),
3941
+ str(((item.get("payload") or {}).get("created_at") or "")),
3942
+ str(item.get("path") or ""),
3943
+ )
3944
+ )
3945
+ return candidates[-1]
3946
+
3947
+ def _idea_candidate_artifacts(self, quest_root: Path) -> list[dict[str, Any]]:
3948
+ records: list[dict[str, Any]] = []
3949
+ for record in self._idea_artifacts(quest_root):
3950
+ details = dict(record.get("details") or {}) if isinstance(record.get("details"), dict) else {}
3951
+ protocol_step = str(record.get("protocol_step") or "").strip().lower()
3952
+ submission_mode = str(
3953
+ details.get("submission_mode") or record.get("submission_mode") or ""
3954
+ ).strip().lower()
3955
+ if protocol_step != "candidate" and submission_mode != "candidate":
3956
+ continue
3957
+ paths = dict(record.get("paths") or {}) if isinstance(record.get("paths"), dict) else {}
3958
+ records.append(
3959
+ {
3960
+ "idea_id": str(record.get("idea_id") or "").strip() or None,
3961
+ "title": str(details.get("title") or "").strip() or None,
3962
+ "problem": str(details.get("problem") or "").strip() or None,
3963
+ "hypothesis": str(details.get("hypothesis") or "").strip() or None,
3964
+ "mechanism": str(details.get("mechanism") or "").strip() or None,
3965
+ "method_brief": str(details.get("method_brief") or "").strip() or None,
3966
+ "selection_scores": self._normalize_selection_scores(details.get("selection_scores")),
3967
+ "mechanism_family": str(details.get("mechanism_family") or "").strip() or None,
3968
+ "change_layer": str(details.get("change_layer") or "").strip() or None,
3969
+ "source_lens": str(details.get("source_lens") or "").strip() or None,
3970
+ "expected_gain": str(details.get("expected_gain") or "").strip() or None,
3971
+ "next_target": str(details.get("next_target") or record.get("next_target") or "").strip() or None,
3972
+ "lineage_intent": str(record.get("lineage_intent") or details.get("lineage_intent") or "").strip() or None,
3973
+ "parent_branch": str(record.get("parent_branch") or details.get("parent_branch") or "").strip() or None,
3974
+ "foundation_ref": record.get("foundation_ref") or details.get("foundation_ref"),
3975
+ "foundation_reason": str(record.get("foundation_reason") or details.get("foundation_reason") or "").strip() or None,
3976
+ "candidate_root": str(paths.get("candidate_root") or "").strip() or None,
3977
+ "idea_md_path": str(paths.get("idea_md") or "").strip() or None,
3978
+ "idea_draft_path": str(paths.get("idea_draft_md") or "").strip() or None,
3979
+ "status": str(record.get("status") or "").strip() or None,
3980
+ "updated_at": str(record.get("updated_at") or record.get("created_at") or "").strip() or None,
3981
+ }
3982
+ )
3983
+ records.sort(key=lambda item: str(item.get("updated_at") or ""))
3984
+ return records
3985
+
3986
+ def _optimization_candidate_reports(self, quest_root: Path) -> list[dict[str, Any]]:
3987
+ records: list[dict[str, Any]] = []
3988
+ for item in self.quest_service._collect_artifacts(quest_root):
3989
+ payload = dict(item.get("payload") or {}) if isinstance(item.get("payload"), dict) else {}
3990
+ if not payload:
3991
+ continue
3992
+ if str(payload.get("kind") or "").strip() != "report":
3993
+ continue
3994
+ report_type = str(payload.get("report_type") or "").strip().lower()
3995
+ if report_type != "optimization_candidate":
3996
+ continue
3997
+ details = dict(payload.get("details") or {}) if isinstance(payload.get("details"), dict) else {}
3998
+ artifact_path = str(item.get("path") or "").strip() or None
3999
+ records.append(
4000
+ {
4001
+ "artifact_id": str(payload.get("artifact_id") or payload.get("id") or "").strip() or None,
4002
+ "candidate_id": str(payload.get("candidate_id") or details.get("candidate_id") or "").strip() or None,
4003
+ "parent_candidate_id": str(payload.get("parent_candidate_id") or details.get("parent_candidate_id") or "").strip() or None,
4004
+ "idea_id": str(payload.get("idea_id") or details.get("idea_id") or "").strip() or None,
4005
+ "branch": str(payload.get("branch") or details.get("branch") or "").strip() or None,
4006
+ "strategy": str(payload.get("strategy") or details.get("strategy") or "").strip() or None,
4007
+ "status": str(payload.get("status") or details.get("status") or "").strip() or None,
4008
+ "mechanism_family": str(payload.get("mechanism_family") or details.get("mechanism_family") or "").strip() or None,
4009
+ "change_layer": str(payload.get("change_layer") or details.get("change_layer") or "").strip() or None,
4010
+ "source_lens": str(payload.get("source_lens") or details.get("source_lens") or "").strip() or None,
4011
+ "summary": str(payload.get("summary") or "").strip() or None,
4012
+ "change_plan": str(payload.get("change_plan") or details.get("change_plan") or "").strip() or None,
4013
+ "expected_gain": str(payload.get("expected_gain") or details.get("expected_gain") or "").strip() or None,
4014
+ "linked_run_id": str(payload.get("linked_run_id") or details.get("linked_run_id") or "").strip() or None,
4015
+ "failure_kind": str(payload.get("failure_kind") or details.get("failure_kind") or "").strip() or None,
4016
+ "metrics_snapshot": payload.get("metrics_snapshot") or details.get("metrics_snapshot"),
4017
+ "updated_at": str(payload.get("updated_at") or payload.get("created_at") or "").strip() or None,
4018
+ "artifact_path": artifact_path,
4019
+ }
4020
+ )
4021
+ records.sort(key=lambda item: str(item.get("updated_at") or ""))
4022
+ return records
4023
+
4024
+ @staticmethod
4025
+ def _frontier_branch_rank(branch: dict[str, Any]) -> tuple[int, int, float, str, str]:
4026
+ latest = dict(branch.get("latest_main_experiment") or {}) if isinstance(branch.get("latest_main_experiment"), dict) else {}
4027
+ recommended_route = str(latest.get("recommended_next_route") or "").strip().lower()
4028
+ route_score = {
4029
+ "iterate": 4,
4030
+ "analysis_or_write": 4,
4031
+ "continue": 3,
4032
+ "revise_idea": 1,
4033
+ }.get(recommended_route, 0)
4034
+ breakthrough = 1 if bool(latest.get("breakthrough")) else 0
4035
+ delta = to_number(latest.get("delta_vs_baseline"))
4036
+ delta_score = float(delta) if delta is not None else float("-inf")
4037
+ return (
4038
+ 1 if bool(branch.get("has_main_result")) else 0,
4039
+ route_score + breakthrough,
4040
+ delta_score,
4041
+ str(branch.get("updated_at") or ""),
4042
+ str(branch.get("branch_name") or ""),
4043
+ )
4044
+
4045
+ def _optimization_frontier_state(self, quest_root: Path) -> dict[str, Any]:
4046
+ return {
4047
+ "artifact_projection": self.quest_service._json_compatible_state(
4048
+ self.quest_service._path_state(self.quest_service._artifact_projection_path(quest_root))
4049
+ ),
4050
+ "research_state": self.quest_service._json_compatible_state(
4051
+ self.quest_service._path_state(self.quest_service._research_state_path(quest_root))
4052
+ ),
4053
+ "quest_yaml": self.quest_service._json_compatible_state(
4054
+ self.quest_service._path_state(self.quest_service._quest_yaml_path(quest_root))
4055
+ ),
4056
+ }
4057
+
4058
+ def get_optimization_frontier(self, quest_root: Path) -> dict[str, Any]:
4059
+ cache_key = str(quest_root.resolve())
4060
+ state = self._optimization_frontier_state(quest_root)
4061
+ with self._optimization_frontier_cache_lock:
4062
+ cached = self._optimization_frontier_cache.get(cache_key)
4063
+ if cached and cached.get("state") == state:
4064
+ return copy.deepcopy(cached.get("payload") or {"ok": False})
4065
+
4066
+ snapshot = self.quest_service.snapshot(self._quest_id(quest_root))
4067
+ branches_payload = self.list_research_branches(quest_root)
4068
+ branches = [dict(item) for item in (branches_payload.get("branches") or []) if isinstance(item, dict)]
4069
+ candidate_briefs = self._idea_candidate_artifacts(quest_root)
4070
+ implementation_candidates = self._optimization_candidate_reports(quest_root)
4071
+
4072
+ branches.sort(key=self._frontier_branch_rank, reverse=True)
4073
+ top_branches = branches[:3]
4074
+
4075
+ active_candidate_statuses = {"proposed", "smoke_running", "smoke_passed", "promoted", "full_eval_running"}
4076
+ active_implementation_candidates = [
4077
+ item for item in implementation_candidates if str(item.get("status") or "").strip().lower() in active_candidate_statuses
4078
+ ]
4079
+
4080
+ stagnant_branch_names: set[str] = set()
4081
+ branch_candidate_failures: dict[str, int] = {}
4082
+ for item in implementation_candidates:
4083
+ branch_name = str(item.get("branch") or "").strip()
4084
+ if not branch_name:
4085
+ continue
4086
+ status = str(item.get("status") or "").strip().lower()
4087
+ if status in {"failed", "smoke_failed", "archived"}:
4088
+ branch_candidate_failures[branch_name] = branch_candidate_failures.get(branch_name, 0) + 1
4089
+ for branch in branches:
4090
+ branch_name = str(branch.get("branch_name") or "").strip()
4091
+ latest = dict(branch.get("latest_main_experiment") or {}) if isinstance(branch.get("latest_main_experiment"), dict) else {}
4092
+ recommended_route = str(latest.get("recommended_next_route") or "").strip().lower()
4093
+ if branch_candidate_failures.get(branch_name, 0) >= 2:
4094
+ stagnant_branch_names.add(branch_name)
4095
+ elif recommended_route in {"continue", "revise_idea"} and bool(branch.get("has_main_result")):
4096
+ stagnant_branch_names.add(branch_name)
4097
+ stagnant_branches = [branch for branch in branches if str(branch.get("branch_name") or "") in stagnant_branch_names]
4098
+
4099
+ successful_branches = [branch for branch in branches if bool(branch.get("has_main_result"))]
4100
+ fusion_candidates = [
4101
+ {
4102
+ "branch_name": str(branch.get("branch_name") or "").strip() or None,
4103
+ "idea_id": branch.get("idea_id"),
4104
+ "idea_title": branch.get("idea_title"),
4105
+ "latest_main_run_id": str((dict(branch.get("latest_main_experiment") or {}) if isinstance(branch.get("latest_main_experiment"), dict) else {}).get("run_id") or "").strip() or None,
4106
+ "strength_signal": {
4107
+ "recommended_next_route": str((dict(branch.get("latest_main_experiment") or {}) if isinstance(branch.get("latest_main_experiment"), dict) else {}).get("recommended_next_route") or "").strip() or None,
4108
+ "delta_vs_baseline": (dict(branch.get("latest_main_experiment") or {}) if isinstance(branch.get("latest_main_experiment"), dict) else {}).get("delta_vs_baseline"),
4109
+ "breakthrough": (dict(branch.get("latest_main_experiment") or {}) if isinstance(branch.get("latest_main_experiment"), dict) else {}).get("breakthrough"),
4110
+ },
4111
+ }
4112
+ for branch in successful_branches[:3]
4113
+ ]
4114
+
4115
+ if active_implementation_candidates:
4116
+ mode = "exploit"
4117
+ reason = "At least one implementation-level candidate is already active, so the frontier should stay focused on execution and result conversion."
4118
+ elif candidate_briefs and len(top_branches) <= 1:
4119
+ mode = "explore"
4120
+ reason = "Candidate briefs exist but the durable line set is still thin, so widening or ranking the brief pool is the best next move."
4121
+ elif len(fusion_candidates) >= 2 and stagnant_branches:
4122
+ mode = "fusion"
4123
+ reason = "Multiple result-bearing branches exist and at least one line is stagnating, so cross-line fusion is now justified."
4124
+ elif top_branches:
4125
+ mode = "exploit"
4126
+ reason = "A durable line already exists and no broader frontier condition dominates, so focus should stay on the strongest current line."
4127
+ else:
4128
+ mode = "stop"
4129
+ reason = "No durable optimization line or candidate pool is active, so there is no meaningful frontier to continue automatically."
4130
+
4131
+ recommended_next_actions: list[str] = []
4132
+ if mode == "explore":
4133
+ recommended_next_actions.extend(
4134
+ [
4135
+ "Create or refine candidate briefs before promoting new lines.",
4136
+ "Rank the candidate brief pool and promote only the strongest 1 to 3 directions.",
4137
+ ]
4138
+ )
4139
+ elif mode == "fusion":
4140
+ recommended_next_actions.extend(
4141
+ [
4142
+ "Compare the strongest result-bearing lines for complementary mechanisms.",
4143
+ "Open a fusion candidate only if the line strengths are complementary rather than redundant.",
4144
+ ]
4145
+ )
4146
+ elif mode == "exploit":
4147
+ recommended_next_actions.extend(
4148
+ [
4149
+ "Keep the active line focused and advance the most promising implementation candidates first.",
4150
+ "Use smoke checks before promoting more candidates into full evaluation.",
4151
+ ]
4152
+ )
4153
+ else:
4154
+ recommended_next_actions.append("Record a stop or park decision before closing the optimization loop.")
4155
+
4156
+ candidate_backlog = {
4157
+ "candidate_brief_count": len(candidate_briefs),
4158
+ "implementation_candidate_count": len(implementation_candidates),
4159
+ "active_implementation_candidate_count": len(active_implementation_candidates),
4160
+ "failed_implementation_candidate_count": sum(
4161
+ 1 for item in implementation_candidates if str(item.get("status") or "").strip().lower() in {"failed", "smoke_failed", "archived"}
4162
+ ),
4163
+ }
4164
+
4165
+ best_branch = top_branches[0] if top_branches else None
4166
+ best_run = (
4167
+ dict(best_branch.get("latest_main_experiment") or {})
4168
+ if isinstance((best_branch or {}).get("latest_main_experiment"), dict)
4169
+ else None
4170
+ )
4171
+ best_branch_name = str((best_branch or {}).get("branch_name") or "").strip() or None
4172
+ best_idea_id = str((best_branch or {}).get("idea_id") or "").strip() or None
4173
+ best_branch_recent_candidates = [
4174
+ {
4175
+ "candidate_id": str(item.get("candidate_id") or "").strip() or None,
4176
+ "strategy": str(item.get("strategy") or "").strip() or None,
4177
+ "status": str(item.get("status") or "").strip() or None,
4178
+ "mechanism_family": str(item.get("mechanism_family") or "").strip() or None,
4179
+ "change_layer": str(item.get("change_layer") or "").strip() or None,
4180
+ "source_lens": str(item.get("source_lens") or "").strip() or None,
4181
+ "change_plan": str(item.get("change_plan") or "").strip() or None,
4182
+ "failure_kind": str(item.get("failure_kind") or "").strip() or None,
4183
+ "linked_run_id": str(item.get("linked_run_id") or "").strip() or None,
4184
+ "updated_at": str(item.get("updated_at") or "").strip() or None,
4185
+ }
4186
+ for item in implementation_candidates
4187
+ if (
4188
+ (best_branch_name and str(item.get("branch") or "").strip() == best_branch_name)
4189
+ or (best_idea_id and str(item.get("idea_id") or "").strip() == best_idea_id)
4190
+ )
4191
+ ][-4:]
4192
+
4193
+ payload = {
4194
+ "ok": True,
4195
+ "optimization_frontier": {
4196
+ "mode": mode,
4197
+ "frontier_reason": reason,
4198
+ "active_anchor": snapshot.get("active_anchor"),
4199
+ "best_branch": best_branch,
4200
+ "best_run": best_run,
4201
+ "top_branches": top_branches,
4202
+ "candidate_briefs": candidate_briefs,
4203
+ "implementation_candidates": implementation_candidates[-8:],
4204
+ "best_branch_recent_candidates": best_branch_recent_candidates,
4205
+ "candidate_backlog": candidate_backlog,
4206
+ "stagnant_branches": stagnant_branches,
4207
+ "fusion_candidates": fusion_candidates,
4208
+ "recommended_next_actions": recommended_next_actions,
4209
+ },
4210
+ }
4211
+ with self._optimization_frontier_cache_lock:
4212
+ self._optimization_frontier_cache[cache_key] = {
4213
+ "state": copy.deepcopy(state),
4214
+ "payload": copy.deepcopy(payload),
4215
+ }
4216
+ return payload
4217
+
2113
4218
  @staticmethod
2114
4219
  def _format_branch_number(index: int) -> str:
2115
4220
  if index < 1000:
@@ -2777,6 +4882,11 @@ class ArtifactService:
2777
4882
  "idea_id": record.get("idea_id"),
2778
4883
  "title": details.get("title"),
2779
4884
  "problem": details.get("problem"),
4885
+ "method_brief": details.get("method_brief"),
4886
+ "selection_scores": self._normalize_selection_scores(details.get("selection_scores")),
4887
+ "mechanism_family": details.get("mechanism_family"),
4888
+ "change_layer": details.get("change_layer"),
4889
+ "source_lens": details.get("source_lens"),
2780
4890
  "next_target": details.get("next_target") or record.get("next_target"),
2781
4891
  "lineage_intent": record.get("lineage_intent") or details.get("lineage_intent"),
2782
4892
  "protocol_step": record.get("protocol_step"),
@@ -2885,6 +4995,11 @@ class ArtifactService:
2885
4995
  "idea_id": latest_idea.get("idea_id") or (latest_experiment.get("idea_id") if isinstance(latest_experiment, dict) else None),
2886
4996
  "idea_title": latest_idea.get("title"),
2887
4997
  "idea_problem": latest_idea.get("problem"),
4998
+ "method_brief": latest_idea.get("method_brief"),
4999
+ "selection_scores": self._normalize_selection_scores(latest_idea.get("selection_scores")),
5000
+ "mechanism_family": latest_idea.get("mechanism_family"),
5001
+ "change_layer": latest_idea.get("change_layer"),
5002
+ "source_lens": latest_idea.get("source_lens"),
2888
5003
  "next_target": latest_idea.get("next_target"),
2889
5004
  "lineage_intent": latest_idea.get("lineage_intent"),
2890
5005
  "parent_branch": resolved_parent_branch,
@@ -2933,7 +5048,7 @@ class ArtifactService:
2933
5048
  research_head_branch = str(state.get("research_head_branch") or "").strip() or None
2934
5049
  canonical_branch = analysis_parent_branch or paper_parent_branch or current_workspace_branch or research_head_branch
2935
5050
  latest_main_run = self._latest_main_run_for_branch(quest_root, canonical_branch or "")
2936
- selected_outline = read_json(self._paper_selected_outline_path(quest_root), {})
5051
+ _, selected_outline = self._read_selected_outline_record(quest_root)
2937
5052
  selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
2938
5053
  active_campaign = (
2939
5054
  self._read_analysis_manifest(quest_root, active_campaign_id)
@@ -2948,22 +5063,414 @@ class ArtifactService:
2948
5063
  )
2949
5064
  return {
2950
5065
  "ok": True,
2951
- "active_idea_id": str(state.get("active_idea_id") or "").strip() or None,
2952
- "research_head_branch": research_head_branch,
2953
- "research_head_worktree_root": str(state.get("research_head_worktree_root") or "").strip() or None,
2954
- "current_workspace_branch": current_workspace_branch,
2955
- "current_workspace_root": str(state.get("current_workspace_root") or "").strip() or None,
2956
- "analysis_parent_branch": analysis_parent_branch,
2957
- "analysis_parent_worktree_root": str(state.get("analysis_parent_worktree_root") or "").strip() or None,
2958
- "current_canonical_branch": canonical_branch,
2959
- "active_analysis_campaign_id": active_campaign_id,
2960
- "active_campaign_title": str(active_campaign.get("title") or "").strip() or None,
2961
- "next_pending_slice_id": str(state.get("next_pending_slice_id") or "").strip() or None,
2962
- "latest_main_run_id": str((latest_main_run or {}).get("run_id") or "").strip() or None,
2963
- "latest_main_run_branch": str((latest_main_run or {}).get("branch") or "").strip() or None,
2964
- "latest_main_result_json": str(latest_paths.get("result_json") or "").strip() or None,
2965
- "selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
2966
- "default_reply_interaction_id": str(snapshot.get("default_reply_interaction_id") or "").strip() or None,
5066
+ "active_idea_id": str(state.get("active_idea_id") or "").strip() or None,
5067
+ "research_head_branch": research_head_branch,
5068
+ "research_head_worktree_root": str(state.get("research_head_worktree_root") or "").strip() or None,
5069
+ "current_workspace_branch": current_workspace_branch,
5070
+ "current_workspace_root": str(state.get("current_workspace_root") or "").strip() or None,
5071
+ "analysis_parent_branch": analysis_parent_branch,
5072
+ "analysis_parent_worktree_root": str(state.get("analysis_parent_worktree_root") or "").strip() or None,
5073
+ "current_canonical_branch": canonical_branch,
5074
+ "active_analysis_campaign_id": active_campaign_id,
5075
+ "active_campaign_title": str(active_campaign.get("title") or "").strip() or None,
5076
+ "next_pending_slice_id": str(state.get("next_pending_slice_id") or "").strip() or None,
5077
+ "latest_main_run_id": str((latest_main_run or {}).get("run_id") or "").strip() or None,
5078
+ "latest_main_run_branch": str((latest_main_run or {}).get("branch") or "").strip() or None,
5079
+ "latest_main_result_json": str(latest_paths.get("result_json") or "").strip() or None,
5080
+ "selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
5081
+ "default_reply_interaction_id": str(snapshot.get("default_reply_interaction_id") or "").strip() or None,
5082
+ }
5083
+
5084
+ def get_paper_contract_health(
5085
+ self,
5086
+ quest_root: Path,
5087
+ *,
5088
+ detail: str = "summary",
5089
+ ) -> dict[str, Any]:
5090
+ normalized_detail = str(detail or "summary").strip().lower() or "summary"
5091
+ if normalized_detail not in {"summary", "full"}:
5092
+ raise ValueError("get_paper_contract_health detail must be `summary` or `full`.")
5093
+ workspace_root = self.quest_service.active_workspace_root(quest_root)
5094
+ paper_contract = self.quest_service._paper_contract_payload(quest_root, workspace_root)
5095
+ paper_evidence = self.quest_service._paper_evidence_payload(quest_root, workspace_root)
5096
+ analysis_inventory = self.quest_service._analysis_inventory_payload(quest_root, workspace_root)
5097
+ paper_lines, active_paper_line_ref = self.quest_service._paper_lines_payload(quest_root, workspace_root)
5098
+ if not paper_contract:
5099
+ return {
5100
+ "ok": False,
5101
+ "message": "No active paper contract is available for the current quest state.",
5102
+ "active_paper_line_ref": active_paper_line_ref,
5103
+ "paper_lines": paper_lines,
5104
+ }
5105
+ selected_outline_ref = str(paper_contract.get("selected_outline_ref") or "").strip() or None
5106
+ if not selected_outline_ref:
5107
+ return {
5108
+ "ok": False,
5109
+ "message": "No selected outline is available for the current quest state.",
5110
+ "active_paper_line_ref": active_paper_line_ref,
5111
+ "paper_lines": paper_lines,
5112
+ }
5113
+ payload = self.quest_service._paper_contract_health_payload(
5114
+ paper_contract=paper_contract,
5115
+ paper_evidence=paper_evidence,
5116
+ analysis_inventory=analysis_inventory,
5117
+ paper_lines=paper_lines,
5118
+ active_paper_line_ref=active_paper_line_ref,
5119
+ )
5120
+ payload = dict(payload or {})
5121
+ if normalized_detail == "summary":
5122
+ payload.pop("unresolved_required_items", None)
5123
+ payload.pop("unmapped_completed_items", None)
5124
+ payload.pop("blocking_pending_slices", None)
5125
+ return {
5126
+ "ok": True,
5127
+ "detail": normalized_detail,
5128
+ "active_paper_line_ref": active_paper_line_ref,
5129
+ "active_workspace_root": str(workspace_root),
5130
+ "paper_contract_health": payload,
5131
+ }
5132
+
5133
+ def get_quest_state(
5134
+ self,
5135
+ quest_root: Path,
5136
+ *,
5137
+ detail: str = "summary",
5138
+ ) -> dict[str, Any]:
5139
+ normalized_detail = str(detail or "summary").strip().lower() or "summary"
5140
+ if normalized_detail not in {"summary", "full"}:
5141
+ raise ValueError("get_quest_state detail must be `summary` or `full`.")
5142
+ snapshot = self.quest_service.snapshot(self._quest_id(quest_root))
5143
+ payload: dict[str, Any] = {
5144
+ "quest_id": snapshot.get("quest_id"),
5145
+ "title": snapshot.get("title"),
5146
+ "active_anchor": snapshot.get("active_anchor"),
5147
+ "continuation_policy": snapshot.get("continuation_policy"),
5148
+ "continuation_anchor": snapshot.get("continuation_anchor"),
5149
+ "continuation_reason": snapshot.get("continuation_reason"),
5150
+ "baseline_gate": snapshot.get("baseline_gate"),
5151
+ "active_baseline_id": snapshot.get("active_baseline_id"),
5152
+ "active_baseline_variant_id": snapshot.get("active_baseline_variant_id"),
5153
+ "active_run_id": snapshot.get("active_run_id"),
5154
+ "active_idea_id": snapshot.get("active_idea_id"),
5155
+ "active_analysis_campaign_id": snapshot.get("active_analysis_campaign_id"),
5156
+ "active_idea_line_ref": snapshot.get("active_idea_line_ref"),
5157
+ "active_paper_line_ref": snapshot.get("active_paper_line_ref"),
5158
+ "current_workspace_branch": snapshot.get("current_workspace_branch"),
5159
+ "current_workspace_root": snapshot.get("current_workspace_root"),
5160
+ "research_head_branch": snapshot.get("research_head_branch"),
5161
+ "research_head_worktree_root": snapshot.get("research_head_worktree_root"),
5162
+ "workspace_mode": snapshot.get("workspace_mode"),
5163
+ "runtime_status": snapshot.get("runtime_status"),
5164
+ "display_status": snapshot.get("display_status"),
5165
+ "waiting_interaction_id": snapshot.get("waiting_interaction_id"),
5166
+ "pending_user_message_count": snapshot.get("pending_user_message_count"),
5167
+ "next_pending_slice_id": snapshot.get("next_pending_slice_id"),
5168
+ "paper_contract_health": snapshot.get("paper_contract_health"),
5169
+ }
5170
+ if normalized_detail == "full":
5171
+ payload.update(
5172
+ {
5173
+ "startup_contract": snapshot.get("startup_contract"),
5174
+ "requested_baseline_ref": snapshot.get("requested_baseline_ref"),
5175
+ "confirmed_baseline_ref": snapshot.get("confirmed_baseline_ref"),
5176
+ "counts": snapshot.get("counts"),
5177
+ "paths": snapshot.get("paths"),
5178
+ "active_interactions": snapshot.get("active_interactions"),
5179
+ "recent_reply_threads": snapshot.get("recent_reply_threads"),
5180
+ "recent_artifacts": snapshot.get("recent_artifacts"),
5181
+ "recent_runs": snapshot.get("recent_runs"),
5182
+ "idea_lines": snapshot.get("idea_lines"),
5183
+ "paper_lines": snapshot.get("paper_lines"),
5184
+ }
5185
+ )
5186
+ return {
5187
+ "ok": True,
5188
+ "detail": normalized_detail,
5189
+ "quest_state": payload,
5190
+ }
5191
+
5192
+ def get_global_status(
5193
+ self,
5194
+ quest_root: Path,
5195
+ *,
5196
+ detail: str = "brief",
5197
+ locale: str = "zh",
5198
+ ) -> dict[str, Any]:
5199
+ normalized_detail = str(detail or "brief").strip().lower() or "brief"
5200
+ if normalized_detail not in {"brief", "full"}:
5201
+ raise ValueError("get_global_status detail must be `brief` or `full`.")
5202
+ normalized_locale = str(locale or "zh").strip().lower() or "zh"
5203
+ snapshot = self.quest_service.snapshot(self._quest_id(quest_root))
5204
+ scoreboard = self.refresh_method_scoreboard(quest_root)
5205
+ scoreboard_payload = dict(scoreboard.get("scoreboard") or {}) if isinstance(scoreboard.get("scoreboard"), dict) else {}
5206
+ counts = dict(snapshot.get("counts") or {}) if isinstance(snapshot.get("counts"), dict) else {}
5207
+ paper_health = (
5208
+ dict(snapshot.get("paper_contract_health") or {})
5209
+ if isinstance(snapshot.get("paper_contract_health"), dict)
5210
+ else {}
5211
+ )
5212
+ recent_runs = [dict(item) for item in (snapshot.get("recent_runs") or []) if isinstance(item, dict)]
5213
+ latest_run = recent_runs[-1] if recent_runs else {}
5214
+ latest_run_summary = str(latest_run.get("summary") or "").strip() or None
5215
+ status_line = (
5216
+ str(((snapshot.get("summary") or {}) if isinstance(snapshot.get("summary"), dict) else {}).get("status_line") or "").strip()
5217
+ or None
5218
+ )
5219
+ claim_boundary = {
5220
+ "supported": int(paper_health.get("supported_claim_count") or 0),
5221
+ "partial": int(paper_health.get("partial_claim_count") or 0),
5222
+ "unsupported": int(paper_health.get("unsupported_claim_count") or 0),
5223
+ "deferred": int(paper_health.get("deferred_claim_count") or 0),
5224
+ }
5225
+ paper_ready = bool(paper_health.get("writing_ready"))
5226
+ bundle_ready = bool(paper_health.get("finalize_ready"))
5227
+ closure_state = str(paper_health.get("closure_state") or "").strip() or None
5228
+ delivery_state = str(paper_health.get("delivery_state") or "").strip() or None
5229
+ stage = str(snapshot.get("active_anchor") or "decision").strip() or "decision"
5230
+ continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip() or "auto"
5231
+ next_stage = str(paper_health.get("recommended_next_stage") or stage).strip() or stage
5232
+ next_action = str(paper_health.get("recommended_action") or "continue").strip() or "continue"
5233
+ brief_summary_zh = (
5234
+ f"当前阶段是 `{stage}`。"
5235
+ f"{(' 论文线当前状态是 `' + closure_state + '`。' if closure_state else '')}"
5236
+ f"{(' 论文线已达到 bundle-ready。' if bundle_ready and not closure_state else '')}"
5237
+ f"{(' 当前是停驻等待新消息,不会继续自动空转。' if continuation_policy == 'wait_for_user_or_resume' else '')}"
5238
+ f"{(' 最近主结果:' + latest_run_summary) if latest_run_summary else ''}"
5239
+ ).strip()
5240
+ brief_summary_en = (
5241
+ f"Current stage: `{stage}`."
5242
+ f"{(' Paper closure state: `' + closure_state + '`.' if closure_state else '')}"
5243
+ f"{(' The paper line is bundle-ready.' if bundle_ready and not closure_state else '')}"
5244
+ f"{(' The quest is currently parked and will not auto-spin until a new user message or resume.' if continuation_policy == 'wait_for_user_or_resume' else '')}"
5245
+ f"{(' Latest run: ' + latest_run_summary) if latest_run_summary else ''}"
5246
+ ).strip()
5247
+ payload: dict[str, Any] = {
5248
+ "quest_id": snapshot.get("quest_id"),
5249
+ "title": snapshot.get("title"),
5250
+ "current_stage": stage,
5251
+ "continuation_policy": continuation_policy,
5252
+ "continuation_anchor": snapshot.get("continuation_anchor"),
5253
+ "status_line": status_line,
5254
+ "latest_run_id": latest_run.get("run_id"),
5255
+ "latest_run_summary": latest_run_summary,
5256
+ "method_scoreboard_path": scoreboard.get("json_path"),
5257
+ "incumbent_method": scoreboard_payload.get("incumbent_title"),
5258
+ "paper_contract_health": {
5259
+ "writing_ready": paper_ready,
5260
+ "finalize_ready": bundle_ready,
5261
+ "closure_state": closure_state,
5262
+ "delivery_state": delivery_state,
5263
+ "delivered_at": paper_health.get("delivered_at"),
5264
+ "keep_bundle_fixed_by_default": bool(paper_health.get("keep_bundle_fixed_by_default")),
5265
+ "recommended_next_stage": next_stage,
5266
+ "recommended_action": next_action,
5267
+ },
5268
+ "claim_boundary": claim_boundary,
5269
+ "pending_user_message_count": int(snapshot.get("pending_user_message_count") or 0),
5270
+ "bash_running_count": int(counts.get("bash_running_count") or 0),
5271
+ "summary_text": brief_summary_zh if normalized_locale.startswith("zh") else brief_summary_en,
5272
+ }
5273
+ if normalized_detail == "full":
5274
+ payload.update(
5275
+ {
5276
+ "runtime_status": snapshot.get("runtime_status"),
5277
+ "display_status": snapshot.get("display_status"),
5278
+ "baseline_gate": snapshot.get("baseline_gate"),
5279
+ "active_baseline_id": snapshot.get("active_baseline_id"),
5280
+ "active_idea_id": snapshot.get("active_idea_id"),
5281
+ "active_analysis_campaign_id": snapshot.get("active_analysis_campaign_id"),
5282
+ "current_workspace_branch": snapshot.get("current_workspace_branch"),
5283
+ "recent_runs": recent_runs[-5:],
5284
+ "paper_lines": snapshot.get("paper_lines"),
5285
+ "idea_lines": snapshot.get("idea_lines"),
5286
+ "method_scoreboard": scoreboard_payload,
5287
+ }
5288
+ )
5289
+ return {
5290
+ "ok": True,
5291
+ "detail": normalized_detail,
5292
+ "locale": normalized_locale,
5293
+ "global_status": payload,
5294
+ }
5295
+
5296
+ def refresh_method_scoreboard(self, quest_root: Path) -> dict[str, Any]:
5297
+ snapshot = self.quest_service.snapshot(self._quest_id(quest_root))
5298
+ ideas = self._idea_artifacts(quest_root)
5299
+ main_runs = self._main_run_artifacts(quest_root)
5300
+ entries_by_key: dict[str, dict[str, Any]] = {}
5301
+ idea_titles_by_id: dict[str, str] = {}
5302
+
5303
+ for idea in ideas:
5304
+ idea_id = str(idea.get("idea_id") or "").strip()
5305
+ if not idea_id:
5306
+ continue
5307
+ idea_details = dict(idea.get("details") or {}) if isinstance(idea.get("details"), dict) else {}
5308
+ idea_title = str(idea.get("title") or idea_details.get("title") or idea_id).strip() or idea_id
5309
+ idea_titles_by_id[idea_id] = idea_title
5310
+ entries_by_key[idea_id] = {
5311
+ "line_key": idea_id,
5312
+ "idea_id": idea_id,
5313
+ "title": idea_title,
5314
+ "branch": str(idea.get("branch") or "").strip() or None,
5315
+ "status": "candidate",
5316
+ "latest_run_id": None,
5317
+ "latest_run_summary": None,
5318
+ "updated_at": str(idea.get("updated_at") or idea.get("created_at") or "").strip() or None,
5319
+ "incumbent": False,
5320
+ }
5321
+
5322
+ for run in main_runs:
5323
+ idea_id = str(run.get("idea_id") or "").strip()
5324
+ key = idea_id or str(run.get("branch") or run.get("run_id") or "run").strip()
5325
+ entry = dict(entries_by_key.get(key) or {})
5326
+ entry.update(
5327
+ {
5328
+ "line_key": key,
5329
+ "idea_id": idea_id or entry.get("idea_id"),
5330
+ "title": str(
5331
+ entry.get("title")
5332
+ or idea_titles_by_id.get(idea_id or "")
5333
+ or run.get("title")
5334
+ or run.get("run_id")
5335
+ or key
5336
+ ).strip()
5337
+ or key,
5338
+ "branch": str(run.get("branch") or entry.get("branch") or "").strip() or None,
5339
+ "status": "main_verified" if str(run.get("status") or "").strip() == "completed" else "candidate",
5340
+ "latest_run_id": str(run.get("run_id") or "").strip() or None,
5341
+ "latest_run_summary": str(run.get("summary") or "").strip() or None,
5342
+ "updated_at": str(run.get("updated_at") or run.get("created_at") or "").strip() or entry.get("updated_at"),
5343
+ "incumbent": False,
5344
+ }
5345
+ )
5346
+ entries_by_key[key] = entry
5347
+
5348
+ entries = sorted(
5349
+ entries_by_key.values(),
5350
+ key=lambda item: str(item.get("updated_at") or ""),
5351
+ )
5352
+ if entries:
5353
+ entries[-1]["incumbent"] = True
5354
+ incumbent = next((item for item in entries if item.get("incumbent")), None)
5355
+
5356
+ scoreboard_payload = {
5357
+ "schema_version": 1,
5358
+ "quest_id": snapshot.get("quest_id"),
5359
+ "updated_at": utc_now(),
5360
+ "entry_count": len(entries),
5361
+ "incumbent_line_key": str((incumbent or {}).get("line_key") or "").strip() or None,
5362
+ "incumbent_title": str((incumbent or {}).get("title") or "").strip() or None,
5363
+ "entries": entries,
5364
+ }
5365
+ status_root = ensure_dir(quest_root / "artifacts" / "status")
5366
+ json_path = status_root / "method_scoreboard.json"
5367
+ md_path = status_root / "method_scoreboard.md"
5368
+ write_json(json_path, scoreboard_payload)
5369
+ lines = [
5370
+ "# Method Scoreboard",
5371
+ "",
5372
+ f"- Updated at: {scoreboard_payload['updated_at']}",
5373
+ f"- Incumbent: {scoreboard_payload.get('incumbent_title') or 'none'}",
5374
+ "",
5375
+ "## Entries",
5376
+ "",
5377
+ ]
5378
+ if entries:
5379
+ for item in entries:
5380
+ incumbent_tag = " [incumbent]" if item.get("incumbent") else ""
5381
+ lines.append(
5382
+ f"- `{item.get('line_key')}`{incumbent_tag}: {item.get('title') or item.get('line_key')} | status={item.get('status') or 'unknown'} | branch={item.get('branch') or 'none'} | latest_run={item.get('latest_run_id') or 'none'}"
5383
+ )
5384
+ if item.get("latest_run_summary"):
5385
+ lines.append(f" - summary: {item['latest_run_summary']}")
5386
+ else:
5387
+ lines.append("- none")
5388
+ write_text(md_path, "\n".join(lines).rstrip() + "\n")
5389
+ return {
5390
+ "ok": True,
5391
+ "json_path": str(json_path),
5392
+ "md_path": str(md_path),
5393
+ "scoreboard": scoreboard_payload,
5394
+ }
5395
+
5396
+ def read_quest_documents(
5397
+ self,
5398
+ quest_root: Path,
5399
+ *,
5400
+ names: list[str] | None = None,
5401
+ mode: str = "excerpt",
5402
+ max_lines: int = 12,
5403
+ ) -> dict[str, Any]:
5404
+ normalized_mode = str(mode or "excerpt").strip().lower() or "excerpt"
5405
+ if normalized_mode not in {"excerpt", "full"}:
5406
+ raise ValueError("read_quest_documents mode must be `excerpt` or `full`.")
5407
+ requested = [str(item or "").strip().lower() for item in (names or []) if str(item or "").strip()]
5408
+ if not requested:
5409
+ requested = ["brief", "plan", "status", "summary", "active_user_requirements"]
5410
+ document_paths = {
5411
+ "brief": quest_root / "brief.md",
5412
+ "plan": quest_root / "plan.md",
5413
+ "status": quest_root / "status.md",
5414
+ "summary": quest_root / "SUMMARY.md",
5415
+ "active_user_requirements": self.quest_service._active_user_requirements_path(quest_root),
5416
+ }
5417
+ items: list[dict[str, Any]] = []
5418
+ for name in requested:
5419
+ path = document_paths.get(name)
5420
+ if path is None:
5421
+ continue
5422
+ exists = path.exists()
5423
+ text = read_text(path, "") if exists else ""
5424
+ if normalized_mode == "excerpt":
5425
+ lines = [line.rstrip() for line in text.splitlines() if line.strip()]
5426
+ content = "\n".join(lines[:max_lines]).strip()
5427
+ else:
5428
+ content = text.strip()
5429
+ items.append(
5430
+ {
5431
+ "name": name,
5432
+ "path": str(path),
5433
+ "exists": exists,
5434
+ "content": content or None,
5435
+ }
5436
+ )
5437
+ return {
5438
+ "ok": True,
5439
+ "mode": normalized_mode,
5440
+ "count": len(items),
5441
+ "items": items,
5442
+ }
5443
+
5444
+ def get_conversation_context(
5445
+ self,
5446
+ quest_root: Path,
5447
+ *,
5448
+ limit: int = 12,
5449
+ include_attachments: bool = False,
5450
+ ) -> dict[str, Any]:
5451
+ quest_id = self._quest_id(quest_root)
5452
+ records = self.quest_service.history(quest_id, limit=max(1, limit))
5453
+ items: list[dict[str, Any]] = []
5454
+ for record in records[-max(1, limit) :]:
5455
+ item = {
5456
+ "id": record.get("id"),
5457
+ "role": record.get("role"),
5458
+ "source": record.get("source"),
5459
+ "content": record.get("content"),
5460
+ "created_at": record.get("created_at"),
5461
+ "reply_to_interaction_id": record.get("reply_to_interaction_id"),
5462
+ "run_id": record.get("run_id"),
5463
+ "skill_id": record.get("skill_id"),
5464
+ }
5465
+ if include_attachments:
5466
+ item["attachments"] = [dict(value) for value in (record.get("attachments") or []) if isinstance(value, dict)]
5467
+ items.append(item)
5468
+ latest_user = next((item for item in reversed(items) if str(item.get("role") or "") == "user"), None)
5469
+ return {
5470
+ "ok": True,
5471
+ "count": len(items),
5472
+ "items": items,
5473
+ "latest_user_message": latest_user,
2967
5474
  }
2968
5475
 
2969
5476
  def get_analysis_campaign(self, quest_root: Path, campaign_id: str | None = None) -> dict[str, Any]:
@@ -2987,6 +5494,9 @@ class ArtifactService:
2987
5494
  "parent_run_id": str(manifest.get("parent_run_id") or "").strip() or None,
2988
5495
  "parent_branch": str(manifest.get("parent_branch") or "").strip() or None,
2989
5496
  "parent_worktree_root": str(manifest.get("parent_worktree_root") or "").strip() or None,
5497
+ "paper_line_id": str(manifest.get("paper_line_id") or "").strip() or None,
5498
+ "paper_line_branch": str(manifest.get("paper_line_branch") or "").strip() or None,
5499
+ "paper_line_root": str(manifest.get("paper_line_root") or "").strip() or None,
2990
5500
  "selected_outline_ref": str(manifest.get("selected_outline_ref") or "").strip() or None,
2991
5501
  "campaign_origin": dict(manifest.get("campaign_origin") or {}) if isinstance(manifest.get("campaign_origin"), dict) else None,
2992
5502
  "todo_items": [dict(item) for item in (manifest.get("todo_items") or []) if isinstance(item, dict)],
@@ -2998,15 +5508,8 @@ class ArtifactService:
2998
5508
  }
2999
5509
 
3000
5510
  def list_paper_outlines(self, quest_root: Path) -> dict[str, Any]:
3001
- selected_outline_path = self._paper_selected_outline_path(quest_root)
3002
- selected_outline = read_json(selected_outline_path, {})
5511
+ selected_outline_path, selected_outline = self._read_selected_outline_record(quest_root)
3003
5512
  selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
3004
- if not selected_outline:
3005
- fallback_selected_outline_path = quest_root / "paper" / "selected_outline.json"
3006
- fallback_selected_outline = read_json(fallback_selected_outline_path, {})
3007
- if isinstance(fallback_selected_outline, dict) and fallback_selected_outline:
3008
- selected_outline = fallback_selected_outline
3009
- selected_outline_path = fallback_selected_outline_path
3010
5513
 
3011
5514
  selected_outline_id = str(selected_outline.get("outline_id") or "").strip()
3012
5515
  status_rank = {"candidate": 1, "revised": 2, "selected": 3}
@@ -3446,7 +5949,46 @@ class ArtifactService:
3446
5949
  }
3447
5950
 
3448
5951
  write_root = self._workspace_root_for(quest_root, workspace_root)
5952
+ semantic_key = self._semantic_record_key(quest_root, payload, workspace_root=write_root)
5953
+ suppress_equivalent = (
5954
+ bool(payload.get("suppress_if_semantically_equivalent"))
5955
+ if "suppress_if_semantically_equivalent" in payload
5956
+ else str(payload.get("kind") or "").strip() in {"decision", "report"}
5957
+ )
5958
+ if suppress_equivalent and semantic_key:
5959
+ existing = self._latest_semantically_equivalent_artifact(
5960
+ quest_root,
5961
+ kind=str(payload.get("kind") or "").strip(),
5962
+ semantic_key=semantic_key,
5963
+ )
5964
+ if existing is not None:
5965
+ existing_record = dict(existing.get("payload") or {})
5966
+ guidance_vm = dict(existing_record.get("guidance_vm") or {}) if isinstance(existing_record.get("guidance_vm"), dict) else {}
5967
+ guidance_text = guidance_summary(guidance_vm) or guidance_for_kind(str(existing_record.get("kind") or payload.get("kind") or "report"))
5968
+ return {
5969
+ "ok": True,
5970
+ "status": "semantically_equivalent",
5971
+ "artifact_id": existing_record.get("artifact_id"),
5972
+ "path": str(existing.get("path") or ""),
5973
+ "guidance": guidance_text,
5974
+ "guidance_vm": guidance_vm,
5975
+ "next_anchor": str(guidance_vm.get("recommended_skill") or "").strip() or None,
5976
+ "recommended_skill_reads": [str(guidance_vm.get("recommended_skill") or "").strip()] if str(guidance_vm.get("recommended_skill") or "").strip() else [],
5977
+ "suggested_artifact_calls": guidance_vm.get("suggested_artifact_calls") if isinstance(guidance_vm.get("suggested_artifact_calls"), list) else [],
5978
+ "next_instruction": guidance_text,
5979
+ "graph": None,
5980
+ "recorded": existing_record.get("kind"),
5981
+ "record": existing_record,
5982
+ "workspace_root": str(existing_record.get("workspace_root") or write_root),
5983
+ "artifact_path": str(existing.get("path") or ""),
5984
+ "checkpoint": None,
5985
+ "baseline_registry_entry": None,
5986
+ "semantic_key": semantic_key,
5987
+ "suppressed": True,
5988
+ }
3449
5989
  record = self._build_record(quest_root, payload, workspace_root=write_root)
5990
+ if semantic_key:
5991
+ record["semantic_key"] = semantic_key
3450
5992
  guidance_vm = build_guidance_for_record(record)
3451
5993
  record["guidance_vm"] = guidance_vm
3452
5994
  guidance_text = guidance_summary(guidance_vm) or guidance_for_kind(record["kind"])
@@ -3465,8 +6007,31 @@ class ArtifactService:
3465
6007
  next_instruction = guidance_text
3466
6008
  artifact_id = record["artifact_id"]
3467
6009
  artifact_path = self._artifact_path(write_root, record["kind"], artifact_id)
6010
+ previous_projection_state_kind, previous_projection_state = self.quest_service._artifact_projection_state(quest_root)
3468
6011
  write_json(artifact_path, record)
3469
6012
  append_jsonl(write_root / "artifacts" / "_index.jsonl", self._index_line(record, artifact_path))
6013
+ current_projection_state_kind, current_projection_state = self.quest_service._artifact_projection_state(quest_root)
6014
+ try:
6015
+ self.quest_service.update_artifact_projection(
6016
+ quest_root,
6017
+ record=record,
6018
+ artifact_path=artifact_path,
6019
+ workspace_root=write_root,
6020
+ previous_state_kind=previous_projection_state_kind,
6021
+ previous_state=previous_projection_state,
6022
+ current_state_kind=current_projection_state_kind,
6023
+ current_state=current_projection_state,
6024
+ )
6025
+ except Exception:
6026
+ pass
6027
+ try:
6028
+ self.quest_service.schedule_projection_refresh(
6029
+ quest_root,
6030
+ kinds=("details", "canvas"),
6031
+ throttle_seconds=0.0,
6032
+ )
6033
+ except Exception:
6034
+ pass
3470
6035
 
3471
6036
  should_checkpoint = self._should_checkpoint(record["kind"]) if checkpoint is None else checkpoint
3472
6037
  checkpoint_result = None
@@ -4026,12 +6591,18 @@ class ArtifactService:
4026
6591
  quest_root: Path,
4027
6592
  *,
4028
6593
  mode: str = "create",
6594
+ submission_mode: str = "line",
4029
6595
  idea_id: str | None = None,
4030
6596
  lineage_intent: str | None = None,
4031
6597
  title: str,
4032
6598
  problem: str = "",
4033
6599
  hypothesis: str = "",
4034
6600
  mechanism: str = "",
6601
+ method_brief: str = "",
6602
+ selection_scores: dict[str, Any] | None = None,
6603
+ mechanism_family: str = "",
6604
+ change_layer: str = "",
6605
+ source_lens: str = "",
4035
6606
  expected_gain: str = "",
4036
6607
  evidence_paths: list[str] | None = None,
4037
6608
  risks: list[str] | None = None,
@@ -4040,18 +6611,30 @@ class ArtifactService:
4040
6611
  foundation_reason: str = "",
4041
6612
  next_target: str = "experiment",
4042
6613
  draft_markdown: str = "",
6614
+ source_candidate_id: str | None = None,
4043
6615
  ) -> dict[str, Any]:
4044
6616
  normalized_mode = str(mode or "create").strip().lower()
4045
6617
  if normalized_mode not in {"create", "revise"}:
4046
6618
  raise ValueError("submit_idea mode must be `create` or `revise`.")
6619
+ normalized_submission_mode = str(submission_mode or "line").strip().lower() or "line"
6620
+ if normalized_submission_mode not in {"line", "candidate"}:
6621
+ raise ValueError("submit_idea submission_mode must be `line` or `candidate`.")
4047
6622
  self._require_baseline_gate_open(quest_root, action="submit_idea")
4048
6623
 
4049
6624
  quest_id = self._quest_id(quest_root)
4050
6625
  state = self.quest_service.read_research_state(quest_root)
6626
+ normalized_method_brief = str(method_brief or "").strip()
6627
+ normalized_selection_scores = self._normalize_selection_scores(selection_scores)
6628
+ normalized_mechanism_family = str(mechanism_family or "").strip() or None
6629
+ normalized_change_layer = str(change_layer or "").strip() or None
6630
+ normalized_source_lens = str(source_lens or "").strip() or None
4051
6631
  evidence_paths = [str(item).strip() for item in (evidence_paths or []) if str(item).strip()]
4052
6632
  risks = [str(item).strip() for item in (risks or []) if str(item).strip()]
4053
6633
  next_target = str(next_target or "experiment").strip().lower() or "experiment"
4054
6634
  normalized_lineage_intent = self._normalize_lineage_intent(lineage_intent)
6635
+ from ..prompts.builder import STANDARD_SKILLS
6636
+
6637
+ next_anchor = next_target if next_target in STANDARD_SKILLS else "experiment"
4055
6638
 
4056
6639
  if normalized_mode == "create":
4057
6640
  resolved_idea_id = str(idea_id or generate_id("idea")).strip()
@@ -4082,6 +6665,171 @@ class ArtifactService:
4082
6665
  )
4083
6666
  if not parent_branch:
4084
6667
  raise ValueError("Unable to resolve a starting branch for the new idea.")
6668
+ if normalized_submission_mode == "candidate":
6669
+ candidate_root = self._idea_candidate_root(quest_root, resolved_idea_id)
6670
+ idea_md_path = candidate_root / "idea.md"
6671
+ idea_draft_path = candidate_root / "draft.md"
6672
+ markdown = self._build_candidate_idea_markdown(
6673
+ idea_id=resolved_idea_id,
6674
+ quest_id=quest_id,
6675
+ title=title,
6676
+ problem=problem,
6677
+ hypothesis=hypothesis,
6678
+ mechanism=mechanism,
6679
+ expected_gain=expected_gain,
6680
+ risks=risks,
6681
+ evidence_paths=evidence_paths,
6682
+ decision_reason=decision_reason,
6683
+ next_target=next_target,
6684
+ candidate_root=candidate_root,
6685
+ method_brief=normalized_method_brief,
6686
+ selection_scores=normalized_selection_scores,
6687
+ mechanism_family=normalized_mechanism_family or "",
6688
+ change_layer=normalized_change_layer or "",
6689
+ source_lens=normalized_source_lens or "",
6690
+ foundation_ref=foundation,
6691
+ foundation_reason=foundation_reason,
6692
+ lineage_intent=normalized_lineage_intent,
6693
+ )
6694
+ draft = self._build_candidate_idea_draft_markdown(
6695
+ idea_id=resolved_idea_id,
6696
+ quest_id=quest_id,
6697
+ title=title,
6698
+ problem=problem,
6699
+ hypothesis=hypothesis,
6700
+ mechanism=mechanism,
6701
+ expected_gain=expected_gain,
6702
+ risks=risks,
6703
+ evidence_paths=evidence_paths,
6704
+ decision_reason=decision_reason,
6705
+ next_target=next_target,
6706
+ candidate_root=candidate_root,
6707
+ method_brief=normalized_method_brief,
6708
+ selection_scores=normalized_selection_scores,
6709
+ mechanism_family=normalized_mechanism_family or "",
6710
+ change_layer=normalized_change_layer or "",
6711
+ source_lens=normalized_source_lens or "",
6712
+ foundation_ref=foundation,
6713
+ foundation_reason=foundation_reason,
6714
+ lineage_intent=normalized_lineage_intent,
6715
+ draft_markdown=draft_markdown,
6716
+ )
6717
+ write_text(idea_md_path, markdown)
6718
+ write_text(idea_draft_path, draft)
6719
+ artifact = self.record(
6720
+ quest_root,
6721
+ {
6722
+ "kind": "idea",
6723
+ "status": "candidate",
6724
+ "summary": f"Idea candidate `{resolved_idea_id}` recorded for later ranking or promotion.",
6725
+ "reason": decision_reason or "A candidate optimization direction was recorded before promotion into a durable branch.",
6726
+ "idea_id": resolved_idea_id,
6727
+ "lineage_intent": normalized_lineage_intent,
6728
+ "branch": None,
6729
+ "parent_branch": parent_branch,
6730
+ "foundation_ref": foundation,
6731
+ "foundation_reason": foundation_reason.strip() or None,
6732
+ "flow_type": "idea_submission",
6733
+ "protocol_step": "candidate",
6734
+ "paths": {
6735
+ "idea_md": str(idea_md_path),
6736
+ "idea_draft_md": str(idea_draft_path),
6737
+ "candidate_root": str(candidate_root),
6738
+ },
6739
+ "details": {
6740
+ "title": title,
6741
+ "problem": problem,
6742
+ "hypothesis": hypothesis,
6743
+ "mechanism": mechanism,
6744
+ "method_brief": normalized_method_brief or None,
6745
+ "selection_scores": normalized_selection_scores or None,
6746
+ "mechanism_family": normalized_mechanism_family,
6747
+ "change_layer": normalized_change_layer,
6748
+ "source_lens": normalized_source_lens,
6749
+ "expected_gain": expected_gain,
6750
+ "next_target": next_target,
6751
+ "lineage_intent": normalized_lineage_intent,
6752
+ "parent_branch": parent_branch,
6753
+ "foundation_ref": foundation,
6754
+ "foundation_reason": foundation_reason.strip() or None,
6755
+ "idea_draft_path": str(idea_draft_path),
6756
+ "evidence_paths": evidence_paths,
6757
+ "risks": risks,
6758
+ "submission_mode": normalized_submission_mode,
6759
+ "source_candidate_id": str(source_candidate_id or "").strip() or None,
6760
+ },
6761
+ },
6762
+ checkpoint=False,
6763
+ workspace_root=quest_root,
6764
+ )
6765
+ interaction = self.interact(
6766
+ quest_root,
6767
+ kind="progress",
6768
+ message=self._build_idea_interaction_message(
6769
+ quest_root=quest_root,
6770
+ action="candidate",
6771
+ idea_id=resolved_idea_id,
6772
+ title=title,
6773
+ mechanism=mechanism,
6774
+ method_brief=normalized_method_brief,
6775
+ foundation_label=self._format_foundation_label(
6776
+ foundation,
6777
+ fallback=foundation.get("branch") or "current head",
6778
+ ),
6779
+ branch_name="candidate",
6780
+ change_layer=normalized_change_layer,
6781
+ source_lens=normalized_source_lens,
6782
+ expected_gain=expected_gain,
6783
+ next_target=next_target,
6784
+ ),
6785
+ deliver_to_bound_conversations=True,
6786
+ include_recent_inbound_messages=False,
6787
+ attachments=[
6788
+ {
6789
+ "kind": "idea_candidate",
6790
+ "idea_id": resolved_idea_id,
6791
+ "candidate_root": str(candidate_root),
6792
+ "parent_branch": parent_branch,
6793
+ "foundation_ref": foundation,
6794
+ "submission_mode": normalized_submission_mode,
6795
+ "source_candidate_id": str(source_candidate_id or "").strip() or None,
6796
+ "method_brief": normalized_method_brief or None,
6797
+ "selection_scores": normalized_selection_scores or None,
6798
+ "mechanism_family": normalized_mechanism_family,
6799
+ "change_layer": normalized_change_layer,
6800
+ "source_lens": normalized_source_lens,
6801
+ "next_target": next_target,
6802
+ }
6803
+ ],
6804
+ )
6805
+ return {
6806
+ "ok": True,
6807
+ "mode": normalized_mode,
6808
+ "submission_mode": normalized_submission_mode,
6809
+ "guidance": artifact.get("guidance"),
6810
+ "guidance_vm": artifact.get("guidance_vm"),
6811
+ "next_anchor": artifact.get("next_anchor"),
6812
+ "recommended_skill_reads": artifact.get("recommended_skill_reads"),
6813
+ "suggested_artifact_calls": artifact.get("suggested_artifact_calls"),
6814
+ "next_instruction": artifact.get("next_instruction"),
6815
+ "idea_id": resolved_idea_id,
6816
+ "lineage_intent": normalized_lineage_intent,
6817
+ "parent_branch": parent_branch,
6818
+ "foundation_ref": foundation,
6819
+ "foundation_reason": foundation_reason.strip() or None,
6820
+ "candidate_root": str(candidate_root),
6821
+ "idea_md_path": str(idea_md_path),
6822
+ "idea_draft_path": str(idea_draft_path),
6823
+ "artifact": artifact,
6824
+ "interaction": interaction,
6825
+ "promotable": True,
6826
+ "source_candidate_id": str(source_candidate_id or "").strip() or None,
6827
+ "method_brief": normalized_method_brief or None,
6828
+ "selection_scores": normalized_selection_scores or None,
6829
+ "mechanism_family": normalized_mechanism_family,
6830
+ "change_layer": normalized_change_layer,
6831
+ "source_lens": normalized_source_lens,
6832
+ }
4085
6833
  branch_name = f"idea/{quest_id}-{resolved_idea_id}"
4086
6834
  worktree_root = canonical_worktree_root(quest_root, f"idea-{resolved_idea_id}")
4087
6835
  branch_result = ensure_branch(quest_root, branch_name, start_point=parent_branch, checkout=False)
@@ -4108,6 +6856,11 @@ class ArtifactService:
4108
6856
  next_target=next_target,
4109
6857
  branch=branch_name,
4110
6858
  worktree_root=worktree_root,
6859
+ method_brief=normalized_method_brief,
6860
+ selection_scores=normalized_selection_scores,
6861
+ mechanism_family=normalized_mechanism_family or "",
6862
+ change_layer=normalized_change_layer or "",
6863
+ source_lens=normalized_source_lens or "",
4111
6864
  foundation_ref=foundation,
4112
6865
  foundation_reason=foundation_reason,
4113
6866
  lineage_intent=normalized_lineage_intent,
@@ -4126,6 +6879,11 @@ class ArtifactService:
4126
6879
  next_target=next_target,
4127
6880
  branch=branch_name,
4128
6881
  worktree_root=worktree_root,
6882
+ method_brief=normalized_method_brief,
6883
+ selection_scores=normalized_selection_scores,
6884
+ mechanism_family=normalized_mechanism_family or "",
6885
+ change_layer=normalized_change_layer or "",
6886
+ source_lens=normalized_source_lens or "",
4129
6887
  foundation_ref=foundation,
4130
6888
  foundation_reason=foundation_reason,
4131
6889
  lineage_intent=normalized_lineage_intent,
@@ -4161,6 +6919,11 @@ class ArtifactService:
4161
6919
  "problem": problem,
4162
6920
  "hypothesis": hypothesis,
4163
6921
  "mechanism": mechanism,
6922
+ "method_brief": normalized_method_brief or None,
6923
+ "selection_scores": normalized_selection_scores or None,
6924
+ "mechanism_family": normalized_mechanism_family,
6925
+ "change_layer": normalized_change_layer,
6926
+ "source_lens": normalized_source_lens,
4164
6927
  "expected_gain": expected_gain,
4165
6928
  "next_target": next_target,
4166
6929
  "branch_no": branch_no,
@@ -4171,6 +6934,8 @@ class ArtifactService:
4171
6934
  "idea_draft_path": str(idea_draft_path),
4172
6935
  "evidence_paths": evidence_paths,
4173
6936
  "risks": risks,
6937
+ "submission_mode": normalized_submission_mode,
6938
+ "source_candidate_id": str(source_candidate_id or "").strip() or None,
4174
6939
  },
4175
6940
  },
4176
6941
  checkpoint=False,
@@ -4192,31 +6957,30 @@ class ArtifactService:
4192
6957
  workspace_mode="idea",
4193
6958
  last_flow_type="idea_submission",
4194
6959
  )
4195
- self.quest_service.update_settings(quest_id, active_anchor="experiment")
6960
+ self.quest_service.update_settings(quest_id, active_anchor=next_anchor)
4196
6961
  checkpoint_result = self._checkpoint_with_optional_push(
4197
6962
  worktree_root,
4198
6963
  message=f"idea: create {resolved_idea_id}",
4199
6964
  )
4200
- idea_md_rel_path = self._workspace_relative(quest_root, idea_md_path)
4201
- idea_draft_rel_path = self._workspace_relative(quest_root, idea_draft_path)
4202
6965
  interaction = self.interact(
4203
6966
  quest_root,
4204
6967
  kind="milestone",
4205
6968
  message=self._build_idea_interaction_message(
6969
+ quest_root=quest_root,
4206
6970
  action="create",
4207
6971
  idea_id=resolved_idea_id,
4208
6972
  title=title,
4209
- problem=problem,
4210
- hypothesis=hypothesis,
4211
6973
  mechanism=mechanism,
6974
+ method_brief=normalized_method_brief,
4212
6975
  foundation_label=self._format_foundation_label(
4213
6976
  foundation,
4214
6977
  fallback=foundation.get("branch") or "current head",
4215
6978
  ),
4216
6979
  branch_name=branch_name,
6980
+ change_layer=normalized_change_layer,
6981
+ source_lens=normalized_source_lens,
6982
+ expected_gain=expected_gain,
4217
6983
  next_target=next_target,
4218
- idea_md_rel_path=idea_md_rel_path,
4219
- draft_md_rel_path=idea_draft_rel_path,
4220
6984
  ),
4221
6985
  deliver_to_bound_conversations=True,
4222
6986
  include_recent_inbound_messages=False,
@@ -4233,6 +6997,13 @@ class ArtifactService:
4233
6997
  "worktree_root": str(worktree_root),
4234
6998
  "idea_md_path": str(idea_md_path),
4235
6999
  "idea_draft_path": str(idea_draft_path),
7000
+ "submission_mode": normalized_submission_mode,
7001
+ "source_candidate_id": str(source_candidate_id or "").strip() or None,
7002
+ "method_brief": normalized_method_brief or None,
7003
+ "selection_scores": normalized_selection_scores or None,
7004
+ "mechanism_family": normalized_mechanism_family,
7005
+ "change_layer": normalized_change_layer,
7006
+ "source_lens": normalized_source_lens,
4236
7007
  "next_target": next_target,
4237
7008
  }
4238
7009
  ],
@@ -4240,6 +7011,7 @@ class ArtifactService:
4240
7011
  return {
4241
7012
  "ok": True,
4242
7013
  "mode": normalized_mode,
7014
+ "submission_mode": normalized_submission_mode,
4243
7015
  "guidance": artifact.get("guidance"),
4244
7016
  "guidance_vm": artifact.get("guidance_vm"),
4245
7017
  "next_anchor": artifact.get("next_anchor"),
@@ -4262,11 +7034,19 @@ class ArtifactService:
4262
7034
  "checkpoint": checkpoint_result,
4263
7035
  "interaction": interaction,
4264
7036
  "research_state": research_state,
7037
+ "source_candidate_id": str(source_candidate_id or "").strip() or None,
7038
+ "method_brief": normalized_method_brief or None,
7039
+ "selection_scores": normalized_selection_scores or None,
7040
+ "mechanism_family": normalized_mechanism_family,
7041
+ "change_layer": normalized_change_layer,
7042
+ "source_lens": normalized_source_lens,
4265
7043
  }
4266
7044
 
4267
7045
  resolved_idea_id = str(idea_id or state.get("active_idea_id") or "").strip()
4268
7046
  if not resolved_idea_id:
4269
7047
  raise ValueError("submit_idea(mode='revise') requires an existing active `idea_id`.")
7048
+ if normalized_submission_mode != "line":
7049
+ raise ValueError("submit_idea(mode='revise') currently only supports submission_mode='line'.")
4270
7050
  if normalized_lineage_intent:
4271
7051
  raise ValueError("submit_idea(mode='revise') does not accept `lineage_intent`; use mode='create' for new branch lineage.")
4272
7052
  branch_name = str(
@@ -4288,6 +7068,11 @@ class ArtifactService:
4288
7068
  draft_created_at = None
4289
7069
  existing_foundation_ref = None
4290
7070
  existing_foundation_reason = None
7071
+ existing_method_brief = None
7072
+ existing_selection_scores = None
7073
+ existing_mechanism_family = None
7074
+ existing_change_layer = None
7075
+ existing_source_lens = None
4291
7076
  if idea_md_path.exists():
4292
7077
  metadata, _body = load_markdown_document(idea_md_path)
4293
7078
  created_at = metadata.get("created_at")
@@ -4297,9 +7082,29 @@ class ArtifactService:
4297
7082
  else None
4298
7083
  )
4299
7084
  existing_foundation_reason = str(metadata.get("foundation_reason") or "").strip() or None
7085
+ existing_method_brief = str(metadata.get("method_brief") or "").strip() or None
7086
+ existing_selection_scores = self._normalize_selection_scores(metadata.get("selection_scores"))
7087
+ existing_mechanism_family = str(metadata.get("mechanism_family") or "").strip() or None
7088
+ existing_change_layer = str(metadata.get("change_layer") or "").strip() or None
7089
+ existing_source_lens = str(metadata.get("source_lens") or "").strip() or None
4300
7090
  if idea_draft_path.exists():
4301
7091
  draft_metadata, _draft_body = load_markdown_document(idea_draft_path)
4302
7092
  draft_created_at = draft_metadata.get("created_at")
7093
+ if existing_method_brief is None:
7094
+ existing_method_brief = str(draft_metadata.get("method_brief") or "").strip() or None
7095
+ if existing_selection_scores is None:
7096
+ existing_selection_scores = self._normalize_selection_scores(draft_metadata.get("selection_scores"))
7097
+ if existing_mechanism_family is None:
7098
+ existing_mechanism_family = str(draft_metadata.get("mechanism_family") or "").strip() or None
7099
+ if existing_change_layer is None:
7100
+ existing_change_layer = str(draft_metadata.get("change_layer") or "").strip() or None
7101
+ if existing_source_lens is None:
7102
+ existing_source_lens = str(draft_metadata.get("source_lens") or "").strip() or None
7103
+ revised_method_brief = normalized_method_brief or existing_method_brief or ""
7104
+ revised_selection_scores = normalized_selection_scores or existing_selection_scores
7105
+ revised_mechanism_family = normalized_mechanism_family or existing_mechanism_family
7106
+ revised_change_layer = normalized_change_layer or existing_change_layer
7107
+ revised_source_lens = normalized_source_lens or existing_source_lens
4303
7108
  markdown = self._build_idea_markdown(
4304
7109
  idea_id=resolved_idea_id,
4305
7110
  quest_id=quest_id,
@@ -4314,6 +7119,11 @@ class ArtifactService:
4314
7119
  next_target=next_target,
4315
7120
  branch=branch_name,
4316
7121
  worktree_root=worktree_root,
7122
+ method_brief=revised_method_brief,
7123
+ selection_scores=revised_selection_scores,
7124
+ mechanism_family=revised_mechanism_family or "",
7125
+ change_layer=revised_change_layer or "",
7126
+ source_lens=revised_source_lens or "",
4317
7127
  foundation_ref=existing_foundation_ref,
4318
7128
  foundation_reason=foundation_reason.strip() or existing_foundation_reason or "",
4319
7129
  lineage_intent=None,
@@ -4333,6 +7143,11 @@ class ArtifactService:
4333
7143
  next_target=next_target,
4334
7144
  branch=branch_name,
4335
7145
  worktree_root=worktree_root,
7146
+ method_brief=revised_method_brief,
7147
+ selection_scores=revised_selection_scores,
7148
+ mechanism_family=revised_mechanism_family or "",
7149
+ change_layer=revised_change_layer or "",
7150
+ source_lens=revised_source_lens or "",
4336
7151
  foundation_ref=existing_foundation_ref,
4337
7152
  foundation_reason=foundation_reason.strip() or existing_foundation_reason or "",
4338
7153
  lineage_intent=None,
@@ -4368,6 +7183,11 @@ class ArtifactService:
4368
7183
  "problem": problem,
4369
7184
  "hypothesis": hypothesis,
4370
7185
  "mechanism": mechanism,
7186
+ "method_brief": revised_method_brief or None,
7187
+ "selection_scores": revised_selection_scores or None,
7188
+ "mechanism_family": revised_mechanism_family,
7189
+ "change_layer": revised_change_layer,
7190
+ "source_lens": revised_source_lens,
4371
7191
  "expected_gain": expected_gain,
4372
7192
  "next_target": next_target,
4373
7193
  "parent_branch": parent_branch,
@@ -4376,6 +7196,7 @@ class ArtifactService:
4376
7196
  "idea_draft_path": str(idea_draft_path),
4377
7197
  "evidence_paths": evidence_paths,
4378
7198
  "risks": risks,
7199
+ "submission_mode": normalized_submission_mode,
4379
7200
  },
4380
7201
  },
4381
7202
  checkpoint=False,
@@ -4398,31 +7219,30 @@ class ArtifactService:
4398
7219
  quest_root,
4399
7220
  **research_state_updates,
4400
7221
  )
4401
- self.quest_service.update_settings(quest_id, active_anchor="experiment")
7222
+ self.quest_service.update_settings(quest_id, active_anchor=next_anchor)
4402
7223
  checkpoint_result = self._checkpoint_with_optional_push(
4403
7224
  worktree_root,
4404
7225
  message=f"idea: revise {resolved_idea_id}",
4405
7226
  )
4406
- idea_md_rel_path = self._workspace_relative(quest_root, idea_md_path)
4407
- idea_draft_rel_path = self._workspace_relative(quest_root, idea_draft_path)
4408
7227
  interaction = self.interact(
4409
7228
  quest_root,
4410
7229
  kind="progress",
4411
7230
  message=self._build_idea_interaction_message(
7231
+ quest_root=quest_root,
4412
7232
  action="revise",
4413
7233
  idea_id=resolved_idea_id,
4414
7234
  title=title,
4415
- problem=problem,
4416
- hypothesis=hypothesis,
4417
7235
  mechanism=mechanism,
7236
+ method_brief=revised_method_brief,
4418
7237
  foundation_label=self._format_foundation_label(
4419
7238
  existing_foundation_ref,
4420
7239
  fallback=(existing_foundation_ref or {}).get("branch") or "current head",
4421
7240
  ),
4422
7241
  branch_name=branch_name,
7242
+ change_layer=revised_change_layer,
7243
+ source_lens=revised_source_lens,
7244
+ expected_gain=expected_gain,
4423
7245
  next_target=next_target,
4424
- idea_md_rel_path=idea_md_rel_path,
4425
- draft_md_rel_path=idea_draft_rel_path,
4426
7246
  ),
4427
7247
  deliver_to_bound_conversations=True,
4428
7248
  include_recent_inbound_messages=False,
@@ -4436,6 +7256,12 @@ class ArtifactService:
4436
7256
  "worktree_root": str(worktree_root),
4437
7257
  "idea_md_path": str(idea_md_path),
4438
7258
  "idea_draft_path": str(idea_draft_path),
7259
+ "submission_mode": normalized_submission_mode,
7260
+ "method_brief": revised_method_brief or None,
7261
+ "selection_scores": revised_selection_scores or None,
7262
+ "mechanism_family": revised_mechanism_family,
7263
+ "change_layer": revised_change_layer,
7264
+ "source_lens": revised_source_lens,
4439
7265
  "next_target": next_target,
4440
7266
  }
4441
7267
  ],
@@ -4443,6 +7269,7 @@ class ArtifactService:
4443
7269
  return {
4444
7270
  "ok": True,
4445
7271
  "mode": normalized_mode,
7272
+ "submission_mode": normalized_submission_mode,
4446
7273
  "guidance": artifact.get("guidance"),
4447
7274
  "guidance_vm": artifact.get("guidance_vm"),
4448
7275
  "next_anchor": artifact.get("next_anchor"),
@@ -4457,6 +7284,11 @@ class ArtifactService:
4457
7284
  "worktree_root": str(worktree_root),
4458
7285
  "idea_md_path": str(idea_md_path),
4459
7286
  "idea_draft_path": str(idea_draft_path),
7287
+ "method_brief": revised_method_brief or None,
7288
+ "selection_scores": revised_selection_scores or None,
7289
+ "mechanism_family": revised_mechanism_family,
7290
+ "change_layer": revised_change_layer,
7291
+ "source_lens": revised_source_lens,
4460
7292
  "artifact": artifact,
4461
7293
  "checkpoint": checkpoint_result,
4462
7294
  "interaction": interaction,
@@ -4532,6 +7364,12 @@ class ArtifactService:
4532
7364
  return dict(quest_data.get("startup_contract") or {})
4533
7365
  return {}
4534
7366
 
7367
+ def _post_baseline_anchor(self, quest_root: Path) -> str:
7368
+ startup_contract = self._startup_contract(quest_root)
7369
+ raw_need_research_paper = startup_contract.get("need_research_paper")
7370
+ need_research_paper = raw_need_research_paper if isinstance(raw_need_research_paper, bool) else True
7371
+ return "idea" if need_research_paper else "optimize"
7372
+
4535
7373
  def _decision_policy(self, quest_root: Path) -> str:
4536
7374
  value = str(self._startup_contract(quest_root).get("decision_policy") or "").strip().lower()
4537
7375
  if value in {"autonomous", "user_gated"}:
@@ -4862,6 +7700,7 @@ class ArtifactService:
4862
7700
  "metric_validation": metric_validation,
4863
7701
  }
4864
7702
  write_json(result_json_path, result_payload)
7703
+ metric_charts: list[dict[str, Any]] = []
4865
7704
 
4866
7705
  artifact = self.record(
4867
7706
  quest_root,
@@ -4882,6 +7721,13 @@ class ArtifactService:
4882
7721
  "paths": {
4883
7722
  "run_md": str(run_md_path),
4884
7723
  "result_json": str(result_json_path),
7724
+ **(
7725
+ {
7726
+ "connector_chart_dir": str(self._main_experiment_chart_dir(workspace_root, run_id=run_identifier))
7727
+ }
7728
+ if metric_charts
7729
+ else {}
7730
+ ),
4885
7731
  },
4886
7732
  "details": {
4887
7733
  "title": title.strip() or run_identifier,
@@ -4897,6 +7743,7 @@ class ArtifactService:
4897
7743
  "auto_promoted_run_branch": auto_promoted_run_branch,
4898
7744
  "changed_file_count": len(resolved_changed_files),
4899
7745
  "evidence_count": len(resolved_evidence_paths),
7746
+ "connector_chart_count": 0,
4900
7747
  "evaluation_summary": normalized_evaluation_summary,
4901
7748
  },
4902
7749
  "delivery_policy": delivery_policy,
@@ -4922,6 +7769,23 @@ class ArtifactService:
4922
7769
  commit_message=f"experiment: record main {run_identifier}",
4923
7770
  workspace_root=workspace_root,
4924
7771
  )
7772
+ metric_charts = self._generate_main_experiment_metric_charts(
7773
+ quest_root,
7774
+ workspace_root=workspace_root,
7775
+ run_id=run_identifier,
7776
+ )
7777
+ if metric_charts:
7778
+ result_payload["connector_metric_charts"] = metric_charts
7779
+ write_json(result_json_path, result_payload)
7780
+ artifact_record = dict(artifact.get("record") or {}) if isinstance(artifact.get("record"), dict) else {}
7781
+ artifact_record["connector_metric_charts"] = metric_charts
7782
+ details = dict(artifact_record.get("details") or {}) if isinstance(artifact_record.get("details"), dict) else {}
7783
+ details["connector_chart_count"] = len(metric_charts)
7784
+ artifact_record["details"] = details
7785
+ artifact["record"] = artifact_record
7786
+ artifact_path = Path(str(artifact.get("path") or ""))
7787
+ if artifact_path:
7788
+ write_json(artifact_path, artifact_record)
4925
7789
  interaction = self.interact(
4926
7790
  quest_root,
4927
7791
  kind="milestone",
@@ -4959,9 +7823,78 @@ class ArtifactService:
4959
7823
  "need_research_paper": delivery_policy.get("need_research_paper"),
4960
7824
  "recommended_next_route": delivery_policy.get("recommended_next_route"),
4961
7825
  "evaluation_summary": normalized_evaluation_summary,
7826
+ "connector_metric_charts": metric_charts,
4962
7827
  }
4963
7828
  ],
4964
7829
  )
7830
+ chart_delivery = self._send_main_experiment_metric_charts(
7831
+ quest_root,
7832
+ run_id=run_identifier,
7833
+ title=title.strip() or run_identifier,
7834
+ charts=metric_charts,
7835
+ )
7836
+ outline_path, selected_outline = self._read_selected_outline_for_sync(quest_root)
7837
+ selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
7838
+ detailed_outline = (
7839
+ dict(selected_outline.get("detailed_outline") or {})
7840
+ if isinstance(selected_outline.get("detailed_outline"), dict)
7841
+ else {}
7842
+ )
7843
+ outline_sections = self._normalize_outline_sections(
7844
+ selected_outline.get("sections"),
7845
+ experimental_designs=self._normalize_string_list(detailed_outline.get("experimental_designs")),
7846
+ )
7847
+ section_id = next(
7848
+ (
7849
+ str(section.get("section_id") or "").strip()
7850
+ for section in outline_sections
7851
+ if run_identifier in self._normalize_string_list(section.get("required_items"))
7852
+ or run_identifier in self._normalize_string_list(section.get("optional_items"))
7853
+ ),
7854
+ None,
7855
+ )
7856
+ if not section_id:
7857
+ section_id = next(
7858
+ (
7859
+ str(section.get("section_id") or "").strip()
7860
+ for section in outline_sections
7861
+ if str(section.get("paper_role") or "main_text").strip() == "main_text"
7862
+ ),
7863
+ None,
7864
+ )
7865
+ if not section_id:
7866
+ section_id = str((outline_sections[0] or {}).get("section_id") or "").strip() if outline_sections else "main-results"
7867
+ paper_role = "main_text"
7868
+ self._upsert_paper_evidence_item(
7869
+ quest_root,
7870
+ {
7871
+ "item_id": run_identifier,
7872
+ "title": title.strip() or run_identifier,
7873
+ "kind": "main_experiment",
7874
+ "status": status,
7875
+ "paper_role": paper_role,
7876
+ "section_id": section_id,
7877
+ "claim_links": [],
7878
+ "setup": setup.strip() or None,
7879
+ "result_summary": results.strip() or conclusion.strip() or progress_eval.get("reason"),
7880
+ "key_metrics": self._paper_evidence_key_metrics(
7881
+ metric_rows=normalized_metric_rows,
7882
+ metrics_summary=normalized_metrics_summary,
7883
+ ),
7884
+ "source_paths": [
7885
+ value
7886
+ for value in [
7887
+ self._workspace_relative(quest_root, run_md_path),
7888
+ self._workspace_relative(quest_root, result_json_path),
7889
+ *resolved_evidence_paths,
7890
+ ]
7891
+ if value
7892
+ ],
7893
+ "run_id": run_identifier,
7894
+ "selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
7895
+ "evaluation_summary": normalized_evaluation_summary,
7896
+ },
7897
+ )
4965
7898
  self.quest_service.update_settings(self._quest_id(quest_root), active_anchor="decision")
4966
7899
  research_state = self.quest_service.update_research_state(
4967
7900
  quest_root,
@@ -4995,6 +7928,8 @@ class ArtifactService:
4995
7928
  "result_json_path": str(result_json_path),
4996
7929
  "artifact": artifact,
4997
7930
  "interaction": interaction,
7931
+ "connector_metric_charts": metric_charts,
7932
+ "connector_metric_chart_delivery": chart_delivery,
4998
7933
  "research_state": research_state,
4999
7934
  "metrics_summary": normalized_metrics_summary,
5000
7935
  "baseline_comparisons": {
@@ -5095,6 +8030,45 @@ class ArtifactService:
5095
8030
  "Writing-facing analysis campaigns require one todo item per slice. "
5096
8031
  f"Missing todo items for: {', '.join(missing_slice_ids)}."
5097
8032
  )
8033
+ missing_contract_fields: list[str] = []
8034
+ for item in normalized_todo_items:
8035
+ title = str(item.get("title") or item.get("slice_id") or item.get("todo_id") or "todo").strip() or "todo"
8036
+ for field in ("section_id", "item_id", "paper_role"):
8037
+ if str(item.get(field) or "").strip():
8038
+ continue
8039
+ missing_contract_fields.append(f"{title}:{field}")
8040
+ if not self._normalize_string_list(item.get("claim_links")):
8041
+ missing_contract_fields.append(f"{title}:claim_links")
8042
+ if missing_contract_fields:
8043
+ raise ValueError(
8044
+ "Writing-facing analysis campaigns require outline-bound paper contract fields for every todo item. "
8045
+ f"Missing: {', '.join(missing_contract_fields[:8])}."
8046
+ )
8047
+ paper_line_branch: str | None = None
8048
+ paper_line_root: str | None = None
8049
+ paper_line_id: str | None = None
8050
+ if writing_facing:
8051
+ paper_context = self._ensure_active_paper_workspace(
8052
+ quest_root,
8053
+ source_branch=parent_branch,
8054
+ source_run_id=resolved_parent_run_id,
8055
+ source_idea_id=active_idea_id,
8056
+ )
8057
+ paper_line_branch = str(paper_context.get("branch") or "").strip() or None
8058
+ paper_line_root = str(paper_context.get("worktree_root") or "").strip() or None
8059
+ paper_line_id = self._paper_line_id(
8060
+ paper_branch=paper_line_branch,
8061
+ outline_id=resolved_outline_ref,
8062
+ source_run_id=resolved_parent_run_id,
8063
+ )
8064
+ if paper_line_root:
8065
+ self._write_paper_line_state(
8066
+ quest_root,
8067
+ workspace_root=Path(paper_line_root),
8068
+ source_branch=parent_branch,
8069
+ source_run_id=resolved_parent_run_id,
8070
+ source_idea_id=active_idea_id,
8071
+ )
5098
8072
  slice_contexts: list[dict[str, Any]] = []
5099
8073
  inventory_entries: list[dict[str, Any]] = []
5100
8074
  for index, raw in enumerate(slices, start=1):
@@ -5123,6 +8097,19 @@ class ArtifactService:
5123
8097
  manuscript_targets = self._normalize_string_list(
5124
8098
  raw.get("manuscript_targets") or matched_todo.get("manuscript_targets")
5125
8099
  )
8100
+ section_id = str(raw.get("section_id") or matched_todo.get("section_id") or "").strip() or None
8101
+ item_id = str(raw.get("item_id") or matched_todo.get("item_id") or slice_id).strip() or slice_id
8102
+ claim_links = self._normalize_string_list(raw.get("claim_links") or matched_todo.get("claim_links"))
8103
+ paper_role = (
8104
+ str(raw.get("paper_role") or matched_todo.get("paper_role") or matched_todo.get("paper_placement") or "").strip()
8105
+ or None
8106
+ )
8107
+ paper_placement = (
8108
+ str(raw.get("paper_placement") or matched_todo.get("paper_placement") or paper_role or "").strip()
8109
+ or None
8110
+ )
8111
+ tier = str(raw.get("tier") or matched_todo.get("tier") or "").strip() or None
8112
+ exp_id = str(raw.get("exp_id") or matched_todo.get("exp_id") or "").strip() or None
5126
8113
  why_now = str(raw.get("why_now") or matched_todo.get("why_now") or "").strip()
5127
8114
  success_criteria = str(raw.get("success_criteria") or matched_todo.get("success_criteria") or "").strip()
5128
8115
  abandonment_criteria = str(
@@ -5145,14 +8132,24 @@ class ArtifactService:
5145
8132
  "",
5146
8133
  str(raw.get("research_question") or matched_todo.get("research_question") or "").strip() or "TBD",
5147
8134
  "",
5148
- "## Experimental Design",
5149
- "",
5150
- str(raw.get("experimental_design") or matched_todo.get("experimental_design") or "").strip() or "TBD",
5151
- "",
5152
- "## Why Now",
5153
- "",
5154
- why_now or "TBD",
5155
- "",
8135
+ "## Experimental Design",
8136
+ "",
8137
+ str(raw.get("experimental_design") or matched_todo.get("experimental_design") or "").strip() or "TBD",
8138
+ "",
8139
+ "## Paper Contract Binding",
8140
+ "",
8141
+ f"- Section id: `{section_id or 'none'}`",
8142
+ f"- Item id: `{item_id}`",
8143
+ f"- Exp id: `{exp_id or 'none'}`",
8144
+ f"- Paper role: `{paper_role or 'none'}`",
8145
+ f"- Paper placement: `{paper_placement or 'none'}`",
8146
+ f"- Tier: `{tier or 'none'}`",
8147
+ f"- Claim links: {', '.join(claim_links) or 'none'}",
8148
+ "",
8149
+ "## Why Now",
8150
+ "",
8151
+ why_now or "TBD",
8152
+ "",
5156
8153
  "## Hypothesis",
5157
8154
  "",
5158
8155
  str(raw.get("hypothesis") or "").strip() or "TBD",
@@ -5228,6 +8225,13 @@ class ArtifactService:
5228
8225
  "experimental_design": str(
5229
8226
  raw.get("experimental_design") or matched_todo.get("experimental_design") or ""
5230
8227
  ).strip(),
8228
+ "section_id": section_id,
8229
+ "item_id": item_id,
8230
+ "exp_id": exp_id,
8231
+ "paper_role": paper_role,
8232
+ "paper_placement": paper_placement,
8233
+ "tier": tier,
8234
+ "claim_links": claim_links,
5231
8235
  "why_now": why_now,
5232
8236
  "hypothesis": str(raw.get("hypothesis") or "").strip(),
5233
8237
  "required_changes": str(raw.get("required_changes") or "").strip(),
@@ -5275,12 +8279,19 @@ class ArtifactService:
5275
8279
  "experimental_designs": normalized_experimental_designs,
5276
8280
  "todo_items": [
5277
8281
  {
8282
+ "exp_id": str(item.get("exp_id") or context.get("exp_id") or "").strip() or None,
5278
8283
  "todo_id": str(item.get("todo_id") or item.get("slice_id") or context["slice_id"]).strip() or context["slice_id"],
5279
8284
  "slice_id": context["slice_id"],
5280
8285
  "title": str(item.get("title") or context["title"]).strip() or context["title"],
5281
8286
  "status": str(item.get("status") or "pending").strip() or "pending",
5282
8287
  "research_question": item.get("research_question") or context.get("research_question"),
5283
8288
  "experimental_design": item.get("experimental_design") or context.get("experimental_design"),
8289
+ "tier": item.get("tier") or context.get("tier"),
8290
+ "paper_placement": item.get("paper_placement") or context.get("paper_placement"),
8291
+ "paper_role": item.get("paper_role") or context.get("paper_role"),
8292
+ "section_id": item.get("section_id") or context.get("section_id"),
8293
+ "item_id": item.get("item_id") or context.get("item_id"),
8294
+ "claim_links": item.get("claim_links") or context.get("claim_links") or [],
5284
8295
  "completion_condition": item.get("completion_condition") or context.get("completion_condition") or context.get("must_not_simplify"),
5285
8296
  "why_now": item.get("why_now") or context.get("why_now"),
5286
8297
  "success_criteria": item.get("success_criteria") or context.get("success_criteria"),
@@ -5339,6 +8350,11 @@ class ArtifactService:
5339
8350
  f"- Goal: {item['goal'] or 'TBD'}",
5340
8351
  f"- Research question: {item['research_question'] or 'TBD'}",
5341
8352
  f"- Experimental design: {item['experimental_design'] or 'TBD'}",
8353
+ f"- Section id: `{item['section_id'] or 'none'}`",
8354
+ f"- Item id: `{item['item_id'] or 'none'}`",
8355
+ f"- Exp id: `{item['exp_id'] or 'none'}`",
8356
+ f"- Paper role: `{item['paper_role'] or 'none'}`",
8357
+ f"- Claim links: {', '.join(item['claim_links']) or 'none'}",
5342
8358
  f"- Why now: {item['why_now'] or 'TBD'}",
5343
8359
  f"- Required baselines: {', '.join(self._analysis_baseline_label(entry) for entry in item['required_baselines']) or 'none'}",
5344
8360
  f"- Success criteria: {item['success_criteria'] or 'TBD'}",
@@ -5361,6 +8377,9 @@ class ArtifactService:
5361
8377
  "active_idea_id": active_idea_id,
5362
8378
  "parent_branch": parent_branch,
5363
8379
  "parent_worktree_root": str(parent_worktree_root),
8380
+ "paper_line_id": paper_line_id,
8381
+ "paper_line_branch": paper_line_branch,
8382
+ "paper_line_root": paper_line_root,
5364
8383
  "campaign_origin": normalized_campaign_origin,
5365
8384
  "selected_outline_ref": resolved_outline_ref,
5366
8385
  "research_questions": normalized_research_questions,
@@ -5398,6 +8417,13 @@ class ArtifactService:
5398
8417
  "run_kind": item["run_kind"],
5399
8418
  "research_question": item["research_question"],
5400
8419
  "experimental_design": item["experimental_design"],
8420
+ "section_id": item["section_id"],
8421
+ "item_id": item["item_id"],
8422
+ "exp_id": item["exp_id"],
8423
+ "paper_role": item["paper_role"],
8424
+ "paper_placement": item["paper_placement"],
8425
+ "tier": item["tier"],
8426
+ "claim_links": item["claim_links"],
5401
8427
  "why_now": item["why_now"],
5402
8428
  "completion_condition": item["completion_condition"] or item["must_not_simplify"],
5403
8429
  "must_not_simplify": item["must_not_simplify"],
@@ -5433,6 +8459,9 @@ class ArtifactService:
5433
8459
  "campaign_title": campaign_title,
5434
8460
  "campaign_goal": campaign_goal,
5435
8461
  "parent_run_id": resolved_parent_run_id,
8462
+ "paper_line_id": paper_line_id,
8463
+ "paper_line_branch": paper_line_branch,
8464
+ "paper_line_root": paper_line_root,
5436
8465
  "campaign_origin": normalized_campaign_origin,
5437
8466
  "selected_outline_ref": resolved_outline_ref,
5438
8467
  "todo_manifest_path": str(todo_manifest_path),
@@ -5447,6 +8476,13 @@ class ArtifactService:
5447
8476
  "goal": item["goal"],
5448
8477
  "research_question": item["research_question"],
5449
8478
  "experimental_design": item["experimental_design"],
8479
+ "section_id": item["section_id"],
8480
+ "item_id": item["item_id"],
8481
+ "exp_id": item["exp_id"],
8482
+ "paper_role": item["paper_role"],
8483
+ "paper_placement": item["paper_placement"],
8484
+ "tier": item["tier"],
8485
+ "claim_links": item["claim_links"],
5450
8486
  "why_now": item["why_now"],
5451
8487
  "completion_condition": item["completion_condition"] or item["must_not_simplify"],
5452
8488
  "must_not_simplify": item["must_not_simplify"],
@@ -5463,6 +8499,32 @@ class ArtifactService:
5463
8499
  checkpoint=False,
5464
8500
  workspace_root=parent_worktree_root,
5465
8501
  )
8502
+ if writing_facing:
8503
+ self._sync_outline_sections(
8504
+ quest_root,
8505
+ items=[
8506
+ {
8507
+ "item_id": item.get("item_id"),
8508
+ "title": item.get("title"),
8509
+ "kind": "analysis_slice",
8510
+ "paper_role": item.get("paper_role"),
8511
+ "status": "pending",
8512
+ "claim_links": item.get("claim_links"),
8513
+ "section_id": item.get("section_id"),
8514
+ "source_paths": [self._workspace_relative(quest_root, Path(item["plan_path"]))] if item.get("plan_path") else [],
8515
+ }
8516
+ for item in slice_contexts
8517
+ ],
8518
+ workspace_root=Path(paper_line_root) if paper_line_root else None,
8519
+ )
8520
+ if paper_line_root:
8521
+ self._write_paper_line_state(
8522
+ quest_root,
8523
+ workspace_root=Path(paper_line_root),
8524
+ source_branch=parent_branch,
8525
+ source_run_id=resolved_parent_run_id,
8526
+ source_idea_id=active_idea_id,
8527
+ )
5466
8528
  research_state = self.quest_service.update_research_state(
5467
8529
  quest_root,
5468
8530
  active_idea_id=active_idea_id,
@@ -5568,9 +8630,7 @@ class ArtifactService:
5568
8630
  selected_outline_path = paper_root / "selected_outline.json"
5569
8631
  else:
5570
8632
  selected_outline_path = self._paper_selected_outline_path(quest_root, workspace_root=workspace_root)
5571
- existing_selected = read_json(selected_outline_path, {})
5572
- if not isinstance(existing_selected, dict) or not existing_selected:
5573
- existing_selected = read_json(quest_root / "paper" / "selected_outline.json", {})
8633
+ _, existing_selected = self._read_selected_outline_record(quest_root, workspace_root=workspace_root)
5574
8634
  existing_selected = existing_selected if isinstance(existing_selected, dict) else {}
5575
8635
  if normalized_mode == "candidate":
5576
8636
  resolved_outline_id = str(outline_id or self._next_paper_outline_id(quest_root)).strip()
@@ -5650,10 +8710,8 @@ class ArtifactService:
5650
8710
  created_at=str(source_record.get("created_at") or "") or None,
5651
8711
  )
5652
8712
 
5653
- write_json(selected_outline_path, resolved_record)
5654
- canonical_selected_outline_path = quest_root / "paper" / "selected_outline.json"
5655
- if canonical_selected_outline_path.resolve() != selected_outline_path.resolve():
5656
- write_json(canonical_selected_outline_path, resolved_record)
8713
+ self._write_selected_outline_sync(quest_root, resolved_record, workspace_root=workspace_root)
8714
+ selected_outline_path = paper_root / "selected_outline.json"
5657
8715
  if source_candidate_path.exists():
5658
8716
  source_record["status"] = "selected" if normalized_mode == "select" else "revised"
5659
8717
  source_record["updated_at"] = utc_now()
@@ -5682,6 +8740,13 @@ class ArtifactService:
5682
8740
  "",
5683
8741
  ]
5684
8742
  write_text(outline_selection_path, "\n".join(selection_lines).rstrip() + "\n")
8743
+ paper_line_state = self._write_paper_line_state(
8744
+ quest_root,
8745
+ workspace_root=workspace_root,
8746
+ source_branch=str(paper_context.get("source_branch") or "").strip() or None,
8747
+ source_run_id=str(paper_context.get("source_run_id") or "").strip() or None,
8748
+ source_idea_id=str(paper_context.get("source_idea_id") or "").strip() or None,
8749
+ )
5685
8750
  self.quest_service.update_settings(self._quest_id(quest_root), active_anchor="write")
5686
8751
  artifact = self.record(
5687
8752
  quest_root,
@@ -5695,13 +8760,17 @@ class ArtifactService:
5695
8760
  "protocol_step": "select" if normalized_mode == "select" else "revise",
5696
8761
  "paths": {
5697
8762
  "selected_outline_json": str(selected_outline_path),
8763
+ "outline_manifest_json": str(self._paper_outline_manifest_path(quest_root, workspace_root=workspace_root)),
5698
8764
  "outline_selection_md": str(outline_selection_path),
8765
+ "paper_line_state_json": str(self._paper_line_state_path(quest_root, workspace_root=workspace_root)),
5699
8766
  **({"revised_outline_json": str(revised_outline_path)} if revised_outline_path else {}),
5700
8767
  },
5701
8768
  "details": {
5702
8769
  "outline_id": source_outline_id,
5703
8770
  "title": resolved_record.get("title"),
5704
8771
  "selected_reason": selected_reason,
8772
+ "paper_line_id": paper_line_state.get("paper_line_id"),
8773
+ "paper_branch": paper_line_state.get("paper_branch"),
5705
8774
  },
5706
8775
  },
5707
8776
  checkpoint=False,
@@ -5742,6 +8811,8 @@ class ArtifactService:
5742
8811
  "title": resolved_record.get("title"),
5743
8812
  "selected_reason": selected_reason,
5744
8813
  "selected_outline_path": str(selected_outline_path),
8814
+ "outline_manifest_path": str(self._paper_outline_manifest_path(quest_root, workspace_root=workspace_root)),
8815
+ "paper_line_state_path": str(self._paper_line_state_path(quest_root, workspace_root=workspace_root)),
5745
8816
  "outline_selection_path": str(outline_selection_path),
5746
8817
  "revised_outline_path": str(revised_outline_path) if revised_outline_path else None,
5747
8818
  }
@@ -5752,9 +8823,12 @@ class ArtifactService:
5752
8823
  "mode": normalized_mode,
5753
8824
  "outline_id": source_outline_id,
5754
8825
  "selected_outline_path": str(selected_outline_path),
8826
+ "outline_manifest_path": str(self._paper_outline_manifest_path(quest_root, workspace_root=workspace_root)),
8827
+ "paper_line_state_path": str(self._paper_line_state_path(quest_root, workspace_root=workspace_root)),
5755
8828
  "outline_selection_path": str(outline_selection_path),
5756
8829
  "revised_outline_path": str(revised_outline_path) if revised_outline_path else None,
5757
8830
  "record": resolved_record,
8831
+ "paper_line_state": paper_line_state,
5758
8832
  "artifact": artifact,
5759
8833
  "interaction": interaction,
5760
8834
  }
@@ -5773,36 +8847,78 @@ class ArtifactService:
5773
8847
  compile_report_path: str | None = None,
5774
8848
  pdf_path: str | None = None,
5775
8849
  latex_root_path: str | None = None,
8850
+ prepare_open_source: bool = False,
5776
8851
  ) -> dict[str, Any]:
5777
8852
  paper_context = self._ensure_active_paper_workspace(quest_root)
5778
8853
  workspace_root = Path(str(paper_context.get("worktree_root") or self._workspace_root_for(quest_root)))
5779
8854
  paper_root = self._paper_root(quest_root, workspace_root=workspace_root, create=True)
5780
- selected_outline_path = self._paper_selected_outline_path(quest_root, workspace_root=workspace_root)
5781
- selected_outline = read_json(selected_outline_path, {})
5782
- if not isinstance(selected_outline, dict) or not selected_outline:
5783
- fallback_selected_outline_path = quest_root / "paper" / "selected_outline.json"
5784
- selected_outline = read_json(fallback_selected_outline_path, {})
5785
- if isinstance(selected_outline, dict) and selected_outline:
5786
- selected_outline_path = fallback_selected_outline_path
8855
+ selected_outline_path, selected_outline = self._read_selected_outline_record(
8856
+ quest_root,
8857
+ workspace_root=workspace_root,
8858
+ )
5787
8859
  selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
5788
8860
  if not selected_outline and not str(outline_path or "").strip():
5789
8861
  raise ValueError("submit_paper_bundle requires a selected outline or explicit `outline_path`.")
8862
+ gate_status = self._paper_bundle_gate_status(quest_root, workspace_root=workspace_root)
8863
+ if gate_status.get("unresolved_required_items") or gate_status.get("unmapped_completed_items"):
8864
+ problems: list[str] = []
8865
+ if gate_status.get("unresolved_required_items"):
8866
+ preview = [
8867
+ f"{item.get('section_id') or 'section'}:{item.get('item_id') or 'item'}"
8868
+ for item in (gate_status.get("unresolved_required_items") or [])[:5]
8869
+ if isinstance(item, dict)
8870
+ ]
8871
+ problems.append(
8872
+ "unresolved required outline items"
8873
+ + (f" ({', '.join(preview)})" if preview else "")
8874
+ )
8875
+ if gate_status.get("unmapped_completed_items"):
8876
+ preview = [
8877
+ f"{item.get('campaign_id') or 'analysis'}:{item.get('slice_id') or item.get('item_id') or 'slice'}"
8878
+ for item in (gate_status.get("unmapped_completed_items") or [])[:5]
8879
+ if isinstance(item, dict)
8880
+ ]
8881
+ problems.append(
8882
+ "completed analysis results still unmapped into the paper contract"
8883
+ + (f" ({', '.join(preview)})" if preview else "")
8884
+ )
8885
+ raise ValueError(
8886
+ "submit_paper_bundle blocked because the paper evidence contract is incomplete: "
8887
+ + "; ".join(problems)
8888
+ + "."
8889
+ )
5790
8890
 
5791
8891
  manifest_path = self._paper_bundle_manifest_path(quest_root, workspace_root=workspace_root)
5792
8892
  baseline_inventory = self._write_paper_baseline_inventory(quest_root, workspace_root=workspace_root)
5793
8893
  baseline_inventory_path = self._paper_baseline_inventory_path(quest_root, workspace_root=workspace_root)
8894
+ evidence_ledger_path = self._paper_evidence_ledger_path(quest_root)
8895
+ if not evidence_ledger_path.exists():
8896
+ self._write_paper_evidence_ledger(
8897
+ quest_root,
8898
+ {
8899
+ "selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
8900
+ "items": [],
8901
+ },
8902
+ workspace_root=workspace_root,
8903
+ )
8904
+ experiment_matrix_path = paper_root / "paper_experiment_matrix.md"
8905
+ experiment_matrix_json_path = self._paper_experiment_matrix_json_path(quest_root, workspace_root=workspace_root)
5794
8906
  source_branch = str(paper_context.get("source_branch") or "").strip() or None
5795
8907
  paper_branch = str(paper_context.get("branch") or "").strip() or current_branch(workspace_root)
5796
8908
  source_run_id = str(paper_context.get("source_run_id") or "").strip() or None
5797
8909
  source_idea_id = str(paper_context.get("source_idea_id") or "").strip() or None
5798
8910
  paper_manifest_rel = self._workspace_relative(quest_root, manifest_path) or "paper/paper_bundle_manifest.json"
5799
8911
  paper_inventory_rel = self._workspace_relative(quest_root, baseline_inventory_path) or "paper/baseline_inventory.json"
5800
- open_source_manifest = self._ensure_open_source_prep(
5801
- quest_root,
5802
- workspace_root=workspace_root,
5803
- source_branch=source_branch,
5804
- source_bundle_manifest_path=paper_manifest_rel,
5805
- baseline_inventory_path=paper_inventory_rel,
8912
+ open_source_manifest = (
8913
+ self._ensure_open_source_prep(
8914
+ quest_root,
8915
+ workspace_root=workspace_root,
8916
+ source_branch=source_branch,
8917
+ source_bundle_manifest_path=paper_manifest_rel,
8918
+ baseline_inventory_path=paper_inventory_rel,
8919
+ )
8920
+ if prepare_open_source
8921
+ else {}
5806
8922
  )
5807
8923
  default_draft_path = self._workspace_relative(quest_root, paper_root / "draft.md") or "paper/draft.md"
5808
8924
  default_writing_plan_path = self._workspace_relative(quest_root, paper_root / "writing_plan.md") or "paper/writing_plan.md"
@@ -5838,23 +8954,53 @@ class ArtifactService:
5838
8954
  "writing_plan_path": str(writing_plan_path or default_writing_plan_path).strip() or None,
5839
8955
  "references_path": str(references_path or default_references_path).strip() or None,
5840
8956
  "claim_evidence_map_path": str(claim_evidence_map_path or default_claim_map_path).strip() or None,
8957
+ "evidence_ledger_path": self._workspace_relative(quest_root, evidence_ledger_path) or "paper/evidence_ledger.json",
8958
+ "experiment_matrix_path": (
8959
+ self._workspace_relative(quest_root, experiment_matrix_path) if experiment_matrix_path.exists() else None
8960
+ ),
8961
+ "experiment_matrix_json_path": (
8962
+ self._workspace_relative(quest_root, experiment_matrix_json_path)
8963
+ if experiment_matrix_json_path.exists()
8964
+ else None
8965
+ ),
5841
8966
  "compile_report_path": str(compile_report_path or default_compile_report_path).strip() or None,
5842
8967
  "pdf_path": str(pdf_path or "").strip() or None,
5843
8968
  "latex_root_path": normalized_latex_root_path,
5844
8969
  "baseline_inventory_path": paper_inventory_rel,
5845
- "open_source_manifest_path": self._workspace_relative(
5846
- quest_root,
5847
- self._open_source_manifest_path(quest_root, workspace_root=workspace_root),
8970
+ "prepare_open_source": bool(prepare_open_source),
8971
+ "open_source_manifest_path": (
8972
+ self._workspace_relative(
8973
+ quest_root,
8974
+ self._open_source_manifest_path(quest_root, workspace_root=workspace_root),
8975
+ )
8976
+ if prepare_open_source
8977
+ else None
8978
+ ),
8979
+ "open_source_cleanup_plan_path": (
8980
+ str(open_source_manifest.get("cleanup_plan_path") or "").strip() or None
5848
8981
  )
5849
- or "release/open_source/manifest.json",
5850
- "open_source_cleanup_plan_path": str(open_source_manifest.get("cleanup_plan_path") or "").strip()
5851
- or "release/open_source/cleanup_plan.md",
8982
+ if prepare_open_source
8983
+ else None,
5852
8984
  "selected_outline_ref": str(selected_outline.get("outline_id") or "").strip() or None,
8985
+ "evidence_gate": gate_status,
5853
8986
  "created_at": utc_now(),
5854
8987
  "updated_at": utc_now(),
5855
8988
  }
5856
8989
  write_json(manifest_path, manifest)
8990
+ paper_line_state = self._write_paper_line_state(
8991
+ quest_root,
8992
+ workspace_root=workspace_root,
8993
+ source_branch=source_branch,
8994
+ source_run_id=source_run_id,
8995
+ source_idea_id=source_idea_id,
8996
+ )
5857
8997
  self.quest_service.update_settings(self._quest_id(quest_root), active_anchor="finalize")
8998
+ self.quest_service.set_continuation_state(
8999
+ quest_root,
9000
+ policy="wait_for_user_or_resume",
9001
+ anchor="decision",
9002
+ reason="paper_bundle_submitted",
9003
+ )
5858
9004
  artifact = self.record(
5859
9005
  quest_root,
5860
9006
  {
@@ -5870,17 +9016,27 @@ class ArtifactService:
5870
9016
  "outline_path": manifest.get("outline_path"),
5871
9017
  "draft_path": manifest.get("draft_path"),
5872
9018
  "pdf_path": manifest.get("pdf_path"),
9019
+ "evidence_ledger_path": str(evidence_ledger_path) if evidence_ledger_path.exists() else None,
5873
9020
  "baseline_inventory_path": str(baseline_inventory_path),
5874
- "open_source_manifest_path": str(self._open_source_manifest_path(quest_root, workspace_root=workspace_root)),
9021
+ "open_source_manifest_path": (
9022
+ str(self._open_source_manifest_path(quest_root, workspace_root=workspace_root))
9023
+ if prepare_open_source
9024
+ else None
9025
+ ),
5875
9026
  },
5876
9027
  "details": {
5877
9028
  "title": manifest.get("title"),
5878
9029
  "selected_outline_ref": manifest.get("selected_outline_ref"),
9030
+ "ready_section_count": gate_status.get("ready_section_count"),
9031
+ "section_count": gate_status.get("section_count"),
9032
+ "ledger_item_count": gate_status.get("ledger_item_count"),
9033
+ "paper_line_id": paper_line_state.get("paper_line_id"),
5879
9034
  "baseline_inventory_count": len(baseline_inventory.get("supplementary_baselines") or []),
5880
- "open_source_status": open_source_manifest.get("status"),
9035
+ "open_source_status": open_source_manifest.get("status") if prepare_open_source else None,
5881
9036
  "paper_branch": paper_branch,
5882
9037
  "source_branch": source_branch,
5883
9038
  "source_run_id": source_run_id,
9039
+ "prepare_open_source": bool(prepare_open_source),
5884
9040
  },
5885
9041
  },
5886
9042
  checkpoint=False,
@@ -5901,6 +9057,7 @@ class ArtifactService:
5901
9057
  writing_plan_rel_path=str(manifest.get("writing_plan_path") or "").strip() or None,
5902
9058
  references_rel_path=str(manifest.get("references_path") or "").strip() or None,
5903
9059
  claim_evidence_map_rel_path=str(manifest.get("claim_evidence_map_path") or "").strip() or None,
9060
+ evidence_ledger_rel_path=str(manifest.get("evidence_ledger_path") or "").strip() or None,
5904
9061
  compile_report_rel_path=str(manifest.get("compile_report_path") or "").strip() or None,
5905
9062
  pdf_rel_path=str(manifest.get("pdf_path") or "").strip() or None,
5906
9063
  latex_root_rel_path=str(manifest.get("latex_root_path") or "").strip() or None,
@@ -5922,11 +9079,17 @@ class ArtifactService:
5922
9079
  "writing_plan_path": manifest.get("writing_plan_path"),
5923
9080
  "references_path": manifest.get("references_path"),
5924
9081
  "claim_evidence_map_path": manifest.get("claim_evidence_map_path"),
9082
+ "evidence_ledger_path": manifest.get("evidence_ledger_path"),
5925
9083
  "compile_report_path": manifest.get("compile_report_path"),
5926
9084
  "pdf_path": manifest.get("pdf_path"),
5927
9085
  "latex_root_path": manifest.get("latex_root_path"),
5928
9086
  "baseline_inventory_path": str(baseline_inventory_path),
5929
- "open_source_manifest_path": str(self._open_source_manifest_path(quest_root, workspace_root=workspace_root)),
9087
+ "prepare_open_source": bool(prepare_open_source),
9088
+ "open_source_manifest_path": (
9089
+ str(self._open_source_manifest_path(quest_root, workspace_root=workspace_root))
9090
+ if prepare_open_source
9091
+ else None
9092
+ ),
5930
9093
  }
5931
9094
  ],
5932
9095
  )
@@ -5935,7 +9098,14 @@ class ArtifactService:
5935
9098
  "manifest_path": str(manifest_path),
5936
9099
  "manifest": manifest,
5937
9100
  "baseline_inventory_path": str(baseline_inventory_path),
5938
- "open_source_manifest_path": str(self._open_source_manifest_path(quest_root, workspace_root=workspace_root)),
9101
+ "evidence_ledger_path": str(evidence_ledger_path),
9102
+ "paper_line_state_path": str(self._paper_line_state_path(quest_root, workspace_root=workspace_root)),
9103
+ "paper_line_state": paper_line_state,
9104
+ "open_source_manifest_path": (
9105
+ str(self._open_source_manifest_path(quest_root, workspace_root=workspace_root))
9106
+ if prepare_open_source
9107
+ else None
9108
+ ),
5939
9109
  "artifact": artifact,
5940
9110
  "interaction": interaction,
5941
9111
  }
@@ -6079,10 +9249,18 @@ class ArtifactService:
6079
9249
  "result_kind": "analysis_slice",
6080
9250
  "campaign_id": campaign_id,
6081
9251
  "slice_id": slice_id,
9252
+ "selected_outline_ref": str(manifest.get("selected_outline_ref") or "").strip() or None,
6082
9253
  "status": status,
6083
9254
  "title": target.get("title"),
6084
9255
  "goal": target.get("goal"),
6085
9256
  "run_kind": target.get("run_kind"),
9257
+ "exp_id": target.get("exp_id"),
9258
+ "section_id": target.get("section_id"),
9259
+ "item_id": target.get("item_id"),
9260
+ "paper_role": target.get("paper_role"),
9261
+ "paper_placement": target.get("paper_placement"),
9262
+ "tier": target.get("tier"),
9263
+ "claim_links": target.get("claim_links") or [],
6086
9264
  "required_baselines": target.get("required_baselines") or [],
6087
9265
  "comparison_baselines": normalized_comparison_baselines,
6088
9266
  "metrics_summary": normalized_metrics_summary,
@@ -6120,6 +9298,15 @@ class ArtifactService:
6120
9298
  "",
6121
9299
  str(target.get("goal") or "").strip() or "TBD",
6122
9300
  "",
9301
+ "## Paper Contract Binding",
9302
+ "",
9303
+ f"- Selected outline: `{str(manifest.get('selected_outline_ref') or 'none').strip() or 'none'}`",
9304
+ f"- Section id: `{str(target.get('section_id') or 'none').strip() or 'none'}`",
9305
+ f"- Item id: `{str(target.get('item_id') or slice_id).strip() or slice_id}`",
9306
+ f"- Exp id: `{str(target.get('exp_id') or 'none').strip() or 'none'}`",
9307
+ f"- Paper role: `{str(target.get('paper_role') or 'none').strip() or 'none'}`",
9308
+ f"- Claim links: {', '.join(str(value).strip() for value in (target.get('claim_links') or []) if str(value).strip()) or 'none'}",
9309
+ "",
6123
9310
  "## Core Requirement",
6124
9311
  "",
6125
9312
  str(target.get("must_not_simplify") or "").strip() or "Full protocol only.",
@@ -6191,6 +9378,14 @@ class ArtifactService:
6191
9378
  "must_not_simplify": target.get("must_not_simplify"),
6192
9379
  "dataset_scope": normalized_scope,
6193
9380
  "subset_approval_ref": subset_approval_ref,
9381
+ "selected_outline_ref": str(manifest.get("selected_outline_ref") or "").strip() or None,
9382
+ "section_id": target.get("section_id"),
9383
+ "item_id": target.get("item_id"),
9384
+ "exp_id": target.get("exp_id"),
9385
+ "paper_role": target.get("paper_role"),
9386
+ "paper_placement": target.get("paper_placement"),
9387
+ "tier": target.get("tier"),
9388
+ "claim_links": target.get("claim_links") or [],
6194
9389
  "metric_rows": normalized_metric_rows,
6195
9390
  "claim_impact": normalized_claim_impact,
6196
9391
  "reviewer_resolution": normalized_reviewer_resolution,
@@ -6227,6 +9422,7 @@ class ArtifactService:
6227
9422
  updated["result_path"] = str(result_path)
6228
9423
  updated["result_json_path"] = str(result_json_path)
6229
9424
  updated["mirror_path"] = str(mirror_path)
9425
+ updated["selected_outline_ref"] = str(manifest.get("selected_outline_ref") or "").strip() or None
6230
9426
  updated["claim_impact"] = normalized_claim_impact
6231
9427
  updated["reviewer_resolution"] = normalized_reviewer_resolution
6232
9428
  updated["manuscript_update_hint"] = normalized_manuscript_update_hint
@@ -6245,6 +9441,49 @@ class ArtifactService:
6245
9441
  "slices": updated_slices,
6246
9442
  },
6247
9443
  )
9444
+ paper_line_root = str(manifest.get("paper_line_root") or "").strip() or None
9445
+ self._upsert_paper_evidence_item(
9446
+ quest_root,
9447
+ {
9448
+ "item_id": str(target.get("item_id") or slice_id).strip() or slice_id,
9449
+ "title": str(target.get("title") or slice_id).strip() or slice_id,
9450
+ "kind": "analysis_slice",
9451
+ "status": status,
9452
+ "paper_role": str(target.get("paper_role") or target.get("paper_placement") or "").strip() or None,
9453
+ "section_id": str(target.get("section_id") or "").strip() or None,
9454
+ "claim_links": self._normalize_string_list(target.get("claim_links")),
9455
+ "setup": setup.strip() or None,
9456
+ "result_summary": results.strip() or normalized_claim_impact or normalized_manuscript_update_hint or None,
9457
+ "key_metrics": self._paper_evidence_key_metrics(
9458
+ metric_rows=normalized_metric_rows,
9459
+ metrics_summary=normalized_metrics_summary,
9460
+ ),
9461
+ "source_paths": [
9462
+ value
9463
+ for value in [
9464
+ self._workspace_relative(quest_root, result_path),
9465
+ self._workspace_relative(quest_root, result_json_path),
9466
+ self._workspace_relative(quest_root, mirror_path),
9467
+ *evidence_paths,
9468
+ ]
9469
+ if value
9470
+ ],
9471
+ "campaign_id": campaign_id,
9472
+ "slice_id": slice_id,
9473
+ "selected_outline_ref": str(manifest.get("selected_outline_ref") or "").strip() or None,
9474
+ "evaluation_summary": normalized_evaluation_summary,
9475
+ "claim_impact": normalized_claim_impact,
9476
+ },
9477
+ workspace_root=Path(paper_line_root) if paper_line_root else None,
9478
+ )
9479
+ if paper_line_root:
9480
+ self._write_paper_line_state(
9481
+ quest_root,
9482
+ workspace_root=Path(paper_line_root),
9483
+ source_branch=str(manifest.get("parent_branch") or "").strip() or None,
9484
+ source_run_id=str(manifest.get("parent_run_id") or "").strip() or None,
9485
+ source_idea_id=str(manifest.get("active_idea_id") or "").strip() or None,
9486
+ )
6248
9487
  baseline_inventory = (
6249
9488
  self._upsert_analysis_baseline_inventory(
6250
9489
  quest_root,
@@ -6837,6 +10076,7 @@ class ArtifactService:
6837
10076
  "source_mode": source_mode,
6838
10077
  "comment": comment,
6839
10078
  },
10079
+ "startup_contract": self._startup_contract(quest_root) or None,
6840
10080
  "source": {"kind": "system", "role": "artifact"},
6841
10081
  },
6842
10082
  checkpoint=True,
@@ -6856,7 +10096,7 @@ class ArtifactService:
6856
10096
  quest_root,
6857
10097
  baseline_gate="confirmed",
6858
10098
  confirmed_baseline_ref=confirmed_ref,
6859
- active_anchor="idea" if auto_advance else "baseline",
10099
+ active_anchor=self._post_baseline_anchor(quest_root) if auto_advance else "baseline",
6860
10100
  )
6861
10101
  registry_entry = self._sync_confirmed_baseline_registry_entry(
6862
10102
  quest_root=quest_root,
@@ -6914,6 +10154,7 @@ class ArtifactService:
6914
10154
  "baseline_gate": "waived",
6915
10155
  "comment": comment,
6916
10156
  },
10157
+ "startup_contract": self._startup_contract(quest_root) or None,
6917
10158
  "source": {"kind": "system", "role": "artifact"},
6918
10159
  },
6919
10160
  checkpoint=True,
@@ -6922,7 +10163,7 @@ class ArtifactService:
6922
10163
  quest_root,
6923
10164
  baseline_gate="waived",
6924
10165
  confirmed_baseline_ref=None,
6925
- active_anchor="idea" if auto_advance else "baseline",
10166
+ active_anchor=self._post_baseline_anchor(quest_root) if auto_advance else "baseline",
6926
10167
  )
6927
10168
  return {
6928
10169
  "ok": True,
@@ -7021,6 +10262,7 @@ class ArtifactService:
7021
10262
  *,
7022
10263
  kind: str = "progress",
7023
10264
  message: str = "",
10265
+ summary_preview: str | None = None,
7024
10266
  response_phase: str = "ack",
7025
10267
  importance: str = "info",
7026
10268
  deliver_to_bound_conversations: bool = True,
@@ -7037,13 +10279,23 @@ class ArtifactService:
7037
10279
  reply_schema: dict[str, Any] | None = None,
7038
10280
  reply_to_interaction_id: str | None = None,
7039
10281
  supersede_open_requests: bool = True,
10282
+ dedupe_key: str | None = None,
10283
+ suppress_if_unchanged: bool | None = None,
10284
+ min_interval_seconds: int | None = None,
7040
10285
  ) -> dict:
7041
10286
  durable_kind = {
7042
10287
  "progress": "progress",
10288
+ "answer": "answer",
7043
10289
  "milestone": "milestone",
7044
10290
  "decision_request": "decision",
7045
10291
  "approval_result": "approval",
7046
10292
  }.get(kind, "progress")
10293
+ full_message = str(message or "").strip()
10294
+ summary_preview_resolved = (
10295
+ self._summary_preview_text(summary_preview, limit=220)
10296
+ if summary_preview is not None
10297
+ else self._summary_preview_text(full_message, limit=220)
10298
+ )
7047
10299
  options_resolved = options or []
7048
10300
  surface_actions_resolved = [dict(item) for item in (surface_actions or []) if isinstance(item, dict)]
7049
10301
  connector_hints_resolved = self._normalize_connector_hints(connector_hints)
@@ -7051,7 +10303,7 @@ class ArtifactService:
7051
10303
  reply_schema_resolved = reply_schema if isinstance(reply_schema, dict) else {}
7052
10304
  reply_mode_resolved = str(
7053
10305
  reply_mode
7054
- or ("blocking" if kind == "decision_request" else "threaded" if kind in {"progress", "milestone"} else "none")
10306
+ or ("blocking" if kind == "decision_request" else "threaded" if kind in {"progress", "milestone", "answer"} else "none")
7055
10307
  ).strip().lower()
7056
10308
  if reply_mode_resolved not in {"none", "threaded", "blocking"}:
7057
10309
  reply_mode_resolved = "blocking" if kind == "decision_request" else "threaded"
@@ -7128,6 +10380,59 @@ class ArtifactService:
7128
10380
  "decision_type": decision_type or None,
7129
10381
  "guidance": guidance,
7130
10382
  }
10383
+ suppress_resolved = (kind == "progress") if suppress_if_unchanged is None else bool(suppress_if_unchanged)
10384
+ dedupe_key_resolved = str(dedupe_key or self._normalize_interaction_message(full_message)).strip() or None
10385
+ if (
10386
+ kind == "progress"
10387
+ and suppress_resolved
10388
+ and dedupe_key_resolved
10389
+ and int(self.quest_service.snapshot(self._quest_id(quest_root)).get("pending_user_message_count") or 0) == 0
10390
+ ):
10391
+ prior_interaction = self._latest_duplicate_progress_interaction(
10392
+ quest_root,
10393
+ dedupe_key=dedupe_key_resolved,
10394
+ min_interval_seconds=min_interval_seconds,
10395
+ )
10396
+ if prior_interaction is not None:
10397
+ interaction_state = self._read_interaction_state(quest_root)
10398
+ waiting_requests = [
10399
+ dict(item)
10400
+ for item in (interaction_state.get("open_requests") or [])
10401
+ if str(item.get("status") or "") == "waiting"
10402
+ ]
10403
+ return {
10404
+ "status": "suppressed_duplicate",
10405
+ "artifact_id": prior_interaction.get("artifact_id"),
10406
+ "interaction_id": prior_interaction.get("interaction_id"),
10407
+ "expects_reply": False,
10408
+ "reply_mode": "threaded",
10409
+ "surface_actions": [],
10410
+ "connector_hints": connector_hints_resolved,
10411
+ "normalized_attachments": attachments_resolved,
10412
+ "attachment_issues": attachment_issues,
10413
+ "delivered": False,
10414
+ "delivery_results": [],
10415
+ "response_phase": response_phase,
10416
+ "delivery_targets": [],
10417
+ "delivery_policy": self._delivery_policy(self._connectors_config()),
10418
+ "preferred_connector": self._preferred_connector(self._connectors_config()),
10419
+ "recent_inbound_messages": [],
10420
+ "delivery_batch": None,
10421
+ "recent_interaction_records": self.quest_service.latest_artifact_interaction_records(quest_root, limit=10),
10422
+ "agent_instruction": self.quest_service.localized_copy(
10423
+ quest_root=quest_root,
10424
+ zh="当前用户可见状态没有变化,不需要再发送一条重复 progress。继续工作,等出现真实变化再汇报。",
10425
+ en="The user-visible state has not changed. Do not send another duplicate progress update; continue working until there is a real change.",
10426
+ ),
10427
+ "queued_message_count_before_delivery": 0,
10428
+ "queued_message_count_after_delivery": 0,
10429
+ "open_request_count": len(waiting_requests),
10430
+ "active_request": waiting_requests[-1] if waiting_requests else None,
10431
+ "default_reply_interaction_id": interaction_state.get("default_reply_interaction_id"),
10432
+ "guidance": "Duplicate progress was suppressed because the latest user-visible state is unchanged.",
10433
+ "suppressed_reason": "unchanged_progress",
10434
+ "dedupe_key": dedupe_key_resolved,
10435
+ }
7131
10436
  resolved_artifact_id = generate_id(durable_kind)
7132
10437
  resolved_interaction_id = interaction_id or (
7133
10438
  resolved_artifact_id if reply_mode_resolved != "none" or reply_to_interaction_id else None
@@ -7135,9 +10440,10 @@ class ArtifactService:
7135
10440
  payload: dict[str, Any] = {
7136
10441
  "kind": durable_kind,
7137
10442
  "artifact_id": resolved_artifact_id,
7138
- "status": "active" if durable_kind == "progress" else "completed",
7139
- "message": message,
7140
- "summary": message,
10443
+ "status": "completed" if kind == "answer" else "active" if durable_kind == "progress" else "completed",
10444
+ "message": full_message,
10445
+ "summary": summary_preview_resolved or full_message,
10446
+ "summary_preview": summary_preview_resolved,
7141
10447
  "interaction_phase": "request" if kind == "decision_request" else response_phase,
7142
10448
  "importance": importance,
7143
10449
  "attachments": attachments_resolved,
@@ -7157,11 +10463,11 @@ class ArtifactService:
7157
10463
  {
7158
10464
  "verdict": "pending_user",
7159
10465
  "action": "request_user_decision",
7160
- "reason": message or "Decision request emitted for user review.",
10466
+ "reason": full_message or "Decision request emitted for user review.",
7161
10467
  }
7162
10468
  )
7163
10469
  if durable_kind == "approval":
7164
- payload.setdefault("reason", message or "Approval result emitted.")
10470
+ payload.setdefault("reason", full_message or "Approval result emitted.")
7165
10471
  artifact = self.record(
7166
10472
  quest_root,
7167
10473
  payload,
@@ -7173,7 +10479,7 @@ class ArtifactService:
7173
10479
  kind=kind,
7174
10480
  expects_reply=expects_reply_resolved,
7175
10481
  reply_mode=reply_mode_resolved,
7176
- message=message,
10482
+ message=full_message,
7177
10483
  options=options_resolved,
7178
10484
  allow_free_text=allow_free_text,
7179
10485
  reply_schema=reply_schema_resolved,
@@ -7196,7 +10502,7 @@ class ArtifactService:
7196
10502
  "quest_id": self._quest_id(quest_root),
7197
10503
  "conversation_id": target,
7198
10504
  "kind": kind,
7199
- "message": message,
10505
+ "message": full_message,
7200
10506
  "response_phase": response_phase,
7201
10507
  "importance": importance,
7202
10508
  "artifact_id": artifact.get("artifact_id"),
@@ -7217,6 +10523,9 @@ class ArtifactService:
7217
10523
  if delivery_result.get("ok", False) or delivery_result.get("queued", False):
7218
10524
  delivery_targets.append(target)
7219
10525
  delivered = True
10526
+ counts_as_visible = (not deliver_to_bound_conversations) or (not delivery_results) or any(
10527
+ bool(item.get("ok", False)) for item in delivery_results
10528
+ )
7220
10529
 
7221
10530
  mailbox_payload = {
7222
10531
  "delivery_batch": None,
@@ -7241,12 +10550,15 @@ class ArtifactService:
7241
10550
  interaction_id=request_state.get("interaction_id"),
7242
10551
  artifact_id=artifact.get("artifact_id"),
7243
10552
  kind=kind,
7244
- message=message,
10553
+ message=full_message,
10554
+ summary_preview=summary_preview_resolved,
10555
+ dedupe_key=dedupe_key_resolved,
7245
10556
  response_phase=response_phase,
7246
10557
  reply_mode=reply_mode_resolved,
7247
10558
  surface_actions=surface_actions_resolved,
7248
10559
  connector_hints=connector_hints_resolved,
7249
10560
  created_at=(artifact.get("record") or {}).get("updated_at"),
10561
+ counts_as_visible=counts_as_visible,
7250
10562
  )
7251
10563
 
7252
10564
  return {
@@ -7277,6 +10589,36 @@ class ArtifactService:
7277
10589
  "guidance": "如果收到新的用户要求,请先吸收这些要求;如果没有新消息,请继续当前任务并在真实检查点再次汇报。",
7278
10590
  }
7279
10591
 
10592
+ @staticmethod
10593
+ def _normalize_interaction_message(message: str) -> str:
10594
+ return " ".join(str(message or "").split())
10595
+
10596
+ def _latest_duplicate_progress_interaction(
10597
+ self,
10598
+ quest_root: Path,
10599
+ *,
10600
+ dedupe_key: str,
10601
+ min_interval_seconds: int | None,
10602
+ ) -> dict[str, Any] | None:
10603
+ recent = self.quest_service.latest_artifact_interaction_records(quest_root, limit=40)
10604
+ for item in reversed(recent):
10605
+ record_type = str(item.get("type") or "").strip()
10606
+ if record_type == "user_inbound":
10607
+ return None
10608
+ if record_type != "artifact_outbound":
10609
+ continue
10610
+ if str(item.get("kind") or "").strip() != "progress":
10611
+ continue
10612
+ previous_key = str(item.get("dedupe_key") or self._normalize_interaction_message(item.get("message") or "")).strip()
10613
+ if previous_key != dedupe_key:
10614
+ continue
10615
+ if min_interval_seconds:
10616
+ seconds_since = self.quest_service._seconds_since_iso_timestamp(item.get("created_at"))
10617
+ if seconds_since is not None and seconds_since > int(min_interval_seconds):
10618
+ return None
10619
+ return dict(item)
10620
+ return None
10621
+
7280
10622
  def complete_quest(
7281
10623
  self,
7282
10624
  quest_root: Path,
@@ -7465,6 +10807,7 @@ class ArtifactService:
7465
10807
  def _default_status(kind: str) -> str:
7466
10808
  return {
7467
10809
  "progress": "active",
10810
+ "answer": "completed",
7468
10811
  "decision": "pending",
7469
10812
  "approval": "accepted",
7470
10813
  "graph": "generated",