@oneciel-ai/ciel-runtime 0.1.1 → 0.2.0

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 (287) hide show
  1. package/README.md +132 -0
  2. package/ciel-runtime-menu.py +56 -6
  3. package/ciel_runtime.py +9379 -35909
  4. package/ciel_runtime_support/advisor_client.py +193 -0
  5. package/ciel_runtime_support/advisor_policy.py +320 -0
  6. package/ciel_runtime_support/advisor_refinement.py +160 -0
  7. package/ciel_runtime_support/advisor_request_builder.py +261 -0
  8. package/ciel_runtime_support/agy_installer.py +169 -0
  9. package/ciel_runtime_support/agy_mcp_restore.py +182 -0
  10. package/ciel_runtime_support/anthropic_model_policy.py +186 -0
  11. package/ciel_runtime_support/anthropic_response_writer.py +255 -0
  12. package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
  13. package/ciel_runtime_support/api_key_cooldown.py +159 -0
  14. package/ciel_runtime_support/architecture.py +488 -1
  15. package/ciel_runtime_support/architecture_budget.py +42 -0
  16. package/ciel_runtime_support/channel_backlog.py +90 -0
  17. package/ciel_runtime_support/channel_cli.py +119 -0
  18. package/ciel_runtime_support/channel_compact_injection.py +82 -0
  19. package/ciel_runtime_support/channel_compact_poll.py +67 -0
  20. package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
  21. package/ciel_runtime_support/channel_config_service.py +281 -0
  22. package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
  23. package/ciel_runtime_support/channel_connection_registry.py +128 -0
  24. package/ciel_runtime_support/channel_connection_worker.py +284 -0
  25. package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
  26. package/ciel_runtime_support/channel_cursor_repository.py +89 -0
  27. package/ciel_runtime_support/channel_cursor_service.py +178 -0
  28. package/ciel_runtime_support/channel_event_identity.py +212 -0
  29. package/ciel_runtime_support/channel_event_projection.py +315 -0
  30. package/ciel_runtime_support/channel_inflight.py +127 -0
  31. package/ciel_runtime_support/channel_injection.py +115 -0
  32. package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
  33. package/ciel_runtime_support/channel_launch_policy.py +180 -0
  34. package/ciel_runtime_support/channel_llm_context.py +156 -0
  35. package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
  36. package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
  37. package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
  38. package/ciel_runtime_support/channel_mcp_tools.py +240 -0
  39. package/ciel_runtime_support/channel_mcp_transport.py +394 -0
  40. package/ciel_runtime_support/channel_message_dedupe.py +65 -0
  41. package/ciel_runtime_support/channel_message_policy.py +256 -0
  42. package/ciel_runtime_support/channel_message_prompt.py +305 -0
  43. package/ciel_runtime_support/channel_message_repository.py +234 -0
  44. package/ciel_runtime_support/channel_notification_projection.py +217 -0
  45. package/ciel_runtime_support/channel_panel.py +162 -0
  46. package/ciel_runtime_support/channel_pending_injection.py +209 -0
  47. package/ciel_runtime_support/channel_pending_poll.py +109 -0
  48. package/ciel_runtime_support/channel_probe_cache.py +433 -0
  49. package/ciel_runtime_support/channel_probe_report.py +101 -0
  50. package/ciel_runtime_support/channel_runtime_environment.py +181 -0
  51. package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
  52. package/ciel_runtime_support/channel_session_repository.py +90 -0
  53. package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
  54. package/ciel_runtime_support/channel_terminal_input.py +277 -0
  55. package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
  56. package/ciel_runtime_support/channel_tool_context.py +166 -0
  57. package/ciel_runtime_support/channel_transcript.py +414 -0
  58. package/ciel_runtime_support/channel_transcript_repository.py +96 -0
  59. package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
  60. package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
  61. package/ciel_runtime_support/chat_files.py +138 -0
  62. package/ciel_runtime_support/chat_http_controller.py +235 -0
  63. package/ciel_runtime_support/claude_environment.py +375 -0
  64. package/ciel_runtime_support/claude_router.py +247 -193
  65. package/ciel_runtime_support/cli_dispatch.py +792 -0
  66. package/ciel_runtime_support/cli_parser.py +165 -0
  67. package/ciel_runtime_support/cli_usage.py +100 -0
  68. package/ciel_runtime_support/codex_app_server.py +20 -5
  69. package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
  70. package/ciel_runtime_support/codex_cli.py +42 -6
  71. package/ciel_runtime_support/codex_config.py +323 -0
  72. package/ciel_runtime_support/codex_launch_configuration.py +240 -0
  73. package/ciel_runtime_support/codex_launch_policy.py +66 -0
  74. package/ciel_runtime_support/codex_mcp_integration.py +195 -0
  75. package/ciel_runtime_support/codex_mcp_restore.py +304 -0
  76. package/ciel_runtime_support/codex_model_catalog.py +133 -0
  77. package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
  78. package/ciel_runtime_support/codex_router.py +147 -1
  79. package/ciel_runtime_support/codex_session_repository.py +115 -0
  80. package/ciel_runtime_support/codex_session_selection.py +114 -0
  81. package/ciel_runtime_support/command_asset_installer.py +103 -0
  82. package/ciel_runtime_support/compatibility_probe.py +295 -0
  83. package/ciel_runtime_support/compatibility_protocol.py +251 -0
  84. package/ciel_runtime_support/compatibility_runtime.py +166 -0
  85. package/ciel_runtime_support/compatibility_test.py +370 -0
  86. package/ciel_runtime_support/config_migrations.py +307 -0
  87. package/ciel_runtime_support/config_repository.py +175 -0
  88. package/ciel_runtime_support/config_value_codec.py +64 -0
  89. package/ciel_runtime_support/configuration_cli.py +374 -0
  90. package/ciel_runtime_support/context_compaction.py +280 -0
  91. package/ciel_runtime_support/context_setup.py +208 -0
  92. package/ciel_runtime_support/context_summary_policy.py +392 -0
  93. package/ciel_runtime_support/credential_cli.py +104 -0
  94. package/ciel_runtime_support/credential_management.py +261 -0
  95. package/ciel_runtime_support/credentials.py +269 -0
  96. package/ciel_runtime_support/executable_discovery.py +141 -0
  97. package/ciel_runtime_support/github_copilot_oauth.py +335 -0
  98. package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
  99. package/ciel_runtime_support/header_forwarding.py +73 -0
  100. package/ciel_runtime_support/headless_config.py +221 -0
  101. package/ciel_runtime_support/http_response.py +129 -0
  102. package/ciel_runtime_support/install_diagnostics.py +149 -0
  103. package/ciel_runtime_support/kimi_identity.py +123 -0
  104. package/ciel_runtime_support/launch_diagnostics.py +204 -0
  105. package/ciel_runtime_support/launch_state.py +127 -0
  106. package/ciel_runtime_support/live_api_key_controller.py +58 -0
  107. package/ciel_runtime_support/llm_config_http.py +148 -0
  108. package/ciel_runtime_support/llm_option_config.py +259 -0
  109. package/ciel_runtime_support/llm_presentation_data.py +447 -0
  110. package/ciel_runtime_support/llm_presets.py +773 -0
  111. package/ciel_runtime_support/lm_studio_runtime.py +401 -0
  112. package/ciel_runtime_support/managed_mcp_config.py +144 -0
  113. package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
  114. package/ciel_runtime_support/managed_service_cleanup.py +89 -0
  115. package/ciel_runtime_support/mcp_config_reader.py +230 -0
  116. package/ciel_runtime_support/mcp_http_proxy.py +607 -0
  117. package/ciel_runtime_support/mcp_inventory.py +59 -0
  118. package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
  119. package/ciel_runtime_support/mcp_probe_codec.py +136 -0
  120. package/ciel_runtime_support/mcp_probe_transport.py +328 -0
  121. package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
  122. package/ciel_runtime_support/mcp_proxy_config.py +107 -0
  123. package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
  124. package/ciel_runtime_support/mcp_proxy_process.py +560 -0
  125. package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
  126. package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
  127. package/ciel_runtime_support/mcp_transport.py +146 -0
  128. package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
  129. package/ciel_runtime_support/model_catalog_projection.py +61 -0
  130. package/ciel_runtime_support/model_context_hints.py +109 -0
  131. package/ciel_runtime_support/model_panel.py +147 -0
  132. package/ciel_runtime_support/model_registry_repository.py +231 -0
  133. package/ciel_runtime_support/npm_runtime.py +191 -0
  134. package/ciel_runtime_support/ollama_catalog.py +462 -0
  135. package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
  136. package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
  137. package/ciel_runtime_support/ollama_context_sync.py +87 -0
  138. package/ciel_runtime_support/ollama_forwarding.py +449 -0
  139. package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
  140. package/ciel_runtime_support/openai_chat_router.py +64 -0
  141. package/ciel_runtime_support/openai_forwarding.py +194 -0
  142. package/ciel_runtime_support/openai_responses_router.py +291 -0
  143. package/ciel_runtime_support/openai_responses_stream.py +135 -0
  144. package/ciel_runtime_support/output_budget.py +89 -0
  145. package/ciel_runtime_support/package_lifecycle.py +217 -0
  146. package/ciel_runtime_support/plan_artifact_controller.py +104 -0
  147. package/ciel_runtime_support/prelaunch.py +959 -0
  148. package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
  149. package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
  150. package/ciel_runtime_support/prelaunch_terminal.py +764 -0
  151. package/ciel_runtime_support/process_control.py +708 -0
  152. package/ciel_runtime_support/prompt_compaction.py +322 -0
  153. package/ciel_runtime_support/prompt_injection.py +176 -0
  154. package/ciel_runtime_support/protocols/__init__.py +24 -0
  155. package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
  156. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
  157. package/ciel_runtime_support/protocols/chat_projection.py +315 -0
  158. package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
  159. package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
  160. package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
  161. package/ciel_runtime_support/protocols/ollama_response.py +231 -0
  162. package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
  163. package/ciel_runtime_support/protocols/openai_responses.py +271 -0
  164. package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
  165. package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
  166. package/ciel_runtime_support/provider_adapters.py +160 -0
  167. package/ciel_runtime_support/provider_catalog_sources.py +316 -0
  168. package/ciel_runtime_support/provider_choice.py +201 -0
  169. package/ciel_runtime_support/provider_compatibility.py +165 -0
  170. package/ciel_runtime_support/provider_config_mutations.py +361 -0
  171. package/ciel_runtime_support/provider_configuration_service.py +162 -0
  172. package/ciel_runtime_support/provider_context.py +308 -0
  173. package/ciel_runtime_support/provider_contract_projection.py +73 -0
  174. package/ciel_runtime_support/provider_descriptor.py +82 -0
  175. package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
  176. package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
  177. package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
  178. package/ciel_runtime_support/provider_limits.py +457 -0
  179. package/ciel_runtime_support/provider_model_identity.py +139 -0
  180. package/ciel_runtime_support/provider_model_selection.py +431 -0
  181. package/ciel_runtime_support/provider_model_specs.py +142 -0
  182. package/ciel_runtime_support/provider_models.py +263 -0
  183. package/ciel_runtime_support/provider_network.py +176 -0
  184. package/ciel_runtime_support/provider_option_cli.py +238 -0
  185. package/ciel_runtime_support/provider_option_panel.py +275 -0
  186. package/ciel_runtime_support/provider_option_status.py +192 -0
  187. package/ciel_runtime_support/provider_policy.py +101 -0
  188. package/ciel_runtime_support/provider_query_policy.py +67 -0
  189. package/ciel_runtime_support/provider_readiness.py +112 -0
  190. package/ciel_runtime_support/provider_request_access.py +131 -0
  191. package/ciel_runtime_support/provider_request_builder.py +250 -0
  192. package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
  193. package/ciel_runtime_support/provider_runtime_info.py +113 -0
  194. package/ciel_runtime_support/provider_runtime_modes.py +150 -0
  195. package/ciel_runtime_support/provider_sampling_policy.py +46 -0
  196. package/ciel_runtime_support/provider_status.py +145 -0
  197. package/ciel_runtime_support/provider_timeout_policy.py +184 -0
  198. package/ciel_runtime_support/provider_tool_policy.py +145 -0
  199. package/ciel_runtime_support/providers/__init__.py +65 -0
  200. package/ciel_runtime_support/providers/anthropic.py +160 -0
  201. package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
  202. package/ciel_runtime_support/providers/base.py +232 -0
  203. package/ciel_runtime_support/providers/catalog.py +326 -0
  204. package/ciel_runtime_support/providers/cloud.py +194 -0
  205. package/ciel_runtime_support/providers/constants.py +54 -0
  206. package/ciel_runtime_support/providers/deepseek.py +115 -0
  207. package/ciel_runtime_support/providers/fireworks.py +158 -0
  208. package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
  209. package/ciel_runtime_support/providers/kimi.py +297 -0
  210. package/ciel_runtime_support/providers/lm_studio.py +76 -0
  211. package/ciel_runtime_support/providers/meta.py +257 -0
  212. package/ciel_runtime_support/providers/native.py +194 -0
  213. package/ciel_runtime_support/providers/nim.py +69 -0
  214. package/ciel_runtime_support/providers/nvidia.py +158 -0
  215. package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
  216. package/ciel_runtime_support/providers/ollama.py +181 -0
  217. package/ciel_runtime_support/providers/ollama_context.py +195 -0
  218. package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
  219. package/ciel_runtime_support/providers/opencode.py +220 -0
  220. package/ciel_runtime_support/providers/opencode_go.py +37 -0
  221. package/ciel_runtime_support/providers/openrouter.py +57 -0
  222. package/ciel_runtime_support/providers/vllm.py +65 -0
  223. package/ciel_runtime_support/providers/zai.py +119 -0
  224. package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
  225. package/ciel_runtime_support/rate_limit_policy.py +117 -0
  226. package/ciel_runtime_support/rate_limit_repository.py +154 -0
  227. package/ciel_runtime_support/registry.py +46 -0
  228. package/ciel_runtime_support/request_shortcuts.py +253 -0
  229. package/ciel_runtime_support/request_trace.py +323 -0
  230. package/ciel_runtime_support/response_collection.py +209 -0
  231. package/ciel_runtime_support/router_access.py +238 -0
  232. package/ciel_runtime_support/router_client_lifecycle.py +366 -0
  233. package/ciel_runtime_support/router_health_policy.py +101 -0
  234. package/ciel_runtime_support/router_http.py +513 -0
  235. package/ciel_runtime_support/router_process_lifecycle.py +401 -0
  236. package/ciel_runtime_support/router_rate_limit_service.py +285 -0
  237. package/ciel_runtime_support/router_server_runtime.py +103 -0
  238. package/ciel_runtime_support/router_shortcuts.py +201 -0
  239. package/ciel_runtime_support/routing_fallback.py +73 -0
  240. package/ciel_runtime_support/runtime_activity_repository.py +143 -0
  241. package/ciel_runtime_support/runtime_adapters.py +104 -0
  242. package/ciel_runtime_support/runtime_command_factory.py +73 -0
  243. package/ciel_runtime_support/runtime_compatibility.py +50 -0
  244. package/ciel_runtime_support/runtime_constants.py +178 -0
  245. package/ciel_runtime_support/runtime_launch.py +1602 -0
  246. package/ciel_runtime_support/runtime_llm_options.py +312 -0
  247. package/ciel_runtime_support/runtime_logging.py +161 -0
  248. package/ciel_runtime_support/runtime_paths.py +157 -0
  249. package/ciel_runtime_support/runtime_restart.py +84 -0
  250. package/ciel_runtime_support/runtime_upgrade.py +149 -0
  251. package/ciel_runtime_support/secure_json_repository.py +55 -0
  252. package/ciel_runtime_support/session_import.py +356 -0
  253. package/ciel_runtime_support/settings_repository.py +8 -0
  254. package/ciel_runtime_support/slash_command_assets.py +211 -0
  255. package/ciel_runtime_support/sse_stream.py +57 -0
  256. package/ciel_runtime_support/sse_trace.py +225 -0
  257. package/ciel_runtime_support/statusline_script.py +593 -0
  258. package/ciel_runtime_support/statusline_settings.py +53 -0
  259. package/ciel_runtime_support/stream_chunk_policy.py +18 -0
  260. package/ciel_runtime_support/streaming_anthropic.py +1955 -0
  261. package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
  262. package/ciel_runtime_support/terminal_platform_io.py +127 -0
  263. package/ciel_runtime_support/timeout_profile.py +196 -0
  264. package/ciel_runtime_support/tool_dialects.py +85 -0
  265. package/ciel_runtime_support/tool_exposure_policy.py +63 -0
  266. package/ciel_runtime_support/tool_guard_hooks.py +218 -0
  267. package/ciel_runtime_support/tool_request_projection.py +96 -0
  268. package/ciel_runtime_support/tool_schema.py +483 -0
  269. package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
  270. package/ciel_runtime_support/ui_text.py +266 -0
  271. package/ciel_runtime_support/upstream_error_policy.py +104 -0
  272. package/ciel_runtime_support/upstream_retry.py +419 -0
  273. package/ciel_runtime_support/upstream_stream_io.py +106 -0
  274. package/ciel_runtime_support/usage_events.py +96 -0
  275. package/ciel_runtime_support/visible_stream_filters.py +130 -0
  276. package/ciel_runtime_support/web_endpoints.py +447 -0
  277. package/ciel_runtime_support/web_ui.py +915 -0
  278. package/ciel_runtime_support/web_ui_controller.py +189 -0
  279. package/ciel_runtime_support/windows_console_input.py +137 -0
  280. package/ciel_runtime_support/windows_console_mode.py +112 -0
  281. package/docs/Architecture.md +54 -0
  282. package/docs/Configuration.md +17 -0
  283. package/docs/Module-Map.md +1093 -26
  284. package/docs/Providers.md +46 -1
  285. package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
  286. package/npm-bin/run-ciel-runtime.js +22 -2
  287. package/package.json +9 -2
@@ -0,0 +1,972 @@
1
+ """Plan-mode and TaskList conversation-turn state machine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Any, Callable
8
+
9
+ CHANNEL_LLM_WAKE_PREFIX = "[external input pending]"
10
+ CHANNEL_LLM_WAKE_LEGACY_PREFIXES = ("[ciel-runtime channel wake]", "[channel pending]")
11
+ PLAN_GUARD_MARKER = "[ciel-runtime-plan-guard]"
12
+ SYSTEM_REMINDER_BLOCK_RE = re.compile(
13
+ r"<system-reminder>.*?</system-reminder>", re.DOTALL
14
+ )
15
+ CLAUDE_CODE_SUGGESTION_MODE_PREFIX = "[SUGGESTION MODE:"
16
+ WORK_CONTINUATION_RESULT_TOOLS = frozenset(
17
+ {
18
+ "Bash",
19
+ "Glob",
20
+ "Grep",
21
+ "LS",
22
+ "Read",
23
+ "Write",
24
+ "Edit",
25
+ "MultiEdit",
26
+ "TaskCreate",
27
+ "TaskList",
28
+ "TaskUpdate",
29
+ "TaskStop",
30
+ "ExitPlanMode",
31
+ }
32
+ )
33
+ WORK_COMPLETION_RESULT_TOOLS = frozenset(
34
+ {"Write", "Edit", "MultiEdit", "TaskUpdate", "TaskStop"}
35
+ )
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class ConversationTurnPorts:
40
+ content_blocks: Callable[[dict[str, Any]], list[Any]]
41
+ lookup_tool_schema: Callable[[str], dict[str, Any] | None]
42
+ tool_schema: Callable[[dict[str, Any], str], dict[str, Any] | None]
43
+ log: Callable[[str, str], None]
44
+ has_tool: Callable[[dict[str, Any], str], bool]
45
+ ultracode_preferred: Callable[[dict[str, Any]], bool]
46
+ content_to_text: Callable[[Any], str]
47
+
48
+
49
+ class ConversationTurnPolicy:
50
+ def __init__(self, ports: ConversationTurnPorts) -> None:
51
+ self.ports = ports
52
+
53
+ def plan_mode_active(self, body: dict[str, Any]) -> bool:
54
+ """Infer Claude Code Plan Mode from tool history and plan-mode attachments."""
55
+ active = False
56
+ tool_names_by_id: dict[str, str] = {}
57
+ for message in body.get("messages") or []:
58
+ if not isinstance(message, dict):
59
+ continue
60
+ attachment = message.get("attachment")
61
+ if isinstance(attachment, dict):
62
+ attachment_type = attachment.get("type")
63
+ if attachment_type in {"plan_mode", "plan_mode_reentry"}:
64
+ active = True
65
+ elif attachment_type == "plan_mode_exit":
66
+ active = False
67
+ if message.get("role") == "assistant":
68
+ for block in self.ports.content_blocks(message):
69
+ if not isinstance(block, dict) or block.get("type") != "tool_use":
70
+ continue
71
+ tool_id = str(block.get("id") or "")
72
+ name = str(block.get("name") or "")
73
+ if tool_id and name:
74
+ tool_names_by_id[tool_id] = name
75
+ elif message.get("role") == "user":
76
+ for block in self.ports.content_blocks(message):
77
+ if not isinstance(block, dict):
78
+ continue
79
+ if block.get("type") == "tool_result":
80
+ tool_use_id = str(block.get("tool_use_id") or "")
81
+ tool_name = tool_names_by_id.get(tool_use_id)
82
+ if tool_name == "EnterPlanMode":
83
+ active = True
84
+ elif tool_name == "ExitPlanMode":
85
+ active = False
86
+ elif block.get("type") in {"plan_mode", "plan_mode_reentry"}:
87
+ active = True
88
+ elif block.get("type") == "plan_mode_exit":
89
+ active = False
90
+ return active
91
+
92
+ def channel_llm_wake_text(self, text: str) -> bool:
93
+ text = re.sub(r"^[\x00-\x1f\x7f\s]+", "", text)
94
+ return text.startswith(CHANNEL_LLM_WAKE_PREFIX) or any(
95
+ text.startswith(prefix) for prefix in CHANNEL_LLM_WAKE_LEGACY_PREFIXES
96
+ )
97
+
98
+ def channel_llm_wake_request(self, body: dict[str, Any]) -> bool:
99
+ return self.channel_llm_wake_text(self.latest_user_text(body))
100
+
101
+ def body_without_channel_llm_wake_prompt(
102
+ self, body: dict[str, Any]
103
+ ) -> dict[str, Any]:
104
+ if not self.channel_llm_wake_request(body):
105
+ return body
106
+ messages = [m for m in body.get("messages", []) if isinstance(m, dict)]
107
+ removed = False
108
+ for index in range(len(messages) - 1, -1, -1):
109
+ if self.channel_llm_wake_text(
110
+ self.user_intent_text_from_message(messages[index])
111
+ ):
112
+ del messages[index]
113
+ removed = True
114
+ break
115
+ if not removed:
116
+ return body
117
+ out = dict(body)
118
+ out["messages"] = messages
119
+ self.ports.log("INFO", "channel_llm_wake_prompt_stripped")
120
+ return out
121
+
122
+ def has_plan_mode_exit(self, body: dict[str, Any]) -> bool:
123
+ for message in body.get("messages") or []:
124
+ if not isinstance(message, dict):
125
+ continue
126
+ attachment = message.get("attachment")
127
+ if (
128
+ isinstance(attachment, dict)
129
+ and attachment.get("type") == "plan_mode_exit"
130
+ ):
131
+ return True
132
+ if message.get("role") != "assistant":
133
+ continue
134
+ for block in self.ports.content_blocks(message):
135
+ if (
136
+ isinstance(block, dict)
137
+ and block.get("type") == "tool_use"
138
+ and block.get("name") == "ExitPlanMode"
139
+ ):
140
+ return True
141
+ return False
142
+
143
+ def allowed_prompt_tools_for_exit_plan_mode(
144
+ self, body: dict[str, Any]
145
+ ) -> list[str]:
146
+ schema = self.ports.tool_schema(
147
+ body, "ExitPlanMode"
148
+ ) or self.ports.lookup_tool_schema("ExitPlanMode")
149
+ if not isinstance(schema, dict):
150
+ return []
151
+ properties = (
152
+ schema.get("properties")
153
+ if isinstance(schema.get("properties"), dict)
154
+ else {}
155
+ )
156
+ allowed_schema = (
157
+ properties.get("allowedPrompts")
158
+ if isinstance(properties.get("allowedPrompts"), dict)
159
+ else None
160
+ )
161
+ if not allowed_schema:
162
+ return []
163
+ items = (
164
+ allowed_schema.get("items")
165
+ if isinstance(allowed_schema.get("items"), dict)
166
+ else {}
167
+ )
168
+ item_properties = (
169
+ items.get("properties") if isinstance(items.get("properties"), dict) else {}
170
+ )
171
+ tool_schema = (
172
+ item_properties.get("tool")
173
+ if isinstance(item_properties.get("tool"), dict)
174
+ else {}
175
+ )
176
+ enum_values = tool_schema.get("enum")
177
+ if not isinstance(enum_values, list):
178
+ return []
179
+ return [
180
+ str(item) for item in enum_values if isinstance(item, str) and item.strip()
181
+ ]
182
+
183
+ def exit_plan_mode_default_prompt_for_tool(self, tool_name: str) -> str:
184
+ return f"use {tool_name} as needed to implement and verify the approved plan"
185
+
186
+ def backfill_exit_plan_mode_allowed_prompts(
187
+ self, body: dict[str, Any], tool_input: dict[str, Any]
188
+ ) -> dict[str, Any]:
189
+ existing = (
190
+ tool_input.get("allowedPrompts") if isinstance(tool_input, dict) else None
191
+ )
192
+ if isinstance(existing, list) and any(
193
+ isinstance(item, dict) for item in existing
194
+ ):
195
+ return tool_input
196
+ allowed_tools = self.allowed_prompt_tools_for_exit_plan_mode(body)
197
+ if not allowed_tools:
198
+ return tool_input
199
+ out = dict(tool_input)
200
+ out["allowedPrompts"] = [
201
+ {
202
+ "tool": tool_name,
203
+ "prompt": self.exit_plan_mode_default_prompt_for_tool(tool_name),
204
+ }
205
+ for tool_name in allowed_tools
206
+ ]
207
+ self.ports.log(
208
+ "INFO",
209
+ f"backfilled ExitPlanMode allowedPrompts tools={','.join(allowed_tools)}",
210
+ )
211
+ return out
212
+
213
+ def plan_mode_tool_name_for_emit(
214
+ self, body: dict[str, Any], name: str, tool_input: dict[str, Any]
215
+ ) -> tuple[str | None, dict[str, Any]]:
216
+ active = self.plan_mode_active(body)
217
+ if name == "EnterPlanMode" and self.body_is_channel_prompt(body):
218
+ self.ports.log("WARN", "dropped EnterPlanMode for external channel prompt")
219
+ return None, tool_input
220
+ if (
221
+ name == "EnterPlanMode"
222
+ and "ExitPlanMode" in self.latest_user_tool_result_names(body)
223
+ ):
224
+ self.ports.log(
225
+ "WARN", "dropped EnterPlanMode immediately after ExitPlanMode result"
226
+ )
227
+ return None, tool_input
228
+ if name == "EnterPlanMode" and active:
229
+ self.ports.log(
230
+ "WARN", "dropped repeated EnterPlanMode while plan mode is active"
231
+ )
232
+ return None, tool_input
233
+ if name == "EnterPlanMode" and self.ports.ultracode_preferred(body):
234
+ self.ports.log(
235
+ "WARN", "dropped EnterPlanMode because ultracode workflow is preferred"
236
+ )
237
+ return None, tool_input
238
+ if name == "ExitPlanMode" and not active:
239
+ self.ports.log("WARN", "dropped ExitPlanMode while plan mode is not active")
240
+ return None, tool_input
241
+ if name == "ExitPlanMode":
242
+ tool_input = self.backfill_exit_plan_mode_allowed_prompts(body, tool_input)
243
+ return name, tool_input
244
+
245
+ def is_guard_feedback_text(self, text: str) -> bool:
246
+ stripped = (text or "").strip()
247
+ return (
248
+ stripped.startswith("Stop hook feedback:")
249
+ or stripped.startswith("Ciel Runtime plan guard:")
250
+ or PLAN_GUARD_MARKER in stripped
251
+ )
252
+
253
+ SYSTEM_REMINDER_BLOCK_RE = re.compile(
254
+ r"<system-reminder>.*?</system-reminder>", re.DOTALL
255
+ )
256
+ CLAUDE_CODE_SUGGESTION_MODE_PREFIX = "[SUGGESTION MODE:"
257
+
258
+ def strip_claude_code_system_reminders(self, text: str) -> str:
259
+ return SYSTEM_REMINDER_BLOCK_RE.sub("", text or "").strip()
260
+
261
+ def is_claude_code_suggestion_mode_text(self, text: str) -> bool:
262
+ return (
263
+ self.strip_claude_code_system_reminders(text)
264
+ .lstrip()
265
+ .startswith(CLAUDE_CODE_SUGGESTION_MODE_PREFIX)
266
+ )
267
+
268
+ def user_intent_text_from_message(self, message: dict[str, Any]) -> str:
269
+ if not isinstance(message, dict) or message.get("role") != "user":
270
+ return ""
271
+ if message.get("isMeta") is True:
272
+ return ""
273
+ content = message.get("content")
274
+ if isinstance(content, str):
275
+ text = self.strip_claude_code_system_reminders(content)
276
+ return "" if self.is_guard_feedback_text(text) else text
277
+ if not isinstance(content, list):
278
+ # Claude Code can inject user-role attachment records such as
279
+ # plan_mode_exit. They are state metadata, not new user intent.
280
+ return ""
281
+ # Claude Code sends tool_result blocks as user-role messages. Those are not
282
+ # user intent. System reminders can also arrive as text blocks adjacent to
283
+ # the real prompt, so remove them before classifying resume prompts.
284
+ parts: list[str] = []
285
+ for block in content:
286
+ if isinstance(block, str):
287
+ text = self.strip_claude_code_system_reminders(block)
288
+ elif isinstance(block, dict) and block.get("type") == "text":
289
+ text = self.strip_claude_code_system_reminders(
290
+ str(block.get("text", ""))
291
+ )
292
+ else:
293
+ continue
294
+ if text and not self.is_guard_feedback_text(text):
295
+ parts.append(text)
296
+ return "\n".join(parts).strip()
297
+
298
+ def latest_user_text(self, body: dict[str, Any]) -> str:
299
+ for message in reversed(body.get("messages") or []):
300
+ text = (
301
+ self.user_intent_text_from_message(message)
302
+ if isinstance(message, dict)
303
+ else ""
304
+ )
305
+ if not text:
306
+ continue
307
+ return text
308
+ return ""
309
+
310
+ def latest_user_intent_message_index(self, body: dict[str, Any]) -> int | None:
311
+ messages = body.get("messages") or []
312
+ for index in range(len(messages) - 1, -1, -1):
313
+ message = messages[index]
314
+ if isinstance(message, dict) and self.user_intent_text_from_message(
315
+ message
316
+ ):
317
+ return index
318
+ return None
319
+
320
+ def latest_user_is_claude_code_suggestion_mode(self, body: dict[str, Any]) -> bool:
321
+ latest = self.latest_user_text(body)
322
+ return bool(latest and self.is_claude_code_suggestion_mode_text(latest))
323
+
324
+ def likely_implementation_planning_request(self, text: str) -> bool:
325
+ normalized = re.sub(r"\s+", " ", text or "").strip()
326
+ if len(normalized) >= 120:
327
+ return True
328
+ # Multi-line prompts usually carry enough task structure that a one-line
329
+ # "I'll make a plan" style response is not a useful final answer.
330
+ non_empty_lines = [line for line in (text or "").splitlines() if line.strip()]
331
+ if len(non_empty_lines) >= 3 and len(normalized) >= 80:
332
+ return True
333
+ return False
334
+
335
+ def non_actionable_short_response(self, text: str) -> bool:
336
+ normalized = re.sub(r"\s+", " ", text or "").strip()
337
+ if not normalized:
338
+ return True
339
+ # Language-agnostic: for a long implementation request, a short single-line
340
+ # text response with no tool call is not actionable. Do not inspect words.
341
+ if len(normalized) <= 80 and "\n" not in (text or ""):
342
+ return True
343
+ if (
344
+ len(normalized) <= 160
345
+ and "\n" not in (text or "")
346
+ and not re.search(r"[`{};/\\\\]|https?://", normalized)
347
+ ):
348
+ return True
349
+ return False
350
+
351
+ def body_is_channel_prompt(self, body: dict[str, Any]) -> bool:
352
+ metadata = (
353
+ body.get("metadata") if isinstance(body.get("metadata"), dict) else {}
354
+ )
355
+ latest_text = self.latest_user_text(body)
356
+ return bool(
357
+ metadata.get("ciel_runtime_channel_injected")
358
+ or latest_text.startswith("[external channel input]")
359
+ or latest_text.startswith("[ciel-runtime channel inbox]")
360
+ or latest_text.startswith(CHANNEL_LLM_WAKE_PREFIX)
361
+ or any(
362
+ latest_text.startswith(prefix)
363
+ for prefix in CHANNEL_LLM_WAKE_LEGACY_PREFIXES
364
+ )
365
+ or latest_text.startswith("[ciel-runtime external channel message")
366
+ )
367
+
368
+ def should_auto_enter_plan_mode(
369
+ self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
370
+ ) -> bool:
371
+ if tool_calls:
372
+ return False
373
+ if self.body_is_channel_prompt(body):
374
+ return False
375
+ if self.ports.ultracode_preferred(body):
376
+ return False
377
+ if not self.ports.has_tool(body, "EnterPlanMode"):
378
+ return False
379
+ if self.plan_mode_active(body):
380
+ return False
381
+ if self.has_plan_mode_exit(body):
382
+ return False
383
+ if self.latest_tool_result_indicates_completed_work(body):
384
+ return False
385
+ if not self.non_actionable_short_response(response_text):
386
+ return False
387
+ return self.likely_implementation_planning_request(self.latest_user_text(body))
388
+
389
+ def response_text_signals_plan_exit(self, text: str) -> bool:
390
+ lowered = str(text or "").lower()
391
+ if "exitplanmode" in lowered:
392
+ return True
393
+ mentions_plan_mode = (
394
+ "plan mode" in lowered
395
+ or "plan-mode" in lowered
396
+ or "플랜모드" in lowered
397
+ or "플랜 모드" in lowered
398
+ )
399
+ if not mentions_plan_mode:
400
+ return False
401
+ return any(
402
+ token in lowered
403
+ for token in ("exit", "leave", "exiting", "leaving", "종료", "탈출", "나가")
404
+ )
405
+
406
+ def should_auto_exit_plan_mode(
407
+ self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
408
+ ) -> bool:
409
+ if tool_calls:
410
+ return False
411
+ if not self.plan_mode_active(body):
412
+ return False
413
+ if not self.ports.has_tool(body, "ExitPlanMode"):
414
+ return False
415
+ if not self.response_text_signals_plan_exit(response_text):
416
+ return False
417
+ return True
418
+
419
+ WORK_CONTINUATION_RESULT_TOOLS: frozenset[str] = frozenset(
420
+ {
421
+ "Bash",
422
+ "Glob",
423
+ "Grep",
424
+ "LS",
425
+ "Read",
426
+ "Write",
427
+ "Edit",
428
+ "MultiEdit",
429
+ "TaskCreate",
430
+ "TaskList",
431
+ "TaskUpdate",
432
+ "TaskStop",
433
+ "ExitPlanMode",
434
+ }
435
+ )
436
+
437
+ WORK_COMPLETION_RESULT_TOOLS: frozenset[str] = frozenset(
438
+ {
439
+ "Write",
440
+ "Edit",
441
+ "MultiEdit",
442
+ "TaskUpdate",
443
+ "TaskStop",
444
+ }
445
+ )
446
+
447
+ def bash_command_looks_mutating(self, command: str) -> bool:
448
+ normalized = re.sub(r"\s+", " ", command or "").strip()
449
+ if not normalized:
450
+ return False
451
+ return bool(
452
+ re.search(
453
+ r"(^|[;&|]\s*|\b)(rm|rmdir|mv|cp|mkdir|touch|chmod|chown|ln|install|git\s+(commit|push|pull|merge|rebase|checkout|switch|restore|reset|clean)|npm\s+(install|update|run|publish)|pnpm\s+(install|update|run|publish)|yarn\s+(install|add|run|publish)|python\d*\s+-m\s+pip\s+install|pip\d*\s+install|docker\s+(run|compose|build|up|down|rm|rmi)|kubectl\s+(apply|delete|create|replace|patch))\b",
454
+ normalized,
455
+ )
456
+ )
457
+
458
+ def latest_user_tool_result_details(
459
+ self, body: dict[str, Any]
460
+ ) -> list[dict[str, Any]]:
461
+ tools_by_id: dict[str, tuple[str, dict[str, Any]]] = {}
462
+ latest: list[dict[str, Any]] = []
463
+ for message in body.get("messages") or []:
464
+ if not isinstance(message, dict):
465
+ continue
466
+ content = message.get("content")
467
+ if message.get("role") == "assistant" and isinstance(content, list):
468
+ for block in content:
469
+ if not isinstance(block, dict) or block.get("type") != "tool_use":
470
+ continue
471
+ tool_id = str(block.get("id") or "")
472
+ name = str(block.get("name") or "")
473
+ tool_input = (
474
+ block.get("input")
475
+ if isinstance(block.get("input"), dict)
476
+ else {}
477
+ )
478
+ if tool_id and name:
479
+ tools_by_id[tool_id] = (name, tool_input)
480
+ elif message.get("role") == "user" and isinstance(content, list):
481
+ current: list[dict[str, Any]] = []
482
+ for block in content:
483
+ if (
484
+ not isinstance(block, dict)
485
+ or block.get("type") != "tool_result"
486
+ ):
487
+ continue
488
+ tool_use_id = str(block.get("tool_use_id") or "")
489
+ name, tool_input = tools_by_id.get(tool_use_id, ("tool", {}))
490
+ current.append(
491
+ {
492
+ "name": name,
493
+ "input": tool_input,
494
+ "text": self.ports.content_to_text(
495
+ block.get("content", "")
496
+ ),
497
+ "is_error": bool(block.get("is_error")),
498
+ }
499
+ )
500
+ if current:
501
+ latest = current
502
+ return latest
503
+
504
+ def latest_tool_result_indicates_completed_work(self, body: dict[str, Any]) -> bool:
505
+ details = self.latest_user_tool_result_details(body)
506
+ if not details:
507
+ return False
508
+ for item in details:
509
+ if item.get("is_error"):
510
+ continue
511
+ name = str(item.get("name") or "")
512
+ tool_input = (
513
+ item.get("input") if isinstance(item.get("input"), dict) else {}
514
+ )
515
+ if name in WORK_COMPLETION_RESULT_TOOLS:
516
+ return True
517
+ if name == "Bash" and self.bash_command_looks_mutating(
518
+ str(tool_input.get("command") or "")
519
+ ):
520
+ return True
521
+ return False
522
+
523
+ def latest_user_tool_result_names(self, body: dict[str, Any]) -> list[str]:
524
+ tool_names_by_id: dict[str, str] = {}
525
+ latest: list[str] = []
526
+ for message in body.get("messages") or []:
527
+ if not isinstance(message, dict):
528
+ continue
529
+ content = message.get("content")
530
+ if message.get("role") == "assistant" and isinstance(content, list):
531
+ for block in content:
532
+ if not isinstance(block, dict) or block.get("type") != "tool_use":
533
+ continue
534
+ tool_id = str(block.get("id") or "")
535
+ name = str(block.get("name") or "")
536
+ if tool_id and name:
537
+ tool_names_by_id[tool_id] = name
538
+ elif message.get("role") == "user" and isinstance(content, list):
539
+ current: list[str] = []
540
+ for block in content:
541
+ if (
542
+ not isinstance(block, dict)
543
+ or block.get("type") != "tool_result"
544
+ ):
545
+ continue
546
+ tool_use_id = str(block.get("tool_use_id") or "")
547
+ if tool_use_id:
548
+ current.append(tool_names_by_id.get(tool_use_id, "tool"))
549
+ if current:
550
+ latest = current
551
+ return latest
552
+
553
+ def latest_user_tool_result_text(self, body: dict[str, Any]) -> str:
554
+ latest = ""
555
+ for message in body.get("messages") or []:
556
+ if not isinstance(message, dict):
557
+ continue
558
+ if message.get("role") != "user" or not isinstance(
559
+ message.get("content"), list
560
+ ):
561
+ continue
562
+ parts: list[str] = []
563
+ for block in message.get("content") or []:
564
+ if not isinstance(block, dict) or block.get("type") != "tool_result":
565
+ continue
566
+ parts.append(self.ports.content_to_text(block.get("content", "")))
567
+ if parts:
568
+ latest = "\n".join(part for part in parts if part)
569
+ return latest
570
+
571
+ def synthetic_tasklist_tool_use_id(self, tool_id: str, name: str) -> bool:
572
+ if name != "TaskList":
573
+ return False
574
+ prefixes = (
575
+ "toolu_ollama_keepalive_",
576
+ "toolu_ollama_choice_",
577
+ "toolu_openai_keepalive_",
578
+ "toolu_openai_choice_",
579
+ "toolu_anthropic_choice_",
580
+ "toolu_ciel_runtime_TaskList_",
581
+ )
582
+ return any(tool_id.startswith(prefix) for prefix in prefixes)
583
+
584
+ def recent_synthetic_tasklist_count(
585
+ self, body: dict[str, Any], after_message_index: int | None = None
586
+ ) -> int:
587
+ count = 0
588
+ messages = body.get("messages") or []
589
+ if after_message_index is not None:
590
+ messages = messages[after_message_index + 1 :]
591
+ for message in reversed(messages):
592
+ if not isinstance(message, dict):
593
+ continue
594
+ if (
595
+ after_message_index is None
596
+ and message.get("role") == "user"
597
+ and self.user_intent_text_from_message(message)
598
+ ):
599
+ break
600
+ content = message.get("content")
601
+ if message.get("role") != "assistant" or not isinstance(content, list):
602
+ continue
603
+ found_keepalive = False
604
+ for block in content:
605
+ if not isinstance(block, dict) or block.get("type") != "tool_use":
606
+ continue
607
+ if self.synthetic_tasklist_tool_use_id(
608
+ str(block.get("id") or ""), str(block.get("name") or "")
609
+ ):
610
+ found_keepalive = True
611
+ if found_keepalive:
612
+ count += 1
613
+ return count
614
+
615
+ def tasklist_result_has_active_work(self, text: str) -> bool:
616
+ normalized = re.sub(r"\s+", " ", text or "").strip().lower()
617
+ if not normalized:
618
+ return False
619
+ # This parses Claude Code's task-list tool output, not user-facing prose.
620
+ if re.search(r"\[\s*(in progress|open|pending)\s*\]", normalized):
621
+ return True
622
+ for label in ("in progress", "open", "pending"):
623
+ for match in re.finditer(rf"\b(\d+)\s+{re.escape(label)}\b", normalized):
624
+ if int(match.group(1)) > 0:
625
+ return True
626
+ return False
627
+
628
+ def latest_tasklist_result_has_no_active_work(self, body: dict[str, Any]) -> bool:
629
+ latest_names = self.latest_user_tool_result_names(body)
630
+ if "TaskList" not in latest_names:
631
+ return False
632
+ return not self.tasklist_result_has_active_work(
633
+ self.latest_user_tool_result_text(body)
634
+ )
635
+
636
+ def latest_assistant_text(self, body: dict[str, Any]) -> str:
637
+ for message in reversed(body.get("messages") or []):
638
+ if not isinstance(message, dict) or message.get("role") != "assistant":
639
+ continue
640
+ return self.ports.content_to_text(message.get("content"))
641
+ return ""
642
+
643
+ def short_resume_prompt(self, text: str) -> bool:
644
+ normalized = re.sub(r"\s+", " ", text or "").strip()
645
+ if not normalized:
646
+ return False
647
+ if len(normalized) > 32:
648
+ return False
649
+ # Language-agnostic: a very short imperative with no question or code-like
650
+ # syntax after an unfinished assistant turn is a request to proceed.
651
+ return not re.search(r"[??`{};/\\\\]|https?://", normalized)
652
+
653
+ def latest_user_looks_like_work_request(self, body: dict[str, Any]) -> bool:
654
+ latest = self.latest_user_text(body)
655
+ normalized = re.sub(r"\s+", " ", latest or "").strip()
656
+ if not normalized:
657
+ return False
658
+ if self.likely_implementation_planning_request(latest):
659
+ return True
660
+ if self.short_resume_prompt(latest) and self.latest_assistant_text(body):
661
+ return True
662
+ if self.short_resume_prompt(latest) and self.latest_user_tool_result_names(
663
+ body
664
+ ):
665
+ return True
666
+ return False
667
+
668
+ def response_asks_for_user_choice_or_permission(self, text: str) -> bool:
669
+ normalized = re.sub(r"\s+", " ", text or "").strip()
670
+ if not normalized:
671
+ return False
672
+ if "?" not in normalized and "?" not in normalized:
673
+ return False
674
+ # Structural guard: do not inspect language-specific words. A question-only
675
+ # end_turn inside Plan Mode is a pause, not progress.
676
+ return len(normalized) <= 1200
677
+
678
+ def should_auto_continue_choice_question_with_tasklist(
679
+ self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
680
+ ) -> bool:
681
+ if tool_calls:
682
+ return False
683
+ if self.latest_user_is_claude_code_suggestion_mode(body):
684
+ return False
685
+ if not self.ports.has_tool(body, "TaskList"):
686
+ return False
687
+ latest_names = self.latest_user_tool_result_names(body)
688
+ if self.latest_tool_result_indicates_completed_work(body):
689
+ return False
690
+ if "TaskList" in latest_names and not self.tasklist_result_has_active_work(
691
+ self.latest_user_tool_result_text(body)
692
+ ):
693
+ return False
694
+ intent_index = self.latest_user_intent_message_index(body)
695
+ if (
696
+ self.recent_synthetic_tasklist_count(body, after_message_index=intent_index)
697
+ >= 2
698
+ ):
699
+ return False
700
+ if not self.response_asks_for_user_choice_or_permission(response_text):
701
+ return False
702
+ if self.plan_mode_active(body):
703
+ return True
704
+ return bool(
705
+ self.latest_user_tool_result_names(body)
706
+ and self.latest_user_looks_like_work_request(body)
707
+ )
708
+
709
+ def should_synthesize_tasklist_for_provider(self, provider: str) -> bool:
710
+ # TaskList synthesis is a recovery path for non-Anthropic backends that
711
+ # return empty or prose-only turns. Anthropic routed mode can emit native
712
+ # tool_use blocks, so synthesizing an extra TaskList there corrupts the turn.
713
+ return (provider or "").strip().lower() != "anthropic"
714
+
715
+ def should_keep_work_alive_with_tasklist(
716
+ self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
717
+ ) -> bool:
718
+ if tool_calls:
719
+ return False
720
+ if self.latest_user_is_claude_code_suggestion_mode(body):
721
+ return False
722
+ if not self.ports.has_tool(body, "TaskList"):
723
+ return False
724
+ latest_names = self.latest_user_tool_result_names(body)
725
+ if not latest_names:
726
+ return False
727
+ latest_result_text = self.latest_user_tool_result_text(body)
728
+ if latest_names == ["TaskList"] and "No tasks found" in latest_result_text:
729
+ return False
730
+ if "TaskList" in latest_names:
731
+ if not self.tasklist_result_has_active_work(latest_result_text):
732
+ return False
733
+ max_keepalive = 6
734
+ intent_index = self.latest_user_intent_message_index(body)
735
+ if (
736
+ self.recent_synthetic_tasklist_count(
737
+ body, after_message_index=intent_index
738
+ )
739
+ >= max_keepalive
740
+ ):
741
+ return False
742
+ if not any(name in WORK_CONTINUATION_RESULT_TOOLS for name in latest_names):
743
+ return False
744
+ if (
745
+ self.latest_tool_result_indicates_completed_work(body)
746
+ and response_text.strip()
747
+ ):
748
+ return False
749
+ if self.non_actionable_short_response(response_text):
750
+ return True
751
+ normalized = re.sub(r"\s+", " ", response_text or "").strip()
752
+ # A resume/continue prompt after a tool result should not end the loop with
753
+ # prose only. Keep this structural and bounded: do not inspect task names,
754
+ # domains, languages, or provider-specific text.
755
+ return (
756
+ self.latest_user_looks_like_work_request(body) and len(normalized) <= 1200
757
+ )
758
+
759
+ def should_recover_empty_end_turn_with_tasklist(
760
+ self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
761
+ ) -> bool:
762
+ """Recover from non-Anthropic providers returning an empty end_turn.
763
+
764
+ Claude Code treats an assistant end_turn with no text and no tool call as a
765
+ completed turn. For implementation/resume prompts, ask Claude Code for the
766
+ task list to give the model a concrete next tool result and keep the loop
767
+ alive.
768
+ """
769
+ if tool_calls:
770
+ return False
771
+ if self.latest_user_is_claude_code_suggestion_mode(body):
772
+ return False
773
+ if response_text.strip():
774
+ return False
775
+ if not self.ports.has_tool(body, "TaskList"):
776
+ return False
777
+ latest_tool_results = self.latest_user_tool_result_names(body)
778
+ if latest_tool_results:
779
+ if (
780
+ "TaskList" in latest_tool_results
781
+ and not self.tasklist_result_has_active_work(
782
+ self.latest_user_tool_result_text(body)
783
+ )
784
+ ):
785
+ return False
786
+ return True
787
+ latest = self.latest_user_text(body)
788
+ if not latest.strip():
789
+ return False
790
+ if self.short_resume_prompt(latest) and (
791
+ self.latest_assistant_text(body) or self.plan_mode_active(body)
792
+ ):
793
+ return True
794
+ return self.likely_implementation_planning_request(latest)
795
+
796
+ def empty_end_turn_notice(
797
+ self,
798
+ ) -> str:
799
+ return (
800
+ "[ciel-runtime] Upstream model returned an empty end_turn with no text or "
801
+ "tool call. No work was performed; please retry or ask me to continue."
802
+ )
803
+
804
+ def empty_end_turn_notice_for_body(self, body: dict[str, Any] | None) -> str:
805
+ if isinstance(body, dict) and self.latest_tasklist_result_has_no_active_work(
806
+ body
807
+ ):
808
+ return (
809
+ "[ciel-runtime] TaskList returned no active tasks. No automatic continuation "
810
+ "is available; provide the next instruction or ask for current status."
811
+ )
812
+ return self.empty_end_turn_notice()
813
+
814
+
815
+ @dataclass(frozen=True, slots=True)
816
+ class ConversationTurnCompatibilityApi:
817
+ """Typed compatibility adapter that preserves late-bound policy assembly."""
818
+
819
+ policy_factory: Callable[[], ConversationTurnPolicy]
820
+
821
+ def plan_mode_active(self, body: dict[str, Any]) -> bool:
822
+ return self.policy_factory().plan_mode_active(body)
823
+
824
+ def channel_llm_wake_text(self, text: str) -> bool:
825
+ return self.policy_factory().channel_llm_wake_text(text)
826
+
827
+ def channel_llm_wake_request(self, body: dict[str, Any]) -> bool:
828
+ return self.policy_factory().channel_llm_wake_request(body)
829
+
830
+ def body_without_channel_llm_wake_prompt(self, body: dict[str, Any]) -> dict[str, Any]:
831
+ return self.policy_factory().body_without_channel_llm_wake_prompt(body)
832
+
833
+ def has_plan_mode_exit(self, body: dict[str, Any]) -> bool:
834
+ return self.policy_factory().has_plan_mode_exit(body)
835
+
836
+ def allowed_prompt_tools_for_exit_plan_mode(self, body: dict[str, Any]) -> list[str]:
837
+ return self.policy_factory().allowed_prompt_tools_for_exit_plan_mode(body)
838
+
839
+ def exit_plan_mode_default_prompt_for_tool(self, tool_name: str) -> str:
840
+ return self.policy_factory().exit_plan_mode_default_prompt_for_tool(tool_name)
841
+
842
+ def backfill_exit_plan_mode_allowed_prompts(
843
+ self, body: dict[str, Any], tool_input: dict[str, Any]
844
+ ) -> dict[str, Any]:
845
+ return self.policy_factory().backfill_exit_plan_mode_allowed_prompts(
846
+ body, tool_input
847
+ )
848
+
849
+ def plan_mode_tool_name_for_emit(
850
+ self, body: dict[str, Any], name: str, tool_input: dict[str, Any]
851
+ ) -> tuple[str | None, dict[str, Any]]:
852
+ return self.policy_factory().plan_mode_tool_name_for_emit(body, name, tool_input)
853
+
854
+ def is_guard_feedback_text(self, text: str) -> bool:
855
+ return self.policy_factory().is_guard_feedback_text(text)
856
+
857
+ def strip_claude_code_system_reminders(self, text: str) -> str:
858
+ return self.policy_factory().strip_claude_code_system_reminders(text)
859
+
860
+ def is_claude_code_suggestion_mode_text(self, text: str) -> bool:
861
+ return self.policy_factory().is_claude_code_suggestion_mode_text(text)
862
+
863
+ def user_intent_text_from_message(self, message: dict[str, Any]) -> str:
864
+ return self.policy_factory().user_intent_text_from_message(message)
865
+
866
+ def latest_user_text(self, body: dict[str, Any]) -> str:
867
+ return self.policy_factory().latest_user_text(body)
868
+
869
+ def latest_user_intent_message_index(self, body: dict[str, Any]) -> int | None:
870
+ return self.policy_factory().latest_user_intent_message_index(body)
871
+
872
+ def latest_user_is_claude_code_suggestion_mode(self, body: dict[str, Any]) -> bool:
873
+ return self.policy_factory().latest_user_is_claude_code_suggestion_mode(body)
874
+
875
+ def likely_implementation_planning_request(self, text: str) -> bool:
876
+ return self.policy_factory().likely_implementation_planning_request(text)
877
+
878
+ def non_actionable_short_response(self, text: str) -> bool:
879
+ return self.policy_factory().non_actionable_short_response(text)
880
+
881
+ def body_is_channel_prompt(self, body: dict[str, Any]) -> bool:
882
+ return self.policy_factory().body_is_channel_prompt(body)
883
+
884
+ def should_auto_enter_plan_mode(
885
+ self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
886
+ ) -> bool:
887
+ return self.policy_factory().should_auto_enter_plan_mode(
888
+ body, response_text, tool_calls
889
+ )
890
+
891
+ def response_text_signals_plan_exit(self, text: str) -> bool:
892
+ return self.policy_factory().response_text_signals_plan_exit(text)
893
+
894
+ def should_auto_exit_plan_mode(
895
+ self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
896
+ ) -> bool:
897
+ return self.policy_factory().should_auto_exit_plan_mode(
898
+ body, response_text, tool_calls
899
+ )
900
+
901
+ def bash_command_looks_mutating(self, command: str) -> bool:
902
+ return self.policy_factory().bash_command_looks_mutating(command)
903
+
904
+ def latest_user_tool_result_details(self, body: dict[str, Any]) -> list[dict[str, Any]]:
905
+ return self.policy_factory().latest_user_tool_result_details(body)
906
+
907
+ def latest_tool_result_indicates_completed_work(self, body: dict[str, Any]) -> bool:
908
+ return self.policy_factory().latest_tool_result_indicates_completed_work(body)
909
+
910
+ def latest_user_tool_result_names(self, body: dict[str, Any]) -> list[str]:
911
+ return self.policy_factory().latest_user_tool_result_names(body)
912
+
913
+ def latest_user_tool_result_text(self, body: dict[str, Any]) -> str:
914
+ return self.policy_factory().latest_user_tool_result_text(body)
915
+
916
+ def synthetic_tasklist_tool_use_id(self, tool_id: str, name: str) -> bool:
917
+ return self.policy_factory().synthetic_tasklist_tool_use_id(tool_id, name)
918
+
919
+ def recent_synthetic_tasklist_count(
920
+ self, body: dict[str, Any], after_message_index: int | None = None
921
+ ) -> int:
922
+ return self.policy_factory().recent_synthetic_tasklist_count(
923
+ body, after_message_index
924
+ )
925
+
926
+ def tasklist_result_has_active_work(self, text: str) -> bool:
927
+ return self.policy_factory().tasklist_result_has_active_work(text)
928
+
929
+ def latest_tasklist_result_has_no_active_work(self, body: dict[str, Any]) -> bool:
930
+ return self.policy_factory().latest_tasklist_result_has_no_active_work(body)
931
+
932
+ def latest_assistant_text(self, body: dict[str, Any]) -> str:
933
+ return self.policy_factory().latest_assistant_text(body)
934
+
935
+ def short_resume_prompt(self, text: str) -> bool:
936
+ return self.policy_factory().short_resume_prompt(text)
937
+
938
+ def latest_user_looks_like_work_request(self, body: dict[str, Any]) -> bool:
939
+ return self.policy_factory().latest_user_looks_like_work_request(body)
940
+
941
+ def response_asks_for_user_choice_or_permission(self, text: str) -> bool:
942
+ return self.policy_factory().response_asks_for_user_choice_or_permission(text)
943
+
944
+ def should_auto_continue_choice_question_with_tasklist(
945
+ self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
946
+ ) -> bool:
947
+ return self.policy_factory().should_auto_continue_choice_question_with_tasklist(
948
+ body, response_text, tool_calls
949
+ )
950
+
951
+ def should_synthesize_tasklist_for_provider(self, provider: str) -> bool:
952
+ return self.policy_factory().should_synthesize_tasklist_for_provider(provider)
953
+
954
+ def should_keep_work_alive_with_tasklist(
955
+ self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
956
+ ) -> bool:
957
+ return self.policy_factory().should_keep_work_alive_with_tasklist(
958
+ body, response_text, tool_calls
959
+ )
960
+
961
+ def should_recover_empty_end_turn_with_tasklist(
962
+ self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
963
+ ) -> bool:
964
+ return self.policy_factory().should_recover_empty_end_turn_with_tasklist(
965
+ body, response_text, tool_calls
966
+ )
967
+
968
+ def empty_end_turn_notice(self) -> str:
969
+ return self.policy_factory().empty_end_turn_notice()
970
+
971
+ def empty_end_turn_notice_for_body(self, body: dict[str, Any] | None) -> str:
972
+ return self.policy_factory().empty_end_turn_notice_for_body(body)