@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
@@ -16,6 +16,7 @@ STANDARD_SKILLS = (
16
16
  "scout",
17
17
  "baseline",
18
18
  "idea",
19
+ "optimize",
19
20
  "experiment",
20
21
  "analysis-campaign",
21
22
  "write",
@@ -43,6 +44,10 @@ STAGE_MEMORY_PLAN = {
43
44
  "quest": ("papers", "ideas", "decisions", "knowledge"),
44
45
  "global": ("papers", "knowledge", "templates"),
45
46
  },
47
+ "optimize": {
48
+ "quest": ("episodes", "decisions", "ideas", "knowledge"),
49
+ "global": ("knowledge", "templates"),
50
+ },
46
51
  "experiment": {
47
52
  "quest": ("ideas", "decisions", "episodes", "knowledge"),
48
53
  "global": ("knowledge", "templates"),
@@ -66,6 +71,38 @@ STAGE_MEMORY_PLAN = {
66
71
  }
67
72
 
68
73
 
74
+ def classify_turn_intent(user_message: str) -> str:
75
+ text = str(user_message or "").strip()
76
+ if not text:
77
+ return "continue_stage"
78
+ normalized = " ".join(text.split()).lower()
79
+ structured_bootstrap_markers = (
80
+ "project bootstrap",
81
+ "primary research request",
82
+ "research goals",
83
+ "baseline context",
84
+ "reference papers",
85
+ "operational constraints",
86
+ "research delivery mode",
87
+ "decision handling mode",
88
+ "launch mode",
89
+ "research contract",
90
+ "mandatory working rules",
91
+ )
92
+ structured_hit_count = sum(1 for marker in structured_bootstrap_markers if marker in normalized)
93
+ if structured_hit_count >= 2:
94
+ return "continue_stage"
95
+ if normalized.startswith("/new ") or normalized.startswith("/new\n"):
96
+ return "continue_stage"
97
+ question_markers = ["?", "?", "现在进展", "全局", "多久", "什么情况", "在哪", "在哪里", "how long", "what", "where"]
98
+ if any(marker in normalized for marker in question_markers):
99
+ return "answer_user_question_first"
100
+ command_markers = ["继续", "发给我", "发送", "运行", "启动", "resume", "send", "run", "launch"]
101
+ if any(marker in normalized for marker in command_markers):
102
+ return "execute_user_command_first"
103
+ return "continue_stage"
104
+
105
+
69
106
  class PromptBuilder:
70
107
  def __init__(self, repo_root: Path, home: Path) -> None:
71
108
  self.repo_root = repo_root
@@ -83,6 +120,8 @@ class PromptBuilder:
83
120
  user_message: str,
84
121
  model: str,
85
122
  turn_reason: str = "user_message",
123
+ turn_intent: str | None = None,
124
+ turn_mode: str | None = None,
86
125
  retry_context: dict | None = None,
87
126
  ) -> str:
88
127
  snapshot = self.quest_service.snapshot(quest_id)
@@ -151,7 +190,12 @@ class PromptBuilder:
151
190
  [
152
191
  "",
153
192
  "## Turn Driver",
154
- self._turn_driver_block(turn_reason=turn_reason, user_message=user_message),
193
+ self._turn_driver_block(
194
+ turn_reason=turn_reason,
195
+ user_message=user_message,
196
+ turn_intent=turn_intent,
197
+ turn_mode=turn_mode,
198
+ ),
155
199
  "",
156
200
  "## Continuation Guard",
157
201
  self._continuation_guard_block(
@@ -173,12 +217,18 @@ class PromptBuilder:
173
217
  "## Research Delivery Policy",
174
218
  self._research_delivery_policy_block(snapshot),
175
219
  "",
220
+ "## Optimization Frontier Snapshot",
221
+ self._optimization_frontier_block(snapshot, quest_root),
222
+ "",
176
223
  "## Paper And Evidence Snapshot",
177
224
  self._paper_and_evidence_block(snapshot, quest_root),
178
225
  "",
179
226
  "## Retry Recovery Packet",
180
227
  self._retry_recovery_block(retry_context),
181
228
  "",
229
+ "## Recovery Resume Packet",
230
+ self._recovery_resume_block(snapshot=snapshot, turn_reason=turn_reason),
231
+ "",
182
232
  "## Interaction Style",
183
233
  self._interaction_style_block(default_locale=default_locale, user_message=user_message, snapshot=snapshot),
184
234
  "",
@@ -206,7 +256,14 @@ class PromptBuilder:
206
256
  )
207
257
  return "\n\n".join(sections).strip() + "\n"
208
258
 
209
- def _turn_driver_block(self, *, turn_reason: str, user_message: str) -> str:
259
+ def _turn_driver_block(
260
+ self,
261
+ *,
262
+ turn_reason: str,
263
+ user_message: str,
264
+ turn_intent: str | None = None,
265
+ turn_mode: str | None = None,
266
+ ) -> str:
210
267
  normalized_reason = str(turn_reason or "user_message").strip() or "user_message"
211
268
  lines = [f"- turn_reason: {normalized_reason}"]
212
269
  if normalized_reason == "auto_continue":
@@ -228,9 +285,28 @@ class PromptBuilder:
228
285
  preview = " ".join(str(user_message or "").split())
229
286
  if len(preview) > 220:
230
287
  preview = preview[:217].rstrip() + "..."
288
+ resolved_turn_intent = str(turn_intent or self._turn_intent(user_message)).strip() or "continue_stage"
289
+ resolved_turn_mode = str(turn_mode or "stage_execution").strip() or "stage_execution"
290
+ lines.append(f"- turn_intent: {resolved_turn_intent}")
291
+ lines.append(f"- turn_mode: {resolved_turn_mode}")
292
+ if resolved_turn_intent == "answer_user_question_first":
293
+ lines.append(
294
+ "- answer_first_rule: the user primarily asked a direct question. Answer it in plain language before resuming any background stage work or generating new route artifacts."
295
+ )
296
+ lines.append(
297
+ "- direct_answer_tool_rule: if the question is about overall progress, paper readiness, current best result, or next step, call artifact.get_global_status(detail='brief'|'full', locale='zh'|'en') before answering from memory or local stage context."
298
+ )
299
+ elif resolved_turn_intent == "execute_user_command_first":
300
+ lines.append(
301
+ "- command_first_rule: the user primarily gave a concrete instruction. Execute or acknowledge that instruction first before resuming background stage narration."
302
+ )
231
303
  lines.append(f"- direct_user_message_preview: {preview or 'none'}")
232
304
  return "\n".join(lines)
233
305
 
306
+ @staticmethod
307
+ def _turn_intent(user_message: str) -> str:
308
+ return classify_turn_intent(user_message)
309
+
234
310
  def _active_communication_surface_block(
235
311
  self,
236
312
  *,
@@ -245,8 +321,6 @@ class PromptBuilder:
245
321
  connector = surface_context["active_connector"]
246
322
  chat_type = surface_context["active_chat_type"]
247
323
  chat_id = surface_context["active_chat_id"]
248
- qq_config = connectors_config.get("qq") if isinstance(connectors_config.get("qq"), dict) else {}
249
-
250
324
  lines = [
251
325
  f"- latest_user_source: {source}",
252
326
  f"- active_surface: {surface}",
@@ -264,38 +338,16 @@ class PromptBuilder:
264
338
  lines.extend(
265
339
  [
266
340
  "- qq_surface_rule: QQ is a milestone-report surface, not a full artifact browser.",
267
- "- qq_default_mode: keep outbound replies concise, respectful, text-first, and progress-aware.",
268
- "- qq_detail_rule: do not proactively dump file inventories, path lists, or low-level file details unless the user explicitly asked for them.",
269
- "- qq_length_rule: for ordinary QQ progress replies, normally use only 2 to 4 short sentences, or 3 very short bullets at most.",
270
- "- qq_summary_first_rule: start with the user-facing conclusion, then the immediate meaning, then the next action; do not make the user reverse-engineer the status from telemetry.",
271
- "- qq_internal_signal_rule: omit worker names, heartbeat timestamps, retry counters, pending/running/completed counts, file names, and monitor-window narration unless that detail is necessary for a user decision or to explain a real risk.",
272
- "- qq_translation_rule: translate internal actions into user value, for example say that you organized the baseline record for easier comparison later instead of listing the files you touched.",
273
- "- qq_eta_rule: for baseline reproduction, main experiments, analysis experiments, and other important long-running research phases, include a rough ETA for the next meaningful result, next step, or next update; if the runtime is uncertain, say that directly and still give the next check-in window.",
274
- f"- qq_auto_send_main_experiment_png: {bool(qq_config.get('auto_send_main_experiment_png', True))}",
275
- f"- qq_auto_send_analysis_summary_png: {bool(qq_config.get('auto_send_analysis_summary_png', True))}",
276
- f"- qq_auto_send_slice_png: {bool(qq_config.get('auto_send_slice_png', False))}",
277
- f"- qq_auto_send_paper_pdf: {bool(qq_config.get('auto_send_paper_pdf', True))}",
278
- f"- qq_enable_markdown_send: {bool(qq_config.get('enable_markdown_send', False))}",
279
- f"- qq_enable_file_upload_experimental: {bool(qq_config.get('enable_file_upload_experimental', False))}",
280
- "- qq_visual_rule: follow the fixed Morandi palette guide defined in the system prompt and active stage skill; do not assume per-install palette config exists.",
281
- "- qq_media_rule: auto-send only high-value milestone media such as a main-experiment summary PNG, an aggregated analysis summary PNG, or the final paper PDF when available and configured.",
282
- "- qq_media_rule_2: do not auto-send every slice image, every debug plot, or draft paper figures unless the user explicitly asked for them.",
283
- "- qq_structured_delivery_rule: when you want native QQ markdown or native QQ image/file delivery, request it through artifact.interact(connector_hints=..., attachments=[...]) instead of inventing connector-specific inline tag syntax.",
341
+ "- qq_reply_rule: keep outbound replies concise, respectful, text-first, and progress-aware.",
342
+ "- qq_detail_rule: rely on the QQ connector contract for detailed surface formatting instead of expanding it here.",
284
343
  ]
285
344
  )
286
345
  elif connector == "weixin":
287
346
  lines.extend(
288
347
  [
289
348
  "- weixin_surface_rule: Weixin is a concise operator surface, not a full artifact browser.",
290
- "- weixin_default_mode: keep outbound replies concise, respectful, text-first, and progress-aware.",
291
- "- weixin_length_rule: for ordinary Weixin progress replies, normally use only 2 to 4 short sentences, or 3 very short bullets at most.",
292
- "- weixin_summary_first_rule: start with the user-facing conclusion, then the immediate meaning, then the next action.",
293
- "- weixin_progress_shape_rule: make the current task, the main difficulty or latest real progress, and the next concrete next step explicit whenever possible.",
294
- "- weixin_eta_rule: for important long-running phases, include a rough ETA or next check-in window when it is helpful and defensible.",
295
- "- weixin_internal_detail_rule: do not proactively dump file inventories, path lists, retry counters, or monitor-log style telemetry unless the user asked for them or they explain a real risk.",
296
- "- weixin_context_token_rule: reply continuity is managed by the runtime through `context_token`; do not invent your own reply token scheme.",
297
- "- weixin_media_rule: when you want native Weixin image, video, or file delivery, request it through artifact.interact(..., attachments=[...]) with `connector_delivery={'weixin': {'media_kind': ...}}` instead of inventing connector-specific inline tag syntax.",
298
- "- weixin_inbound_media_rule: inbound Weixin image, video, and file messages can arrive as quest-local attachments under `userfiles/weixin/...`; read those files when the user sent media.",
349
+ "- weixin_reply_rule: keep outbound replies concise, respectful, text-first, and progress-aware.",
350
+ "- weixin_detail_rule: rely on the Weixin connector contract for detailed transport formatting instead of expanding it here.",
299
351
  ]
300
352
  )
301
353
  else:
@@ -472,7 +524,20 @@ class PromptBuilder:
472
524
  pending_user_count = int(snapshot.get("pending_user_message_count") or 0)
473
525
  if pending_user_count > 0:
474
526
  return f"Poll artifact.interact(...) and handle the {pending_user_count} queued user message(s) first."
527
+ continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
528
+ continuation_anchor = str(snapshot.get("continuation_anchor") or "").strip()
529
+ if continuation_policy == "wait_for_user_or_resume":
530
+ if continuation_anchor:
531
+ return (
532
+ f"The quest is intentionally parked after the latest durable checkpoint. Wait for a new user message or "
533
+ f"`/resume`, then continue from `{continuation_anchor}` instead of auto-continuing the previous stage."
534
+ )
535
+ return "The quest is intentionally parked after the latest durable checkpoint. Wait for a new user message or `/resume`."
536
+ if continuation_policy == "none":
537
+ return "Do not auto-continue this quest. Wait for an explicit new user instruction before doing more work."
475
538
  active_anchor = str(snapshot.get("active_anchor") or "decision").strip() or "decision"
539
+ if continuation_anchor:
540
+ active_anchor = continuation_anchor
476
541
  active_idea_id = str(snapshot.get("active_idea_id") or "").strip()
477
542
  next_slice_id = str(snapshot.get("next_pending_slice_id") or "").strip()
478
543
  active_campaign_id = str(snapshot.get("active_analysis_campaign_id") or "").strip()
@@ -489,6 +554,8 @@ class PromptBuilder:
489
554
  "Continue idea analysis and route selection until the next durable idea branch is submitted "
490
555
  "with `lineage_intent='continue_line'` or `lineage_intent='branch_alternative'`."
491
556
  )
557
+ if active_anchor == "optimize":
558
+ return "Continue the optimization loop from the current frontier, candidate pool, durable runs, and branch state."
492
559
  if active_anchor == "experiment":
493
560
  return "Continue the main experiment workflow from the current workspace, logs, and recorded evidence."
494
561
  if active_anchor == "analysis-campaign":
@@ -634,6 +701,24 @@ class PromptBuilder:
634
701
 
635
702
  return "\n".join(lines)
636
703
 
704
+ @staticmethod
705
+ def _recovery_resume_block(*, snapshot: dict, turn_reason: str) -> str:
706
+ if str(turn_reason or "").strip() != "auto_continue":
707
+ return "- none"
708
+ source = str(snapshot.get("last_resume_source") or "").strip()
709
+ if not source.startswith("auto:daemon-recovery"):
710
+ return "- none"
711
+ lines = [
712
+ f"- resume_source: {source}",
713
+ f"- resumed_at: {snapshot.get('last_resume_at') or 'unknown'}",
714
+ f"- abandoned_run_id: {snapshot.get('last_recovery_abandoned_run_id') or 'none'}",
715
+ f"- recovery_summary: {snapshot.get('last_recovery_summary') or 'none'}",
716
+ "- recovery_rule: this turn exists because the daemon/runtime previously died or stale running state was reconciled; first re-establish the current truth before continuing any old stage loop.",
717
+ "- recovery_rule_2: if there is any new user message, handle that before blindly resuming the older subtask.",
718
+ "- recovery_rule_3: do not assume the previous branch-local route is still the right immediate action until branch/workspace, run state, and user intent are checked together.",
719
+ ]
720
+ return "\n".join(lines)
721
+
637
722
  def _prompt_fragment(self, relative_path: str | Path, *, quest_root: Path | None = None) -> str:
638
723
  path = self._prompt_path(relative_path, quest_root=quest_root)
639
724
  return self._markdown_body(path)
@@ -693,6 +778,15 @@ class PromptBuilder:
693
778
  return value
694
779
  return "standard"
695
780
 
781
+ @staticmethod
782
+ def _standard_profile(snapshot: dict) -> str:
783
+ startup_contract = snapshot.get("startup_contract")
784
+ if isinstance(startup_contract, dict):
785
+ value = str(startup_contract.get("standard_profile") or "").strip().lower()
786
+ if value in {"canonical_research_graph", "optimization_task"}:
787
+ return value
788
+ return "canonical_research_graph"
789
+
696
790
  @staticmethod
697
791
  def _custom_profile(snapshot: dict) -> str:
698
792
  startup_contract = snapshot.get("startup_contract")
@@ -732,6 +826,7 @@ class PromptBuilder:
732
826
  def _research_delivery_policy_block(self, snapshot: dict) -> str:
733
827
  need_research_paper = self._need_research_paper(snapshot)
734
828
  launch_mode = self._launch_mode(snapshot)
829
+ standard_profile = self._standard_profile(snapshot)
735
830
  custom_profile = self._custom_profile(snapshot)
736
831
  baseline_execution_policy = self._baseline_execution_policy(snapshot)
737
832
  review_followup_policy = self._review_followup_policy(snapshot)
@@ -739,6 +834,7 @@ class PromptBuilder:
739
834
  lines = [
740
835
  f"- need_research_paper: {need_research_paper}",
741
836
  f"- launch_mode: {launch_mode}",
837
+ f"- standard_profile: {standard_profile if launch_mode == 'standard' else 'n/a'}",
742
838
  f"- custom_profile: {custom_profile if launch_mode == 'custom' else 'n/a'}",
743
839
  f"- review_followup_policy: {review_followup_policy if custom_profile == 'review_audit' else 'n/a'}",
744
840
  f"- baseline_execution_policy: {baseline_execution_policy if launch_mode == 'custom' else 'n/a'}",
@@ -833,6 +929,15 @@ class PromptBuilder:
833
929
  "- manuscript_edit_rule: when manuscript revision is needed, provide section-level copy-ready replacement text and explicit deltas even if no LaTeX source is available.",
834
930
  ]
835
931
  )
932
+ elif standard_profile == "optimization_task":
933
+ lines.extend(
934
+ [
935
+ "- standard_optimization_entry_rule: this standard entry is explicitly optimization-only; treat repeated implementation attempts and measured main-experiment results as the primary progress loop.",
936
+ "- standard_optimization_no_analysis_default: do not route into `analysis-campaign` by default; only run extra analysis when it directly validates a suspected win, disambiguates a frontier decision, or exposes a concrete failure mode that changes the next optimization move.",
937
+ "- standard_optimization_no_writing_default: do not route into `write`, `review`, or `finalize` while this optimization task profile remains active unless the user explicitly broadens scope.",
938
+ "- standard_optimization_iteration_rule: prefer more justified optimization attempts, branch promotion, or frontier cleanup over paper-facing packaging.",
939
+ ]
940
+ )
836
941
  if need_research_paper:
837
942
  lines.extend(
838
943
  [
@@ -847,6 +952,9 @@ class PromptBuilder:
847
952
  lines.extend(
848
953
  [
849
954
  "- delivery_goal: the quest should pursue the strongest justified algorithmic result rather than paper packaging.",
955
+ "- optimization_object_rule: distinguish candidate briefs, durable optimization lines, and implementation-level optimization candidates; do not treat them as one object type.",
956
+ "- optimization_frontier_rule: before major route selection in algorithm-first work, read `artifact.get_optimization_frontier(...)` and treat the current frontier as the primary optimize-state summary.",
957
+ "- optimization_promotion_rule: `submission_mode='candidate'` is branchless pre-promotion state, while `submission_mode='line'` is a committed durable line with a branch/worktree.",
850
958
  "- main_result_rule: use each measured main-experiment result to decide whether to create a `continue_line` child branch, create a `branch_alternative` sibling-like branch, run more analysis, or stop.",
851
959
  "- no_paper_rule: do not default into `artifact.submit_paper_outline(...)`, `artifact.submit_paper_bundle(...)`, or `finalize` while this mode remains active.",
852
960
  "- autonomy_rule: choose the next optimization foundation from durable evidence such as baseline state, the current research head, and recent main-experiment results; do not routinely ask the user to choose that.",
@@ -855,6 +963,69 @@ class PromptBuilder:
855
963
  )
856
964
  return "\n".join(lines)
857
965
 
966
+ def _optimization_frontier_block(self, snapshot: dict, quest_root: Path) -> str:
967
+ active_anchor = str(snapshot.get("active_anchor") or "").strip().lower()
968
+ if self._need_research_paper(snapshot) and active_anchor != "optimize":
969
+ return "- not primary in the current delivery mode"
970
+
971
+ try:
972
+ from ..artifact import ArtifactService
973
+
974
+ payload = ArtifactService(self.home).get_optimization_frontier(quest_root)
975
+ except Exception:
976
+ payload = {"ok": False}
977
+
978
+ frontier = (
979
+ dict(payload.get("optimization_frontier") or {})
980
+ if isinstance(payload, dict) and isinstance(payload.get("optimization_frontier"), dict)
981
+ else {}
982
+ )
983
+ if not frontier:
984
+ return "- unavailable"
985
+
986
+ best_branch = dict(frontier.get("best_branch") or {}) if isinstance(frontier.get("best_branch"), dict) else {}
987
+ best_run = dict(frontier.get("best_run") or {}) if isinstance(frontier.get("best_run"), dict) else {}
988
+ backlog = dict(frontier.get("candidate_backlog") or {}) if isinstance(frontier.get("candidate_backlog"), dict) else {}
989
+ next_actions = [str(item).strip() for item in (frontier.get("recommended_next_actions") or []) if str(item).strip()]
990
+ stagnant = frontier.get("stagnant_branches") or []
991
+ fusion = frontier.get("fusion_candidates") or []
992
+ local_attempts = [
993
+ dict(item)
994
+ for item in (frontier.get("best_branch_recent_candidates") or [])
995
+ if isinstance(item, dict)
996
+ ]
997
+
998
+ lines = [
999
+ f"- frontier_mode: {str(frontier.get('mode') or 'unknown')}",
1000
+ f"- frontier_reason: {str(frontier.get('frontier_reason') or 'none')}",
1001
+ f"- frontier_best_branch: {str(best_branch.get('branch_name') or best_branch.get('branch_no') or 'none')}",
1002
+ f"- frontier_best_run: {str(best_run.get('run_id') or 'none')}",
1003
+ f"- frontier_candidate_briefs: {int(backlog.get('candidate_brief_count') or 0)}",
1004
+ f"- frontier_active_implementation_candidates: {int(backlog.get('active_implementation_candidate_count') or 0)}",
1005
+ f"- frontier_failed_implementation_candidates: {int(backlog.get('failed_implementation_candidate_count') or 0)}",
1006
+ f"- frontier_stagnant_branch_count: {len([item for item in stagnant if isinstance(item, dict)])}",
1007
+ f"- frontier_fusion_candidate_count: {len([item for item in fusion if isinstance(item, dict)])}",
1008
+ "- optimization_frontier_rule: in algorithm-first work, treat this block as the primary route-selection surface before relying on paper-facing state.",
1009
+ ]
1010
+ if local_attempts:
1011
+ parts: list[str] = []
1012
+ for item in local_attempts[-3:]:
1013
+ summary_bits = [
1014
+ str(item.get("candidate_id") or "").strip() or "candidate",
1015
+ str(item.get("status") or "").strip() or "unknown",
1016
+ str(item.get("strategy") or "").strip() or None,
1017
+ str(item.get("mechanism_family") or "").strip() or None,
1018
+ str(item.get("failure_kind") or "").strip() or None,
1019
+ ]
1020
+ parts.append(" / ".join(bit for bit in summary_bits if bit))
1021
+ lines.append(f"- frontier_same_line_local_attempt_memory: {' | '.join(parts)}")
1022
+ lines.append(
1023
+ "- optimization_local_memory_rule: before seed, loop, or debug work on the leading line, inspect this same-line local attempt memory so you do not repeat a near-duplicate change blindly."
1024
+ )
1025
+ if next_actions:
1026
+ lines.append(f"- frontier_next_actions: {' | '.join(next_actions[:3])}")
1027
+ return "\n".join(lines)
1028
+
858
1029
  def _interaction_style_block(self, *, default_locale: str, user_message: str, snapshot: dict) -> str:
859
1030
  normalized_locale = str(default_locale or "").lower()
860
1031
  chinese_turn = normalized_locale.startswith("zh") or bool(re.search(r"[\u4e00-\u9fff]", user_message))
@@ -862,6 +1033,7 @@ class PromptBuilder:
862
1033
  need_research_paper = self._need_research_paper(snapshot)
863
1034
  decision_policy = self._decision_policy(snapshot)
864
1035
  launch_mode = self._launch_mode(snapshot)
1036
+ standard_profile = self._standard_profile(snapshot)
865
1037
  custom_profile = self._custom_profile(snapshot)
866
1038
  lines = [
867
1039
  f"- configured_default_locale: {default_locale}",
@@ -869,6 +1041,7 @@ class PromptBuilder:
869
1041
  f"- bound_conversation_count: {len(bound_conversations)}",
870
1042
  f"- decision_policy: {decision_policy}",
871
1043
  f"- launch_mode: {launch_mode}",
1044
+ f"- standard_profile: {standard_profile if launch_mode == 'standard' else 'n/a'}",
872
1045
  f"- custom_profile: {custom_profile if launch_mode == 'custom' else 'n/a'}",
873
1046
  "- collaboration_mode: long-horizon, continuity-first, artifact-aware",
874
1047
  "- response_pattern: say what changed -> say what it means -> say what happens next",
@@ -888,17 +1061,19 @@ class PromptBuilder:
888
1061
  f"- standby_prefix_rule: when you intentionally leave one blocking standby interaction after task completion, prefix it with {'[等待决策]' if chinese_turn else '[Waiting for decision]'} and wait for a new user reply before continuing",
889
1062
  "- stop_notice_protocol: if work must pause or stop, send a user-visible notice that explains why, confirms preserved context, and states that any new message or `/resume` will continue from the same quest",
890
1063
  "- respect_protocol: write user-facing updates as natural, respectful, easy-to-follow chat; do not sound like a formal status report or internal tool log",
891
- "- omission_protocol: for ordinary user-facing updates, omit file paths, artifact ids, branch/worktree ids, session ids, raw commands, raw logs, and internal tool names unless the user asked for them or needs them to act",
1064
+ "- novice_context_protocol: assume the user may not know the repo layout, branch model, artifact schema, or tool names; explain progress in task language first.",
1065
+ "- structure_protocol: when explaining 2 to 3 options, tradeoffs, or next steps, prefer a short numbered structure so the user can scan the decision surface quickly.",
1066
+ "- example_and_numbers_protocol: when it materially improves understanding, include one short example or 1 to 3 key numbers or comparisons instead of relying only on vague adjectives such as better, slower, or more stable.",
1067
+ "- omission_protocol: for ordinary user-facing updates, omit file paths, file names, artifact ids, branch/worktree ids, session ids, raw commands, raw logs, and internal tool names unless the user asked for them or needs them to act",
892
1068
  "- compaction_protocol: ordinary artifact.interact progress updates should usually fit in 2 to 4 short sentences and should not read like a monitoring transcript or execution diary",
893
- "- watchdog_payload_protocol: if a tool result includes `watchdog_notes`, `progress_watchdog_note`, `visibility_watchdog_note`, or `state_change_watchdog_note`, treat that as an action item and send the required artifact.interact(...) update before doing more background work",
1069
+ "- watchdog_payload_protocol: if a tool result includes `watchdog_notes`, `progress_watchdog_note`, `visibility_watchdog_note`, or `state_change_watchdog_note`, treat that as an action item to inspect state and decide whether a fresh user-visible update is actually needed; do not emit duplicate progress by reflex",
894
1070
  "- human_progress_shape_protocol: ordinary progress updates should usually make three things explicit in human language: the current task, the main difficulty or latest real progress, and the concrete next measure you will take",
895
1071
  "- stage_contract_protocol: stage-specific plan/checklist rules, milestone rules, literature rules, and writing rules belong in the requested skill; do not expect this runtime block to restate them",
896
1072
  "- teammate_voice_protocol: write like a calm capable teammate using natural first-person phrasing when helpful, for example 'I'm working on ...', 'The main issue right now is ...', 'Next I'll ...'; do not sound like a dashboard or incident log",
897
- "- translation_protocol: convert internal actions into user-facing meaning; describe what was finished and why it matters instead of naming every touched file, counter, timestamp, or subprocess",
1073
+ "- translation_protocol: convert internal actions into user-facing meaning; describe what was finished and why it matters instead of naming every touched file, path, branch, counter, timestamp, or subprocess",
898
1074
  "- detail_gate_protocol: include exact counters, worker labels, timestamps, retry counts, or file names only when the user explicitly asked for them, when they change the recommended action, or when they are the only honest way to explain a real blocker",
899
1075
  "- monitoring_summary_protocol: for long-running monitoring loops, summarize the frontier state in plain language such as still progressing, temporarily stalled, recovered, or needs intervention; do not narrate each watch window",
900
1076
  "- preflight_rewrite_protocol: before sending artifact.interact, quickly self-check whether the draft reads like a monitoring log, file inventory, or internal diary; if it mentions watch windows, heartbeats, retry counters, raw counts, timestamps, or multiple file names without being necessary for user action, rewrite it into conclusion -> meaning -> next step first",
901
- "- non_research_mode_protocol: if the user message looks like a non-research request, ask for a second confirmation before engaging stage skills or research workflow; after completion, leave one blocking standby interaction instead of repeatedly pinging",
902
1077
  "- workspace_discipline: read and modify code inside current_workspace_root; treat quest_root as the canonical repo identity and durable runtime root",
903
1078
  "- binary_safety: do not open or rewrite large binary assets unless truly necessary; prefer summaries, metadata, and targeted inspection first",
904
1079
  ]
@@ -913,7 +1088,7 @@ class PromptBuilder:
913
1088
  else:
914
1089
  lines.extend(
915
1090
  [
916
- "- user_gated_decision_protocol: when continuation truly depends on user preference, approval, or scope choice, use one structured blocking decision request with 1 to 3 concrete options.",
1091
+ "- user_gated_decision_protocol: when continuation truly depends on user preference, approval, or scope choice, use one structured blocking decision request with 1 to 3 concrete options; for each option say what it means, how strongly you recommend it, and what impact it would have on speed, quality, cost, or risk.",
917
1092
  "- user_gated_restraint: even in user-gated mode, do not turn ordinary progress or ordinary stage completion into blocking interrupts.",
918
1093
  ]
919
1094
  )
@@ -925,6 +1100,10 @@ class PromptBuilder:
925
1100
  lines.append(
926
1101
  "- completion_protocol: when `startup_contract.need_research_paper` is false, the quest goal is the strongest justified algorithmic result; keep iterating from measured main-experiment results and do not self-route into paper work by default"
927
1102
  )
1103
+ if launch_mode == "standard" and standard_profile == "optimization_task":
1104
+ lines.append(
1105
+ "- standard_optimization_completion_protocol: in this entry profile, do not treat missing paper artifacts or missing analysis-campaign artifacts as unfinished work by themselves; keep pushing the optimization frontier until the result plateaus, a blocker appears, or the user changes scope."
1106
+ )
928
1107
  if chinese_turn:
929
1108
  lines.extend(
930
1109
  [
@@ -942,141 +1121,40 @@ class PromptBuilder:
942
1121
  return "\n".join(lines)
943
1122
 
944
1123
  def _quest_context_block(self, quest_root: Path) -> str:
945
- parts = []
946
- for title, filename in (
947
- ("Brief", "brief.md"),
948
- ("Plan", "plan.md"),
949
- ("Status", "status.md"),
950
- ("Summary", "SUMMARY.md"),
951
- ):
952
- text = read_text(quest_root / filename).strip() or "(empty)"
953
- parts.extend([f"{title} ({filename}):", text, ""])
954
- return "\n".join(parts).strip()
1124
+ return "\n".join(
1125
+ [
1126
+ "- quest_context_rule: quest documents are durable but not pre-expanded here.",
1127
+ "- quest_documents_tool: call artifact.read_quest_documents(names=['brief','plan','status','summary'], mode='excerpt'|'full') when document detail is needed.",
1128
+ "- active_user_requirements_tool: call artifact.read_quest_documents(names=['active_user_requirements'], mode='full') when exact current durable user requirements matter.",
1129
+ ]
1130
+ )
955
1131
 
956
1132
  def _durable_state_block(self, snapshot: dict, quest_root: Path) -> str:
957
- requested_baseline_ref = (
958
- dict(snapshot.get("requested_baseline_ref") or {})
959
- if isinstance(snapshot.get("requested_baseline_ref"), dict)
960
- else None
961
- )
962
- startup_contract = (
963
- dict(snapshot.get("startup_contract") or {})
964
- if isinstance(snapshot.get("startup_contract"), dict)
965
- else None
966
- )
967
1133
  confirmed_baseline_ref = (
968
1134
  dict(snapshot.get("confirmed_baseline_ref") or {})
969
1135
  if isinstance(snapshot.get("confirmed_baseline_ref"), dict)
970
- else None
1136
+ else {}
971
1137
  )
972
- requested_baseline_id = str((requested_baseline_ref or {}).get("baseline_id") or "").strip()
973
- confirmed_baseline_id = str((confirmed_baseline_ref or {}).get("baseline_id") or "").strip()
974
- confirmed_baseline_rel_path = str(
975
- (confirmed_baseline_ref or {}).get("baseline_root_rel_path") or ""
976
- ).strip()
977
1138
  confirmed_metric_contract_json_rel_path = str(
978
- (confirmed_baseline_ref or {}).get("metric_contract_json_rel_path") or ""
1139
+ confirmed_baseline_ref.get("metric_contract_json_rel_path") or ""
979
1140
  ).strip()
980
- prebound_baseline_ready = bool(
981
- requested_baseline_id
982
- and confirmed_baseline_id
983
- and requested_baseline_id == confirmed_baseline_id
984
- and str(snapshot.get("baseline_gate") or "").strip().lower() == "confirmed"
985
- )
986
1141
  lines = [
987
1142
  f"- baseline_gate: {snapshot.get('baseline_gate') or 'pending'}",
988
1143
  f"- active_baseline_id: {snapshot.get('active_baseline_id') or 'none'}",
989
- f"- active_baseline_variant_id: {snapshot.get('active_baseline_variant_id') or 'none'}",
990
- f"- requested_baseline_ref: {json.dumps(requested_baseline_ref, ensure_ascii=False, sort_keys=True) if requested_baseline_ref else 'none'}",
991
- f"- startup_contract: {json.dumps(startup_contract, ensure_ascii=False, sort_keys=True) if startup_contract else 'none'}",
992
- f"- startup_decision_policy: {self._decision_policy(snapshot)}",
993
- f"- confirmed_baseline_ref: {json.dumps(confirmed_baseline_ref, ensure_ascii=False, sort_keys=True) if confirmed_baseline_ref else 'none'}",
994
- f"- confirmed_baseline_import_root: {confirmed_baseline_rel_path or 'none'}",
995
- f"- prebound_baseline_ready: {prebound_baseline_ready}",
996
1144
  f"- active_run_id: {snapshot.get('active_run_id') or 'none'}",
997
- f"- research_head_branch: {snapshot.get('research_head_branch') or 'none'}",
998
- f"- research_head_worktree_root: {snapshot.get('research_head_worktree_root') or 'none'}",
999
- f"- current_workspace_branch: {snapshot.get('current_workspace_branch') or 'none'}",
1000
- f"- current_workspace_root: {snapshot.get('current_workspace_root') or 'none'}",
1001
1145
  f"- active_idea_id: {snapshot.get('active_idea_id') or 'none'}",
1002
- f"- active_idea_md_path: {snapshot.get('active_idea_md_path') or 'none'}",
1003
1146
  f"- active_analysis_campaign_id: {snapshot.get('active_analysis_campaign_id') or 'none'}",
1004
- f"- next_pending_slice_id: {snapshot.get('next_pending_slice_id') or 'none'}",
1147
+ f"- active_paper_line_ref: {snapshot.get('active_paper_line_ref') or 'none'}",
1148
+ f"- current_workspace_branch: {snapshot.get('current_workspace_branch') or 'none'}",
1149
+ f"- current_workspace_root: {snapshot.get('current_workspace_root') or 'none'}",
1005
1150
  f"- workspace_mode: {snapshot.get('workspace_mode') or 'quest'}",
1006
1151
  f"- runtime_status: {snapshot.get('runtime_status') or snapshot.get('status') or 'unknown'}",
1007
- f"- stop_reason: {snapshot.get('stop_reason') or 'none'}",
1008
- f"- pending_decisions: {', '.join(snapshot.get('pending_decisions') or []) or 'none'}",
1009
- f"- pending_user_message_count: {snapshot.get('pending_user_message_count') or 0}",
1010
- f"- active_interaction_count: {len(snapshot.get('active_interactions') or [])}",
1011
1152
  f"- waiting_interaction_id: {snapshot.get('waiting_interaction_id') or 'none'}",
1012
- f"- latest_thread_interaction_id: {snapshot.get('latest_thread_interaction_id') or 'none'}",
1013
- f"- default_reply_interaction_id: {snapshot.get('default_reply_interaction_id') or 'none'}",
1014
- f"- last_artifact_interact_at: {snapshot.get('last_artifact_interact_at') or 'none'}",
1015
- f"- seconds_since_last_artifact_interact: {snapshot.get('seconds_since_last_artifact_interact') if snapshot.get('seconds_since_last_artifact_interact') is not None else 'none'}",
1016
- f"- tool_calls_since_last_artifact_interact: {snapshot.get('tool_calls_since_last_artifact_interact') or 0}",
1017
- f"- last_tool_activity_at: {snapshot.get('last_tool_activity_at') or 'none'}",
1018
- f"- last_tool_activity_name: {snapshot.get('last_tool_activity_name') or 'none'}",
1019
- f"- last_delivered_batch_id: {snapshot.get('last_delivered_batch_id') or 'none'}",
1020
- f"- bound_conversations: {', '.join(snapshot.get('bound_conversations') or []) or 'none'}",
1021
- f"- cloud_linked: {snapshot.get('cloud', {}).get('linked', False)}",
1153
+ f"- pending_user_message_count: {snapshot.get('pending_user_message_count') or 0}",
1154
+ f"- continuation_policy: {snapshot.get('continuation_policy') or 'auto'}",
1155
+ f"- continuation_anchor: {snapshot.get('continuation_anchor') or 'none'}",
1156
+ "- quest_state_tool: call artifact.get_quest_state(detail='summary'|'full') for current runtime refs, interactions, recent artifacts, and recent runs.",
1022
1157
  ]
1023
- if prebound_baseline_ready and confirmed_baseline_rel_path:
1024
- lines.extend(
1025
- [
1026
- "- prebound_baseline_execution_policy: runtime already attached and confirmed the requested baseline before this turn.",
1027
- f"- prebound_baseline_runtime_path: {confirmed_baseline_rel_path}",
1028
- "- prebound_baseline_agent_rule: do not redo baseline discovery or reproduction unless you find a concrete incompatibility, corruption, or missing evidence problem.",
1029
- ]
1030
- )
1031
- active_workspace_root = Path(str(snapshot.get("current_workspace_root") or quest_root))
1032
- attachment_root = active_workspace_root / "baselines" / "imported"
1033
- if attachment_root.exists():
1034
- attachments = [read_yaml(path, {}) for path in sorted(attachment_root.glob("*/attachment.yaml"))]
1035
- attachments = [
1036
- item
1037
- for item in attachments
1038
- if isinstance(item, dict)
1039
- and item
1040
- and (
1041
- not str(item.get("source_baseline_id") or "").strip()
1042
- or not self.baseline_registry.is_deleted(str(item.get("source_baseline_id") or "").strip())
1043
- )
1044
- ]
1045
- if attachments:
1046
- attachment = max(
1047
- attachments,
1048
- key=lambda item: (
1049
- str(item.get("attached_at") or ""),
1050
- str(item.get("source_baseline_id") or ""),
1051
- ),
1052
- )
1053
- entry = attachment.get("entry") if isinstance(attachment.get("entry"), dict) else {}
1054
- confirmation = attachment.get("confirmation") if isinstance(attachment.get("confirmation"), dict) else {}
1055
- if not confirmed_metric_contract_json_rel_path:
1056
- confirmed_metric_contract_json_rel_path = str(
1057
- confirmation.get("metric_contract_json_rel_path") or ""
1058
- ).strip()
1059
- contract = entry.get("metric_contract") if isinstance(entry.get("metric_contract"), dict) else {}
1060
- primary_metric_id = str(contract.get("primary_metric_id") or "").strip() or "none"
1061
- metric_ids = [
1062
- str(item.get("metric_id") or "").strip()
1063
- for item in contract.get("metrics", [])
1064
- if isinstance(item, dict) and str(item.get("metric_id") or "").strip()
1065
- ]
1066
- lines.extend(
1067
- [
1068
- f"- active_baseline_primary_metric_id: {primary_metric_id}",
1069
- f"- active_baseline_metric_ids: {', '.join(metric_ids) or 'none'}",
1070
- ]
1071
- )
1072
- if (
1073
- not confirmed_metric_contract_json_rel_path
1074
- and confirmed_baseline_rel_path
1075
- and (quest_root / confirmed_baseline_rel_path / "json" / "metric_contract.json").exists()
1076
- ):
1077
- confirmed_metric_contract_json_rel_path = str(
1078
- Path(confirmed_baseline_rel_path, "json", "metric_contract.json").as_posix()
1079
- )
1080
1158
  if confirmed_metric_contract_json_rel_path:
1081
1159
  lines.extend(
1082
1160
  [
@@ -1084,256 +1162,42 @@ class PromptBuilder:
1084
1162
  "- active_baseline_metric_contract_rule: before planning or running `experiment` or `analysis-campaign`, read this JSON file and treat it as the canonical baseline comparison contract unless a newer confirmed baseline explicitly replaces it.",
1085
1163
  ]
1086
1164
  )
1087
- analysis_baseline_inventory = read_json(quest_root / "artifacts" / "baselines" / "analysis_inventory.json", {})
1088
- analysis_baseline_inventory = analysis_baseline_inventory if isinstance(analysis_baseline_inventory, dict) else {}
1089
- analysis_inventory_entries = (
1090
- analysis_baseline_inventory.get("entries") if isinstance(analysis_baseline_inventory.get("entries"), list) else []
1091
- )
1092
- registered_count = sum(
1093
- 1
1094
- for item in analysis_inventory_entries
1095
- if isinstance(item, dict) and str(item.get("status") or "").strip().lower() == "registered"
1096
- )
1097
- if analysis_inventory_entries:
1098
- lines.extend(
1099
- [
1100
- f"- supplementary_baseline_inventory_status: artifacts/baselines/analysis_inventory.json [exists]",
1101
- f"- supplementary_baseline_count: {len(analysis_inventory_entries)}",
1102
- f"- supplementary_baseline_registered_count: {registered_count}",
1103
- ]
1104
- )
1105
- else:
1106
- lines.append("- supplementary_baseline_inventory_status: artifacts/baselines/analysis_inventory.json [missing]")
1107
- lines.extend(["", "Active interactions:"])
1108
- active_interactions = snapshot.get("active_interactions") or []
1109
- if active_interactions:
1110
- for item in active_interactions[-3:]:
1111
- interaction_id = item.get("interaction_id") or item.get("artifact_id") or "interaction"
1112
- status = item.get("status") or "unknown"
1113
- message = str(item.get("message") or "").strip().replace("\n", " ")
1114
- if len(message) > 180:
1115
- message = message[:177].rstrip() + "..."
1116
- lines.append(f"- {interaction_id} [{status}] {message or '(no message)'}")
1117
- else:
1118
- lines.append("- none")
1119
- if int(snapshot.get("pending_user_message_count") or 0) > 0:
1120
- lines.extend(
1121
- [
1122
- "",
1123
- "Queued user-message notice:",
1124
- "- There are queued user messages waiting to be picked up via artifact.interact(include_recent_inbound_messages=True).",
1125
- "- Before continuing a resumed or follow-up turn, retrieve that mailbox payload first.",
1126
- "- After the mailbox returns user text, immediately send a follow-up artifact.interact acknowledgement or direct answer before resuming background work.",
1127
- ]
1128
- )
1129
-
1130
- lines.extend(
1131
- [
1132
- "",
1133
- "Recent artifacts:",
1134
- ]
1135
- )
1136
- recent_artifacts = snapshot.get("recent_artifacts") or []
1137
- if recent_artifacts:
1138
- for item in recent_artifacts[-5:]:
1139
- payload = item.get("payload") or {}
1140
- label = payload.get("artifact_id") or Path(item.get("path", "")).stem or "artifact"
1141
- summary = payload.get("summary") or payload.get("reason") or "No summary provided."
1142
- lines.append(f"- {item.get('kind')}: {label} -> {summary}")
1143
- else:
1144
- lines.append("- none")
1145
-
1146
- lines.extend(["", "Recent runs:"])
1147
- recent_runs = snapshot.get("recent_runs") or []
1148
- if recent_runs:
1149
- for item in recent_runs[-5:]:
1150
- run_id = item.get("run_id") or "unknown-run"
1151
- summary = item.get("summary") or "No summary provided."
1152
- lines.append(f"- {run_id}: {summary}")
1153
- else:
1154
- lines.append("- none")
1155
-
1156
- lines.extend(["", "Recent quest memory cards:"])
1157
- quest_cards = self.memory_service.list_recent(scope="quest", quest_root=quest_root, limit=5)
1158
- if quest_cards:
1159
- for card in quest_cards:
1160
- lines.append(f"- {card.get('type')}: {card.get('title')} ({card.get('path')})")
1161
- else:
1162
- lines.append("- none")
1163
-
1164
- lines.extend(["", "Recent global memory cards:"])
1165
- global_cards = self.memory_service.list_recent(scope="global", limit=3)
1166
- if global_cards:
1167
- for card in global_cards:
1168
- lines.append(f"- {card.get('type')}: {card.get('title')} ({card.get('path')})")
1169
- else:
1170
- lines.append("- none")
1171
-
1172
- lines.extend(["", "Reusable baselines:"])
1173
- baseline_entries = self.baseline_registry.list_entries()[-5:]
1174
- if baseline_entries:
1175
- for entry in baseline_entries:
1176
- baseline_id = entry.get("baseline_id") or entry.get("entry_id") or "unknown-baseline"
1177
- summary = entry.get("summary") or entry.get("task") or "No summary provided."
1178
- status = str(entry.get("status") or "unknown").strip() or "unknown"
1179
- lines.append(f"- {baseline_id} [{status}]: {summary}")
1180
- else:
1181
- lines.append("- none")
1182
1165
  return "\n".join(lines)
1183
1166
 
1184
1167
  def _paper_and_evidence_block(self, snapshot: dict, quest_root: Path) -> str:
1185
- workspace_root = Path(str(snapshot.get("active_workspace_root") or quest_root))
1186
- paper_root = workspace_root / "paper"
1187
- if not paper_root.exists():
1188
- paper_root = quest_root / "paper"
1189
- open_source_root = workspace_root / "release" / "open_source"
1190
- if not open_source_root.exists():
1191
- open_source_root = quest_root / "release" / "open_source"
1192
- selected_outline = read_json(paper_root / "selected_outline.json", {})
1193
- selected_outline = selected_outline if isinstance(selected_outline, dict) else {}
1194
- detailed_outline = (
1195
- dict(selected_outline.get("detailed_outline") or {})
1196
- if isinstance(selected_outline.get("detailed_outline"), dict)
1168
+ paper_contract = (
1169
+ dict(snapshot.get("paper_contract") or {})
1170
+ if isinstance(snapshot.get("paper_contract"), dict)
1197
1171
  else {}
1198
1172
  )
1199
- bundle_manifest = read_json(paper_root / "paper_bundle_manifest.json", {})
1200
- bundle_manifest = bundle_manifest if isinstance(bundle_manifest, dict) else {}
1201
- paper_baseline_inventory = read_json(paper_root / "baseline_inventory.json", {})
1202
- paper_baseline_inventory = paper_baseline_inventory if isinstance(paper_baseline_inventory, dict) else {}
1203
- claim_evidence_map = read_json(paper_root / "claim_evidence_map.json", {})
1204
- claim_evidence_map = claim_evidence_map if isinstance(claim_evidence_map, dict) else {}
1205
- compile_report = read_json(paper_root / "build" / "compile_report.json", {})
1206
- compile_report = compile_report if isinstance(compile_report, dict) else {}
1207
- open_source_manifest = read_json(open_source_root / "manifest.json", {})
1208
- open_source_manifest = open_source_manifest if isinstance(open_source_manifest, dict) else {}
1209
- default_paper_prefix = (
1210
- paper_root.relative_to(quest_root).as_posix()
1211
- if paper_root.is_relative_to(quest_root)
1212
- else "paper"
1213
- )
1214
- default_release_prefix = (
1215
- open_source_root.relative_to(quest_root).as_posix()
1216
- if open_source_root.is_relative_to(quest_root)
1217
- else "release/open_source"
1218
- )
1219
-
1220
- selected_outline_ref = str(
1221
- selected_outline.get("outline_id") or bundle_manifest.get("selected_outline_ref") or ""
1222
- ).strip()
1223
- selected_outline_title = str(
1224
- detailed_outline.get("title") or selected_outline.get("title") or bundle_manifest.get("title") or ""
1225
- ).strip()
1226
- research_questions_raw = detailed_outline.get("research_questions")
1227
- research_questions: list[str] = []
1228
- if isinstance(research_questions_raw, list):
1229
- for item in research_questions_raw:
1230
- if isinstance(item, dict):
1231
- question = str(item.get("question_text") or item.get("title") or item.get("id") or "").strip()
1232
- else:
1233
- question = str(item or "").strip()
1234
- if question:
1235
- research_questions.append(question)
1236
-
1237
1173
  lines = [
1238
- f"- selected_outline_ref: {selected_outline_ref or 'none'}",
1239
- f"- selected_outline_title: {selected_outline_title or 'none'}",
1240
- f"- selected_outline_story_present: {bool(selected_outline.get('story'))}",
1241
- f"- selected_outline_ten_questions_present: {bool(selected_outline.get('ten_questions'))}",
1242
- f"- active_research_question_count: {len(research_questions)}",
1174
+ f"- selected_outline_ref: {str(paper_contract.get('selected_outline_ref') or 'none')}",
1175
+ f"- selected_outline_title: {str(paper_contract.get('title') or 'none')}",
1243
1176
  ]
1244
- if research_questions:
1245
- for index, question in enumerate(research_questions[:3], start=1):
1246
- lines.append(f"- active_research_question_{index}: {question}")
1247
-
1248
- def _path_status(path_str: str | None, *, fallback: str) -> str:
1249
- resolved = str(path_str or fallback).strip() or fallback
1250
- exists = (quest_root / resolved).exists()
1251
- return f"{resolved} [{'exists' if exists else 'missing'}]"
1252
-
1253
- lines.extend(
1254
- [
1255
- f"- writing_plan_status: {_path_status(bundle_manifest.get('writing_plan_path'), fallback=f'{default_paper_prefix}/writing_plan.md')}",
1256
- f"- draft_status: {_path_status(bundle_manifest.get('draft_path'), fallback=f'{default_paper_prefix}/draft.md')}",
1257
- f"- references_status: {_path_status(bundle_manifest.get('references_path'), fallback=f'{default_paper_prefix}/references.bib')}",
1258
- f"- claim_evidence_map_status: {_path_status(bundle_manifest.get('claim_evidence_map_path'), fallback=f'{default_paper_prefix}/claim_evidence_map.json')}",
1259
- f"- baseline_inventory_status: {_path_status(bundle_manifest.get('baseline_inventory_path'), fallback=f'{default_paper_prefix}/baseline_inventory.json')}",
1260
- f"- review_status: {f'{default_paper_prefix}/review/review.md [exists]' if (paper_root / 'review' / 'review.md').exists() else f'{default_paper_prefix}/review/review.md [missing]'}",
1261
- f"- proofing_report_status: {f'{default_paper_prefix}/proofing/proofing_report.md [exists]' if (paper_root / 'proofing' / 'proofing_report.md').exists() else f'{default_paper_prefix}/proofing/proofing_report.md [missing]'}",
1262
- f"- page_images_manifest_status: {f'{default_paper_prefix}/proofing/page_images_manifest.json [exists]' if (paper_root / 'proofing' / 'page_images_manifest.json').exists() else f'{default_paper_prefix}/proofing/page_images_manifest.json [missing]'}",
1263
- ]
1177
+ paper_contract_health = (
1178
+ dict(snapshot.get("paper_contract_health") or {})
1179
+ if isinstance(snapshot.get("paper_contract_health"), dict)
1180
+ else {}
1264
1181
  )
1265
-
1266
- if bundle_manifest:
1267
- pdf_rel_path = str(bundle_manifest.get("pdf_path") or "").strip()
1268
- compile_rel_path = str(bundle_manifest.get("compile_report_path") or "").strip()
1269
- latex_root_path = str(bundle_manifest.get("latex_root_path") or "").strip()
1182
+ if paper_contract_health:
1183
+ primary_blocker = str(
1184
+ ((paper_contract_health.get("blocking_reasons") or [None])[0]) or "none"
1185
+ ).strip() or "none"
1270
1186
  lines.extend(
1271
1187
  [
1272
- "- paper_bundle_manifest_present: True",
1273
- f"- bundle_pdf_status: {_path_status(pdf_rel_path, fallback=f'{default_paper_prefix}/paper.pdf')}",
1274
- f"- bundle_compile_report_status: {_path_status(compile_rel_path, fallback=f'{default_paper_prefix}/build/compile_report.json')}",
1275
- f"- bundle_latex_root: {latex_root_path or 'none'}",
1276
- f"- open_source_manifest_status: {_path_status(bundle_manifest.get('open_source_manifest_path'), fallback=f'{default_release_prefix}/manifest.json')}",
1277
- f"- open_source_cleanup_plan_status: {_path_status(bundle_manifest.get('open_source_cleanup_plan_path'), fallback=f'{default_release_prefix}/cleanup_plan.md')}",
1188
+ f"- paper_contract_health: {'ready' if bool(paper_contract_health.get('writing_ready')) else 'blocked'}",
1189
+ f"- paper_health_counts: unresolved_required={int(paper_contract_health.get('unresolved_required_count') or 0)}, unmapped_completed={int(paper_contract_health.get('unmapped_completed_count') or 0)}, blocking_pending={int(paper_contract_health.get('blocking_open_supplementary_count') or 0)}",
1190
+ f"- paper_recommended_next_stage: {str(paper_contract_health.get('recommended_next_stage') or 'none')}",
1191
+ f"- paper_recommended_action: {str(paper_contract_health.get('recommended_action') or 'none')}",
1192
+ f"- paper_primary_blocker: {primary_blocker}",
1193
+ "- paper_health_tool: call artifact.get_paper_contract_health(detail='full') before paper-facing write/finalize work when the exact blocking items matter.",
1194
+ "- paper_outline_tool: call artifact.list_paper_outlines(...) when outline inventory or a valid outline_id is needed.",
1195
+ "- paper_campaign_tool: call artifact.get_analysis_campaign(campaign_id='active') when exact supplementary slice status matters.",
1278
1196
  ]
1279
1197
  )
1280
- else:
1281
- lines.append("- paper_bundle_manifest_present: False")
1282
-
1283
- claims = claim_evidence_map.get("claims") if isinstance(claim_evidence_map.get("claims"), list) else []
1284
- counts = {"supported": 0, "partial": 0, "unsupported": 0, "deferred": 0}
1285
- unresolved: list[str] = []
1286
- for item in claims:
1287
- if not isinstance(item, dict):
1288
- continue
1289
- status = str(item.get("support_status") or "").strip().lower()
1290
- if status in counts:
1291
- counts[status] += 1
1292
- if status in {"partial", "unsupported", "deferred"}:
1293
- claim_id = str(item.get("claim_id") or item.get("claim_text") or "claim").strip()
1294
- unresolved.append(f"{claim_id} [{status}]")
1295
- lines.append(
1296
- "- claim_status_counts: "
1297
- + ", ".join(f"{key}={value}" for key, value in counts.items())
1298
- )
1299
- if unresolved:
1300
- lines.append(f"- downgrade_watchlist: {'; '.join(unresolved[:5])}")
1301
- else:
1302
- lines.append("- downgrade_watchlist: none")
1303
-
1304
- if compile_report:
1305
- lines.append(f"- compile_report_ok: {compile_report.get('ok') if 'ok' in compile_report else 'unknown'}")
1306
- supplementary_baselines = (
1307
- paper_baseline_inventory.get("supplementary_baselines")
1308
- if isinstance(paper_baseline_inventory.get("supplementary_baselines"), list)
1309
- else []
1310
- )
1311
- if paper_baseline_inventory:
1312
- lines.append(f"- paper_supplementary_baseline_count: {len(supplementary_baselines)}")
1313
- if open_source_manifest:
1314
1198
  lines.append(
1315
- f"- open_source_release_branch: {str(open_source_manifest.get('release_branch') or '').strip() or 'none'}"
1199
+ "- paper_contract_rule: if the paper state is blocked, do not stabilize draft prose as if the paper were settled; follow the recommended paper action first."
1316
1200
  )
1317
-
1318
- lines.extend(["", "Recent supporting runs:"])
1319
- recent_runs = snapshot.get("recent_runs") or []
1320
- supporting_runs = [
1321
- item
1322
- for item in recent_runs
1323
- if isinstance(item, dict) and str(item.get("run_id") or "").strip()
1324
- ]
1325
- if supporting_runs:
1326
- for item in supporting_runs[-3:]:
1327
- run_id = str(item.get("run_id") or "run").strip()
1328
- summary = str(item.get("summary") or "").strip() or "No summary provided."
1329
- lines.append(f"- {run_id}: {summary}")
1330
- else:
1331
- lines.append("- none")
1332
-
1333
- lines.append("")
1334
- lines.append(
1335
- "- paper_state_rule: when drafting, reviewing, bundling, or finalizing, treat the selected outline, claim-evidence map, bundle manifest, proofing outputs, and downgrade watchlist as the active writing truth surface."
1336
- )
1337
1201
  return "\n".join(lines)
1338
1202
 
1339
1203
  def _priority_memory_block(
@@ -1346,46 +1210,15 @@ class PromptBuilder:
1346
1210
  ) -> str:
1347
1211
  stage = active_anchor if active_anchor in STAGE_MEMORY_PLAN else skill_id
1348
1212
  plan = STAGE_MEMORY_PLAN.get(stage, STAGE_MEMORY_PLAN["decision"])
1349
- selected: list[dict] = []
1350
- seen_paths: set[str] = set()
1351
-
1352
- for scope in ("quest", "global"):
1353
- for kind in plan.get(scope, ()):
1354
- cards = self.memory_service.list_recent(
1355
- scope=scope,
1356
- quest_root=quest_root if scope == "quest" else None,
1357
- kind=kind,
1358
- limit=2,
1359
- )
1360
- if not cards:
1361
- continue
1362
- self._append_priority_memory(
1363
- selected,
1364
- seen_paths,
1365
- card=cards[-1],
1366
- scope=scope,
1367
- quest_root=quest_root,
1368
- reason=f"recent {stage} {kind} memory",
1369
- )
1370
- if len(selected) >= 6:
1371
- return self._format_priority_memory(selected)
1372
-
1373
- for query in self._memory_queries(user_message):
1374
- matches = self.memory_service.search(query, scope="both", quest_root=quest_root, limit=6)
1375
- for card in matches:
1376
- scope = str(card.get("scope") or "quest")
1377
- self._append_priority_memory(
1378
- selected,
1379
- seen_paths,
1380
- card=card,
1381
- scope=scope,
1382
- quest_root=quest_root,
1383
- reason=f"matches current user message: `{query}`",
1384
- )
1385
- if len(selected) >= 8:
1386
- return self._format_priority_memory(selected)
1387
-
1388
- return self._format_priority_memory(selected)
1213
+ quest_kinds = ", ".join(plan.get("quest", ())) or "none"
1214
+ global_kinds = ", ".join(plan.get("global", ())) or "none"
1215
+ return "\n".join(
1216
+ [
1217
+ f"- stage_memory_rule: for `{stage}`, prefer quest memory kinds [{quest_kinds}] and global memory kinds [{global_kinds}] when memory lookup is needed.",
1218
+ "- memory_lookup_tool: call memory.list_recent(...) to recover context after pause/restart and memory.search(...) before repeating prior work.",
1219
+ "- memory_injection_rule: memory is intentionally not pre-expanded here; pull only the cards that matter now.",
1220
+ ]
1221
+ )
1389
1222
 
1390
1223
  def _append_priority_memory(
1391
1224
  self,
@@ -1447,20 +1280,12 @@ class PromptBuilder:
1447
1280
  return tokens
1448
1281
 
1449
1282
  def _conversation_block(self, quest_id: str, limit: int = 12) -> str:
1450
- records = self.quest_service.history(quest_id, limit=limit)
1451
- if not records:
1452
- return "- none"
1453
- lines = []
1454
- for item in records[-limit:]:
1455
- role = str(item.get("role") or "unknown")
1456
- source = str(item.get("source") or "unknown")
1457
- content = str(item.get("content") or "").strip().replace("\n", " ")
1458
- if len(content) > 400:
1459
- content = content[:397].rstrip() + "..."
1460
- reply_to = str(item.get("reply_to_interaction_id") or "").strip()
1461
- suffix = f" -> reply_to:{reply_to}" if reply_to else ""
1462
- lines.append(f"- [{role}|{source}]{suffix} {content}")
1463
- return "\n".join(lines)
1283
+ return "\n".join(
1284
+ [
1285
+ "- conversation_context_rule: recent conversation is not pre-expanded here.",
1286
+ f"- conversation_tool: call artifact.get_conversation_context(limit={limit}, include_attachments=False) when earlier turn continuity matters.",
1287
+ ]
1288
+ )
1464
1289
 
1465
1290
  def _markdown_body(self, path: Path) -> str:
1466
1291
  text = path.read_text(encoding="utf-8")