codex-autorunner 0.1.2__py3-none-any.whl → 1.1.0__py3-none-any.whl

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 (276) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/__main__.py +4 -0
  3. codex_autorunner/agents/codex/harness.py +1 -1
  4. codex_autorunner/agents/opencode/client.py +68 -35
  5. codex_autorunner/agents/opencode/constants.py +3 -0
  6. codex_autorunner/agents/opencode/harness.py +6 -1
  7. codex_autorunner/agents/opencode/logging.py +21 -5
  8. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  9. codex_autorunner/agents/opencode/runtime.py +176 -47
  10. codex_autorunner/agents/opencode/supervisor.py +36 -48
  11. codex_autorunner/agents/registry.py +155 -8
  12. codex_autorunner/api.py +25 -0
  13. codex_autorunner/bootstrap.py +22 -37
  14. codex_autorunner/cli.py +5 -1156
  15. codex_autorunner/codex_cli.py +20 -84
  16. codex_autorunner/core/__init__.py +4 -0
  17. codex_autorunner/core/about_car.py +49 -32
  18. codex_autorunner/core/adapter_utils.py +21 -0
  19. codex_autorunner/core/app_server_ids.py +59 -0
  20. codex_autorunner/core/app_server_logging.py +7 -3
  21. codex_autorunner/core/app_server_prompts.py +27 -260
  22. codex_autorunner/core/app_server_threads.py +26 -28
  23. codex_autorunner/core/app_server_utils.py +165 -0
  24. codex_autorunner/core/archive.py +349 -0
  25. codex_autorunner/core/codex_runner.py +12 -2
  26. codex_autorunner/core/config.py +587 -103
  27. codex_autorunner/core/docs.py +10 -2
  28. codex_autorunner/core/drafts.py +136 -0
  29. codex_autorunner/core/engine.py +1531 -866
  30. codex_autorunner/core/exceptions.py +4 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +202 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +88 -0
  35. codex_autorunner/core/flows/reasons.py +52 -0
  36. codex_autorunner/core/flows/reconciler.py +131 -0
  37. codex_autorunner/core/flows/runtime.py +382 -0
  38. codex_autorunner/core/flows/store.py +568 -0
  39. codex_autorunner/core/flows/transition.py +138 -0
  40. codex_autorunner/core/flows/ux_helpers.py +257 -0
  41. codex_autorunner/core/flows/worker_process.py +242 -0
  42. codex_autorunner/core/git_utils.py +62 -0
  43. codex_autorunner/core/hub.py +136 -16
  44. codex_autorunner/core/locks.py +4 -0
  45. codex_autorunner/core/notifications.py +14 -2
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/core/ports/agent_backend.py +150 -0
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/core/ports/run_event.py +91 -0
  50. codex_autorunner/core/prompt.py +15 -7
  51. codex_autorunner/core/redaction.py +29 -0
  52. codex_autorunner/core/review_context.py +5 -8
  53. codex_autorunner/core/run_index.py +6 -0
  54. codex_autorunner/core/runner_process.py +5 -2
  55. codex_autorunner/core/state.py +0 -88
  56. codex_autorunner/core/state_roots.py +57 -0
  57. codex_autorunner/core/supervisor_protocol.py +15 -0
  58. codex_autorunner/core/supervisor_utils.py +67 -0
  59. codex_autorunner/core/text_delta_coalescer.py +54 -0
  60. codex_autorunner/core/ticket_linter_cli.py +201 -0
  61. codex_autorunner/core/ticket_manager_cli.py +432 -0
  62. codex_autorunner/core/update.py +24 -16
  63. codex_autorunner/core/update_paths.py +28 -0
  64. codex_autorunner/core/update_runner.py +2 -0
  65. codex_autorunner/core/usage.py +164 -12
  66. codex_autorunner/core/utils.py +120 -11
  67. codex_autorunner/discovery.py +2 -4
  68. codex_autorunner/flows/review/__init__.py +17 -0
  69. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  70. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  71. codex_autorunner/flows/ticket_flow/definition.py +98 -0
  72. codex_autorunner/integrations/agents/__init__.py +17 -0
  73. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  74. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  75. codex_autorunner/integrations/agents/codex_backend.py +448 -0
  76. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  77. codex_autorunner/integrations/agents/opencode_backend.py +598 -0
  78. codex_autorunner/integrations/agents/runner.py +91 -0
  79. codex_autorunner/integrations/agents/wiring.py +271 -0
  80. codex_autorunner/integrations/app_server/client.py +583 -152
  81. codex_autorunner/integrations/app_server/env.py +2 -107
  82. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  83. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  84. codex_autorunner/integrations/telegram/adapter.py +204 -165
  85. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  86. codex_autorunner/integrations/telegram/config.py +221 -0
  87. codex_autorunner/integrations/telegram/constants.py +17 -2
  88. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  89. codex_autorunner/integrations/telegram/doctor.py +47 -0
  90. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
  91. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  92. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  93. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  94. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
  95. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  96. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  97. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  98. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
  99. codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
  100. codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
  101. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  102. codex_autorunner/integrations/telegram/helpers.py +111 -16
  103. codex_autorunner/integrations/telegram/outbox.py +208 -37
  104. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  105. codex_autorunner/integrations/telegram/service.py +221 -42
  106. codex_autorunner/integrations/telegram/state.py +100 -2
  107. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
  108. codex_autorunner/integrations/telegram/transport.py +39 -4
  109. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  110. codex_autorunner/manifest.py +2 -0
  111. codex_autorunner/plugin_api.py +22 -0
  112. codex_autorunner/routes/__init__.py +37 -67
  113. codex_autorunner/routes/agents.py +2 -137
  114. codex_autorunner/routes/analytics.py +3 -0
  115. codex_autorunner/routes/app_server.py +2 -131
  116. codex_autorunner/routes/base.py +2 -624
  117. codex_autorunner/routes/file_chat.py +7 -0
  118. codex_autorunner/routes/flows.py +7 -0
  119. codex_autorunner/routes/messages.py +7 -0
  120. codex_autorunner/routes/repos.py +2 -196
  121. codex_autorunner/routes/review.py +2 -147
  122. codex_autorunner/routes/sessions.py +2 -175
  123. codex_autorunner/routes/settings.py +2 -168
  124. codex_autorunner/routes/shared.py +2 -275
  125. codex_autorunner/routes/system.py +4 -188
  126. codex_autorunner/routes/usage.py +3 -0
  127. codex_autorunner/routes/voice.py +2 -119
  128. codex_autorunner/routes/workspace.py +3 -0
  129. codex_autorunner/server.py +3 -2
  130. codex_autorunner/static/agentControls.js +41 -11
  131. codex_autorunner/static/agentEvents.js +248 -0
  132. codex_autorunner/static/app.js +35 -24
  133. codex_autorunner/static/archive.js +826 -0
  134. codex_autorunner/static/archiveApi.js +37 -0
  135. codex_autorunner/static/autoRefresh.js +36 -8
  136. codex_autorunner/static/bootstrap.js +1 -0
  137. codex_autorunner/static/bus.js +1 -0
  138. codex_autorunner/static/cache.js +1 -0
  139. codex_autorunner/static/constants.js +20 -4
  140. codex_autorunner/static/dashboard.js +344 -325
  141. codex_autorunner/static/diffRenderer.js +37 -0
  142. codex_autorunner/static/docChatCore.js +324 -0
  143. codex_autorunner/static/docChatStorage.js +65 -0
  144. codex_autorunner/static/docChatVoice.js +65 -0
  145. codex_autorunner/static/docEditor.js +133 -0
  146. codex_autorunner/static/env.js +1 -0
  147. codex_autorunner/static/eventSummarizer.js +166 -0
  148. codex_autorunner/static/fileChat.js +182 -0
  149. codex_autorunner/static/health.js +155 -0
  150. codex_autorunner/static/hub.js +126 -185
  151. codex_autorunner/static/index.html +839 -863
  152. codex_autorunner/static/liveUpdates.js +1 -0
  153. codex_autorunner/static/loader.js +1 -0
  154. codex_autorunner/static/messages.js +873 -0
  155. codex_autorunner/static/mobileCompact.js +2 -1
  156. codex_autorunner/static/preserve.js +17 -0
  157. codex_autorunner/static/settings.js +149 -217
  158. codex_autorunner/static/smartRefresh.js +52 -0
  159. codex_autorunner/static/styles.css +8850 -3876
  160. codex_autorunner/static/tabs.js +175 -11
  161. codex_autorunner/static/terminal.js +32 -0
  162. codex_autorunner/static/terminalManager.js +34 -59
  163. codex_autorunner/static/ticketChatActions.js +333 -0
  164. codex_autorunner/static/ticketChatEvents.js +16 -0
  165. codex_autorunner/static/ticketChatStorage.js +16 -0
  166. codex_autorunner/static/ticketChatStream.js +264 -0
  167. codex_autorunner/static/ticketEditor.js +844 -0
  168. codex_autorunner/static/ticketVoice.js +9 -0
  169. codex_autorunner/static/tickets.js +1988 -0
  170. codex_autorunner/static/utils.js +43 -3
  171. codex_autorunner/static/voice.js +1 -0
  172. codex_autorunner/static/workspace.js +765 -0
  173. codex_autorunner/static/workspaceApi.js +53 -0
  174. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  175. codex_autorunner/surfaces/__init__.py +5 -0
  176. codex_autorunner/surfaces/cli/__init__.py +6 -0
  177. codex_autorunner/surfaces/cli/cli.py +1224 -0
  178. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  179. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  180. codex_autorunner/surfaces/web/__init__.py +1 -0
  181. codex_autorunner/surfaces/web/app.py +2019 -0
  182. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  183. codex_autorunner/surfaces/web/middleware.py +587 -0
  184. codex_autorunner/surfaces/web/pty_session.py +370 -0
  185. codex_autorunner/surfaces/web/review.py +6 -0
  186. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  187. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  188. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  189. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  190. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  191. codex_autorunner/surfaces/web/routes/base.py +615 -0
  192. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  193. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  194. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  195. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  196. codex_autorunner/surfaces/web/routes/review.py +148 -0
  197. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  198. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  199. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  200. codex_autorunner/surfaces/web/routes/system.py +196 -0
  201. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  202. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  203. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  204. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  205. codex_autorunner/surfaces/web/schemas.py +417 -0
  206. codex_autorunner/surfaces/web/static_assets.py +490 -0
  207. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  208. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  209. codex_autorunner/tickets/__init__.py +27 -0
  210. codex_autorunner/tickets/agent_pool.py +399 -0
  211. codex_autorunner/tickets/files.py +89 -0
  212. codex_autorunner/tickets/frontmatter.py +55 -0
  213. codex_autorunner/tickets/lint.py +102 -0
  214. codex_autorunner/tickets/models.py +97 -0
  215. codex_autorunner/tickets/outbox.py +244 -0
  216. codex_autorunner/tickets/replies.py +179 -0
  217. codex_autorunner/tickets/runner.py +881 -0
  218. codex_autorunner/tickets/spec_ingest.py +77 -0
  219. codex_autorunner/web/__init__.py +5 -1
  220. codex_autorunner/web/app.py +2 -1771
  221. codex_autorunner/web/hub_jobs.py +2 -191
  222. codex_autorunner/web/middleware.py +2 -587
  223. codex_autorunner/web/pty_session.py +2 -369
  224. codex_autorunner/web/runner_manager.py +2 -24
  225. codex_autorunner/web/schemas.py +2 -396
  226. codex_autorunner/web/static_assets.py +4 -484
  227. codex_autorunner/web/static_refresh.py +2 -85
  228. codex_autorunner/web/terminal_sessions.py +2 -77
  229. codex_autorunner/workspace/__init__.py +40 -0
  230. codex_autorunner/workspace/paths.py +335 -0
  231. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  232. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  233. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
  234. codex_autorunner/agents/execution/policy.py +0 -292
  235. codex_autorunner/agents/factory.py +0 -52
  236. codex_autorunner/agents/orchestrator.py +0 -358
  237. codex_autorunner/core/doc_chat.py +0 -1446
  238. codex_autorunner/core/snapshot.py +0 -580
  239. codex_autorunner/integrations/github/chatops.py +0 -268
  240. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  241. codex_autorunner/routes/docs.py +0 -381
  242. codex_autorunner/routes/github.py +0 -327
  243. codex_autorunner/routes/runs.py +0 -250
  244. codex_autorunner/spec_ingest.py +0 -812
  245. codex_autorunner/static/docChatActions.js +0 -287
  246. codex_autorunner/static/docChatEvents.js +0 -300
  247. codex_autorunner/static/docChatRender.js +0 -205
  248. codex_autorunner/static/docChatStream.js +0 -361
  249. codex_autorunner/static/docs.js +0 -20
  250. codex_autorunner/static/docsClipboard.js +0 -69
  251. codex_autorunner/static/docsCrud.js +0 -257
  252. codex_autorunner/static/docsDocUpdates.js +0 -62
  253. codex_autorunner/static/docsDrafts.js +0 -16
  254. codex_autorunner/static/docsElements.js +0 -69
  255. codex_autorunner/static/docsInit.js +0 -285
  256. codex_autorunner/static/docsParse.js +0 -160
  257. codex_autorunner/static/docsSnapshot.js +0 -87
  258. codex_autorunner/static/docsSpecIngest.js +0 -263
  259. codex_autorunner/static/docsState.js +0 -127
  260. codex_autorunner/static/docsThreadRegistry.js +0 -44
  261. codex_autorunner/static/docsUi.js +0 -153
  262. codex_autorunner/static/docsVoice.js +0 -56
  263. codex_autorunner/static/github.js +0 -504
  264. codex_autorunner/static/logs.js +0 -678
  265. codex_autorunner/static/review.js +0 -157
  266. codex_autorunner/static/runs.js +0 -418
  267. codex_autorunner/static/snapshot.js +0 -124
  268. codex_autorunner/static/state.js +0 -94
  269. codex_autorunner/static/todoPreview.js +0 -27
  270. codex_autorunner/workspace.py +0 -16
  271. codex_autorunner-0.1.2.dist-info/METADATA +0 -249
  272. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  273. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  274. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  275. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  276. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,881 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any, Callable, Optional
6
+
7
+ from ..core.flows.models import FlowEventType
8
+ from ..core.git_utils import git_diff_stats, run_git
9
+ from ..workspace.paths import workspace_doc_path
10
+ from .agent_pool import AgentPool, AgentTurnRequest
11
+ from .files import list_ticket_paths, read_ticket, safe_relpath, ticket_is_done
12
+ from .frontmatter import parse_markdown_frontmatter
13
+ from .lint import lint_ticket_frontmatter
14
+ from .models import TicketFrontmatter, TicketResult, TicketRunConfig
15
+ from .outbox import (
16
+ archive_dispatch,
17
+ create_turn_summary,
18
+ ensure_outbox_dirs,
19
+ resolve_outbox_paths,
20
+ )
21
+ from .replies import ensure_reply_dirs, parse_user_reply, resolve_reply_paths
22
+
23
+ _logger = logging.getLogger(__name__)
24
+
25
+ WORKSPACE_DOC_MAX_CHARS = 4000
26
+
27
+
28
+ class TicketRunner:
29
+ """Execute a ticket directory one agent turn at a time.
30
+
31
+ This runner is intentionally small and file-backed:
32
+ - Tickets are markdown files under `config.ticket_dir`.
33
+ - User messages + optional attachments are written to an outbox under `config.runs_dir`.
34
+ - The orchestrator is stateless aside from the `state` dict passed into step().
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ workspace_root: Path,
41
+ run_id: str,
42
+ config: TicketRunConfig,
43
+ agent_pool: AgentPool,
44
+ ):
45
+ self._workspace_root = workspace_root
46
+ self._run_id = run_id
47
+ self._config = config
48
+ self._agent_pool = agent_pool
49
+
50
+ async def step(
51
+ self,
52
+ state: dict[str, Any],
53
+ *,
54
+ emit_event: Optional[Callable[[FlowEventType, dict[str, Any]], None]] = None,
55
+ ) -> TicketResult:
56
+ """Execute exactly one orchestration step.
57
+
58
+ A step is either:
59
+ - run one agent turn for the current ticket, or
60
+ - pause because prerequisites are missing, or
61
+ - mark the whole run completed (no remaining tickets).
62
+ """
63
+
64
+ state = dict(state or {})
65
+ # Clear transient reason from previous pause/resume cycles.
66
+ state.pop("reason", None)
67
+
68
+ _commit_raw = state.get("commit")
69
+ commit_state: dict[str, Any] = (
70
+ _commit_raw if isinstance(_commit_raw, dict) else {}
71
+ )
72
+ commit_pending = bool(commit_state.get("pending"))
73
+ commit_retries = int(commit_state.get("retries") or 0)
74
+ # Global counters.
75
+ total_turns = int(state.get("total_turns") or 0)
76
+ if total_turns >= self._config.max_total_turns:
77
+ return self._pause(
78
+ state,
79
+ reason=f"Max turns reached ({self._config.max_total_turns}). Review tickets and resume.",
80
+ reason_code="needs_user_fix",
81
+ )
82
+
83
+ ticket_dir = self._workspace_root / self._config.ticket_dir
84
+ runs_dir = self._config.runs_dir
85
+
86
+ # Ensure outbox dirs exist.
87
+ outbox_paths = resolve_outbox_paths(
88
+ workspace_root=self._workspace_root,
89
+ runs_dir=runs_dir,
90
+ run_id=self._run_id,
91
+ )
92
+ ensure_outbox_dirs(outbox_paths)
93
+
94
+ # Ensure reply inbox dirs exist (human -> agent messages).
95
+ reply_paths = resolve_reply_paths(
96
+ workspace_root=self._workspace_root,
97
+ runs_dir=runs_dir,
98
+ run_id=self._run_id,
99
+ )
100
+ ensure_reply_dirs(reply_paths)
101
+
102
+ ticket_paths = list_ticket_paths(ticket_dir)
103
+ if not ticket_paths:
104
+ return self._pause(
105
+ state,
106
+ reason=(
107
+ "No tickets found. Create tickets under "
108
+ f"{safe_relpath(ticket_dir, self._workspace_root)} and resume."
109
+ ),
110
+ reason_code="no_tickets",
111
+ )
112
+
113
+ current_ticket = state.get("current_ticket")
114
+ current_path: Optional[Path] = (
115
+ (self._workspace_root / current_ticket)
116
+ if isinstance(current_ticket, str) and current_ticket
117
+ else None
118
+ )
119
+
120
+ # If current ticket is done, clear it unless we're in the middle of a
121
+ # bounded "commit required" follow-up loop.
122
+ if current_path and ticket_is_done(current_path) and not commit_pending:
123
+ current_path = None
124
+ state.pop("current_ticket", None)
125
+ state.pop("ticket_turns", None)
126
+ state.pop("last_agent_output", None)
127
+ state.pop("lint", None)
128
+ state.pop("commit", None)
129
+
130
+ if current_path is None:
131
+ next_path = self._find_next_ticket(ticket_paths)
132
+ if next_path is None:
133
+ state["status"] = "completed"
134
+ return TicketResult(
135
+ status="completed", state=state, reason="All tickets done."
136
+ )
137
+ current_path = next_path
138
+ state["current_ticket"] = safe_relpath(current_path, self._workspace_root)
139
+ # Inform listeners immediately which ticket is about to run so the UI
140
+ # can show the active indicator before the first turn completes.
141
+ if emit_event is not None:
142
+ emit_event(
143
+ FlowEventType.STEP_PROGRESS,
144
+ {
145
+ "message": "Selected ticket",
146
+ "current_ticket": state["current_ticket"],
147
+ },
148
+ )
149
+ # New ticket resets per-ticket state.
150
+ state["ticket_turns"] = 0
151
+ state.pop("last_agent_output", None)
152
+ state.pop("lint", None)
153
+ state.pop("commit", None)
154
+
155
+ # Determine lint-retry mode early. When lint state is present, we allow the
156
+ # agent to fix the ticket frontmatter even if the ticket is currently
157
+ # unparsable by the strict lint rules.
158
+ if state.get("status") == "paused":
159
+ # Clear stale pause markers so upgraded logic can proceed without manual DB edits.
160
+ state["status"] = "running"
161
+ state.pop("reason", None)
162
+ state.pop("reason_details", None)
163
+ state.pop("reason_code", None)
164
+
165
+ _lint_raw = state.get("lint")
166
+ lint_state: dict[str, Any] = _lint_raw if isinstance(_lint_raw, dict) else {}
167
+ _lint_errors_raw = lint_state.get("errors")
168
+ lint_errors: list[str] = (
169
+ _lint_errors_raw if isinstance(_lint_errors_raw, list) else []
170
+ )
171
+ lint_retries = int(lint_state.get("retries") or 0)
172
+ _conv_id_raw = lint_state.get("conversation_id")
173
+ reuse_conversation_id: Optional[str] = (
174
+ _conv_id_raw if isinstance(_conv_id_raw, str) else None
175
+ )
176
+
177
+ # Read ticket (may lint-fail). In lint-retry mode, fall back to a relaxed
178
+ # frontmatter parse so we can still execute an agent turn to repair the file.
179
+ ticket_doc = None
180
+ ticket_errors: list[str] = []
181
+ if lint_errors:
182
+ try:
183
+ raw = current_path.read_text(encoding="utf-8")
184
+ except OSError as exc:
185
+ return self._pause(
186
+ state,
187
+ reason=(
188
+ "Ticket unreadable during lint retry for "
189
+ f"{safe_relpath(current_path, self._workspace_root)}: {exc}"
190
+ ),
191
+ current_ticket=safe_relpath(current_path, self._workspace_root),
192
+ reason_code="infra_error",
193
+ )
194
+
195
+ data, _ = parse_markdown_frontmatter(raw)
196
+ agent = data.get("agent")
197
+ agent_id = agent.strip() if isinstance(agent, str) else None
198
+ if not agent_id:
199
+ return self._pause(
200
+ state,
201
+ reason=(
202
+ "Cannot determine ticket agent during lint retry (missing frontmatter.agent). "
203
+ "Fix the ticket frontmatter manually and resume."
204
+ ),
205
+ current_ticket=safe_relpath(current_path, self._workspace_root),
206
+ reason_code="needs_user_fix",
207
+ )
208
+
209
+ # Validate agent id unless it is the special user sentinel.
210
+ if agent_id != "user":
211
+ try:
212
+ from ..agents.registry import validate_agent_id
213
+
214
+ agent_id = validate_agent_id(agent_id)
215
+ except Exception as exc:
216
+ return self._pause(
217
+ state,
218
+ reason=(
219
+ "Cannot determine valid agent during lint retry for "
220
+ f"{safe_relpath(current_path, self._workspace_root)}: {exc}"
221
+ ),
222
+ current_ticket=safe_relpath(current_path, self._workspace_root),
223
+ reason_code="needs_user_fix",
224
+ )
225
+
226
+ ticket_doc = type(
227
+ "_TicketDocForLintRetry",
228
+ (),
229
+ {
230
+ "frontmatter": TicketFrontmatter(
231
+ agent=agent_id,
232
+ done=False,
233
+ )
234
+ },
235
+ )()
236
+ else:
237
+ ticket_doc, ticket_errors = read_ticket(current_path)
238
+ if ticket_errors or ticket_doc is None:
239
+ return self._pause(
240
+ state,
241
+ reason=f"Ticket frontmatter invalid: {safe_relpath(current_path, self._workspace_root)}",
242
+ reason_details="Errors:\n- " + "\n- ".join(ticket_errors),
243
+ current_ticket=safe_relpath(current_path, self._workspace_root),
244
+ reason_code="needs_user_fix",
245
+ )
246
+
247
+ # Built-in manual user ticket.
248
+ if ticket_doc.frontmatter.agent == "user":
249
+ if ticket_doc.frontmatter.done:
250
+ # Nothing to do, will advance next step.
251
+ return TicketResult(status="continue", state=state)
252
+ return self._pause(
253
+ state,
254
+ reason=(
255
+ "Paused for user input. Mark ticket as done when ready: "
256
+ f"{safe_relpath(current_path, self._workspace_root)}"
257
+ ),
258
+ current_ticket=safe_relpath(current_path, self._workspace_root),
259
+ reason_code="user_pause",
260
+ )
261
+
262
+ ticket_turns = int(state.get("ticket_turns") or 0)
263
+ reply_seq = int(state.get("reply_seq") or 0)
264
+ reply_context, reply_max_seq = self._build_reply_context(
265
+ reply_paths=reply_paths, last_seq=reply_seq
266
+ )
267
+
268
+ previous_ticket_content: Optional[str] = None
269
+ try:
270
+ if current_path in ticket_paths:
271
+ curr_idx = ticket_paths.index(current_path)
272
+ if curr_idx > 0:
273
+ prev_path = ticket_paths[curr_idx - 1]
274
+ previous_ticket_content = prev_path.read_text(encoding="utf-8")
275
+ except Exception:
276
+ pass
277
+
278
+ prompt = self._build_prompt(
279
+ ticket_path=current_path,
280
+ ticket_doc=ticket_doc,
281
+ last_agent_output=(
282
+ state.get("last_agent_output")
283
+ if isinstance(state.get("last_agent_output"), str)
284
+ else None
285
+ ),
286
+ last_checkpoint_error=(
287
+ state.get("last_checkpoint_error")
288
+ if isinstance(state.get("last_checkpoint_error"), str)
289
+ else None
290
+ ),
291
+ commit_required=commit_pending,
292
+ commit_attempt=commit_retries + 1 if commit_pending else 0,
293
+ commit_max_attempts=self._config.max_commit_retries,
294
+ outbox_paths=outbox_paths,
295
+ lint_errors=lint_errors if lint_errors else None,
296
+ reply_context=reply_context,
297
+ previous_ticket_content=previous_ticket_content,
298
+ )
299
+
300
+ # Execute turn.
301
+ # Build options dict with model/reasoning from ticket frontmatter if set.
302
+ turn_options: dict[str, Any] = {}
303
+ if ticket_doc.frontmatter.model:
304
+ turn_options["model"] = ticket_doc.frontmatter.model
305
+ if ticket_doc.frontmatter.reasoning:
306
+ turn_options["reasoning"] = ticket_doc.frontmatter.reasoning
307
+ req = AgentTurnRequest(
308
+ agent_id=ticket_doc.frontmatter.agent,
309
+ prompt=prompt,
310
+ workspace_root=self._workspace_root,
311
+ conversation_id=reuse_conversation_id,
312
+ emit_event=emit_event,
313
+ options=turn_options if turn_options else None,
314
+ )
315
+
316
+ total_turns += 1
317
+ ticket_turns += 1
318
+ state["total_turns"] = total_turns
319
+ state["ticket_turns"] = ticket_turns
320
+
321
+ head_before_turn: Optional[str] = None
322
+ try:
323
+ head_proc = run_git(
324
+ ["rev-parse", "HEAD"], cwd=self._workspace_root, check=True
325
+ )
326
+ head_before_turn = (head_proc.stdout or "").strip() or None
327
+ except Exception:
328
+ head_before_turn = None
329
+
330
+ result = await self._agent_pool.run_turn(req)
331
+ if result.error:
332
+ state["last_agent_output"] = result.text
333
+ state["last_agent_id"] = result.agent_id
334
+ state["last_agent_conversation_id"] = result.conversation_id
335
+ state["last_agent_turn_id"] = result.turn_id
336
+ return self._pause(
337
+ state,
338
+ reason="Agent turn failed. Fix the issue and resume.",
339
+ reason_details=f"Error: {result.error}",
340
+ current_ticket=safe_relpath(current_path, self._workspace_root),
341
+ reason_code="infra_error",
342
+ )
343
+
344
+ # Mark replies as consumed only after a successful agent turn.
345
+ if reply_max_seq > reply_seq:
346
+ state["reply_seq"] = reply_max_seq
347
+ state["last_agent_output"] = result.text
348
+ state["last_agent_id"] = result.agent_id
349
+ state["last_agent_conversation_id"] = result.conversation_id
350
+ state["last_agent_turn_id"] = result.turn_id
351
+
352
+ # Best-effort: check whether the agent created a commit and whether the
353
+ # working tree is clean, before any runner-driven checkpoint commit.
354
+ head_after_agent: Optional[str] = None
355
+ clean_after_agent: Optional[bool] = None
356
+ status_after_agent: Optional[str] = None
357
+ agent_committed_this_turn: Optional[bool] = None
358
+ try:
359
+ head_proc = run_git(
360
+ ["rev-parse", "HEAD"], cwd=self._workspace_root, check=True
361
+ )
362
+ head_after_agent = (head_proc.stdout or "").strip() or None
363
+ status_proc = run_git(
364
+ ["status", "--porcelain"], cwd=self._workspace_root, check=True
365
+ )
366
+ status_after_agent = (status_proc.stdout or "").strip()
367
+ clean_after_agent = not bool(status_after_agent)
368
+ if head_before_turn and head_after_agent:
369
+ agent_committed_this_turn = head_after_agent != head_before_turn
370
+ except Exception:
371
+ head_after_agent = None
372
+ clean_after_agent = None
373
+ status_after_agent = None
374
+ agent_committed_this_turn = None
375
+
376
+ # Post-turn: archive outbox if DISPATCH.md exists.
377
+ dispatch_seq = int(state.get("dispatch_seq") or 0)
378
+ current_ticket_id = safe_relpath(current_path, self._workspace_root)
379
+ dispatch, dispatch_errors = archive_dispatch(
380
+ outbox_paths, next_seq=dispatch_seq + 1, ticket_id=current_ticket_id
381
+ )
382
+ if dispatch_errors:
383
+ # Treat as pause: user should fix DISPATCH.md frontmatter. Keep outbox
384
+ # lint separate from ticket frontmatter lint to avoid mixing behaviors.
385
+ state["outbox_lint"] = dispatch_errors
386
+ return self._pause(
387
+ state,
388
+ reason="Invalid DISPATCH.md frontmatter.",
389
+ reason_details="Errors:\n- " + "\n- ".join(dispatch_errors),
390
+ current_ticket=safe_relpath(current_path, self._workspace_root),
391
+ reason_code="needs_user_fix",
392
+ )
393
+
394
+ if dispatch is not None:
395
+ state["dispatch_seq"] = dispatch.seq
396
+ state.pop("outbox_lint", None)
397
+
398
+ # Create turn summary record for the agent's final output.
399
+ # This appears in dispatch history as a distinct "turn summary" entry.
400
+ turn_summary_seq = int(state.get("dispatch_seq") or 0) + 1
401
+
402
+ # Compute diff stats for this turn (changes since head_before_turn).
403
+ # This captures both committed and uncommitted changes made by the agent.
404
+ turn_diff_stats = None
405
+ try:
406
+ if head_before_turn:
407
+ # Compare current state (HEAD + working tree) against pre-turn commit
408
+ turn_diff_stats = git_diff_stats(
409
+ self._workspace_root, from_ref=head_before_turn
410
+ )
411
+ else:
412
+ # No reference commit; show all uncommitted changes
413
+ turn_diff_stats = git_diff_stats(
414
+ self._workspace_root, from_ref=None, include_staged=True
415
+ )
416
+ except Exception:
417
+ # Best-effort; don't block on stats computation errors
418
+ turn_diff_stats = None
419
+
420
+ turn_summary, turn_summary_errors = create_turn_summary(
421
+ outbox_paths,
422
+ next_seq=turn_summary_seq,
423
+ agent_output=result.text or "",
424
+ ticket_id=current_ticket_id,
425
+ agent_id=result.agent_id,
426
+ turn_number=total_turns,
427
+ diff_stats=turn_diff_stats,
428
+ )
429
+ if turn_summary is not None:
430
+ state["dispatch_seq"] = turn_summary.seq
431
+
432
+ # Post-turn: ticket frontmatter must remain valid.
433
+ updated_fm, fm_errors = self._recheck_ticket_frontmatter(current_path)
434
+ if fm_errors:
435
+ lint_retries += 1
436
+ if lint_retries > self._config.max_lint_retries:
437
+ return self._pause(
438
+ state,
439
+ reason="Ticket frontmatter invalid. Manual fix required.",
440
+ reason_details=(
441
+ "Exceeded lint retry limit. Fix the ticket frontmatter manually and resume.\n\n"
442
+ "Errors:\n- " + "\n- ".join(fm_errors)
443
+ ),
444
+ current_ticket=safe_relpath(current_path, self._workspace_root),
445
+ reason_code="needs_user_fix",
446
+ )
447
+
448
+ state["lint"] = {
449
+ "errors": fm_errors,
450
+ "retries": lint_retries,
451
+ "conversation_id": result.conversation_id,
452
+ }
453
+ return TicketResult(
454
+ status="continue",
455
+ state=state,
456
+ reason="Ticket frontmatter invalid; requesting agent fix.",
457
+ current_ticket=safe_relpath(current_path, self._workspace_root),
458
+ agent_output=result.text,
459
+ agent_id=result.agent_id,
460
+ agent_conversation_id=result.conversation_id,
461
+ agent_turn_id=result.turn_id,
462
+ )
463
+
464
+ # Clear lint state if previously set.
465
+ if state.get("lint"):
466
+ state.pop("lint", None)
467
+
468
+ # Optional: auto-commit checkpoint (best-effort).
469
+ checkpoint_error = None
470
+ commit_required_now = bool(
471
+ updated_fm and updated_fm.done and clean_after_agent is False
472
+ )
473
+ if self._config.auto_commit and not commit_pending and not commit_required_now:
474
+ checkpoint_error = self._checkpoint_git(
475
+ turn=total_turns, agent=result.agent_id
476
+ )
477
+
478
+ # If we dispatched a pause message, pause regardless of ticket completion.
479
+ if dispatch is not None and dispatch.dispatch.mode == "pause":
480
+ reason = dispatch.dispatch.title or "Paused for user input."
481
+ if checkpoint_error:
482
+ reason += f"\n\nNote: checkpoint commit failed: {checkpoint_error}"
483
+ state["status"] = "paused"
484
+ state["reason"] = reason
485
+ state["reason_code"] = "user_pause"
486
+ return TicketResult(
487
+ status="paused",
488
+ state=state,
489
+ reason=reason,
490
+ dispatch=dispatch,
491
+ current_ticket=safe_relpath(current_path, self._workspace_root),
492
+ agent_output=result.text,
493
+ agent_id=result.agent_id,
494
+ agent_conversation_id=result.conversation_id,
495
+ agent_turn_id=result.turn_id,
496
+ )
497
+
498
+ # If ticket is marked done, require a clean working tree (i.e., changes
499
+ # committed) before advancing. This is bounded by max_commit_retries.
500
+ if updated_fm and updated_fm.done:
501
+ if clean_after_agent is False:
502
+ # Enter or continue bounded commit loop.
503
+ if commit_pending:
504
+ # A "commit required" turn just ran and did not succeed.
505
+ next_failed_attempts = commit_retries + 1
506
+ else:
507
+ # Ticket just transitioned to done, but repo is still dirty.
508
+ next_failed_attempts = 0
509
+
510
+ state["commit"] = {
511
+ "pending": True,
512
+ "retries": next_failed_attempts,
513
+ "head_before": head_before_turn,
514
+ "head_after": head_after_agent,
515
+ "agent_committed_this_turn": agent_committed_this_turn,
516
+ "status_porcelain": status_after_agent,
517
+ }
518
+
519
+ if (
520
+ commit_pending
521
+ and next_failed_attempts >= self._config.max_commit_retries
522
+ ):
523
+ detail = (status_after_agent or "").strip()
524
+ detail_lines = detail.splitlines()[:20]
525
+ details_parts = [
526
+ "Please commit manually (ensuring pre-commit hooks pass) and resume."
527
+ ]
528
+ if detail_lines:
529
+ details_parts.append(
530
+ "\n\nWorking tree status (git status --porcelain):\n- "
531
+ + "\n- ".join(detail_lines)
532
+ )
533
+ return self._pause(
534
+ state,
535
+ reason=(
536
+ f"Commit failed after {self._config.max_commit_retries} attempts. "
537
+ "Manual commit required."
538
+ ),
539
+ reason_details="".join(details_parts),
540
+ current_ticket=safe_relpath(current_path, self._workspace_root),
541
+ reason_code="needs_user_fix",
542
+ )
543
+
544
+ return TicketResult(
545
+ status="continue",
546
+ state=state,
547
+ reason="Ticket done but commit required; requesting agent commit.",
548
+ current_ticket=safe_relpath(current_path, self._workspace_root),
549
+ agent_output=result.text,
550
+ agent_id=result.agent_id,
551
+ agent_conversation_id=result.conversation_id,
552
+ agent_turn_id=result.turn_id,
553
+ )
554
+
555
+ # Clean (or unknown) → commit satisfied (or no changes / cannot check).
556
+ state.pop("commit", None)
557
+ state.pop("current_ticket", None)
558
+ state.pop("ticket_turns", None)
559
+ state.pop("last_agent_output", None)
560
+ state.pop("lint", None)
561
+ else:
562
+ # If the ticket is no longer done, clear any pending commit gating.
563
+ state.pop("commit", None)
564
+
565
+ if checkpoint_error:
566
+ # Non-fatal, but surface in state for UI.
567
+ state["last_checkpoint_error"] = checkpoint_error
568
+ else:
569
+ state.pop("last_checkpoint_error", None)
570
+
571
+ return TicketResult(
572
+ status="continue",
573
+ state=state,
574
+ reason="Turn complete.",
575
+ dispatch=dispatch,
576
+ current_ticket=safe_relpath(current_path, self._workspace_root),
577
+ agent_output=result.text,
578
+ agent_id=result.agent_id,
579
+ agent_conversation_id=result.conversation_id,
580
+ agent_turn_id=result.turn_id,
581
+ )
582
+
583
+ def _find_next_ticket(self, ticket_paths: list[Path]) -> Optional[Path]:
584
+ for path in ticket_paths:
585
+ if ticket_is_done(path):
586
+ continue
587
+ return path
588
+ return None
589
+
590
+ def _recheck_ticket_frontmatter(self, ticket_path: Path):
591
+ try:
592
+ raw = ticket_path.read_text(encoding="utf-8")
593
+ except OSError as exc:
594
+ return None, [f"Failed to read ticket after turn: {exc}"]
595
+ from .frontmatter import parse_markdown_frontmatter
596
+
597
+ data, _ = parse_markdown_frontmatter(raw)
598
+ fm, errors = lint_ticket_frontmatter(data)
599
+ return fm, errors
600
+
601
+ def _checkpoint_git(self, *, turn: int, agent: str) -> Optional[str]:
602
+ """Create a best-effort git commit checkpoint.
603
+
604
+ Returns an error string if the checkpoint failed, else None.
605
+ """
606
+
607
+ try:
608
+ status_proc = run_git(
609
+ ["status", "--porcelain"], cwd=self._workspace_root, check=True
610
+ )
611
+ if not (status_proc.stdout or "").strip():
612
+ return None
613
+ run_git(["add", "-A"], cwd=self._workspace_root, check=True)
614
+ msg = self._config.checkpoint_message_template.format(
615
+ run_id=self._run_id,
616
+ turn=turn,
617
+ agent=agent,
618
+ )
619
+ run_git(["commit", "-m", msg], cwd=self._workspace_root, check=True)
620
+ return None
621
+ except Exception as exc:
622
+ _logger.exception("Checkpoint commit failed")
623
+ return str(exc)
624
+
625
+ def _pause(
626
+ self,
627
+ state: dict[str, Any],
628
+ *,
629
+ reason: str,
630
+ reason_code: str = "needs_user_fix",
631
+ reason_details: Optional[str] = None,
632
+ current_ticket: Optional[str] = None,
633
+ ) -> TicketResult:
634
+ state = dict(state)
635
+ state["status"] = "paused"
636
+ state["reason"] = reason
637
+ state["reason_code"] = reason_code
638
+ if reason_details:
639
+ state["reason_details"] = reason_details
640
+ else:
641
+ state.pop("reason_details", None)
642
+ return TicketResult(
643
+ status="paused",
644
+ state=state,
645
+ reason=reason,
646
+ reason_details=reason_details,
647
+ current_ticket=current_ticket
648
+ or (
649
+ state.get("current_ticket")
650
+ if isinstance(state.get("current_ticket"), str)
651
+ else None
652
+ ),
653
+ )
654
+
655
+ def _build_reply_context(self, *, reply_paths, last_seq: int) -> tuple[str, int]:
656
+ """Render new human replies (reply_history) into a prompt block.
657
+
658
+ Returns (rendered_text, max_seq_seen).
659
+ """
660
+
661
+ history_dir = getattr(reply_paths, "reply_history_dir", None)
662
+ if history_dir is None:
663
+ return "", last_seq
664
+ if not history_dir.exists() or not history_dir.is_dir():
665
+ return "", last_seq
666
+
667
+ entries: list[tuple[int, Path]] = []
668
+ try:
669
+ for child in history_dir.iterdir():
670
+ try:
671
+ if not child.is_dir():
672
+ continue
673
+ name = child.name
674
+ if not (len(name) == 4 and name.isdigit()):
675
+ continue
676
+ seq = int(name)
677
+ if seq <= last_seq:
678
+ continue
679
+ entries.append((seq, child))
680
+ except OSError:
681
+ continue
682
+ except OSError:
683
+ return "", last_seq
684
+
685
+ if not entries:
686
+ return "", last_seq
687
+
688
+ entries.sort(key=lambda x: x[0])
689
+ max_seq = max(seq for seq, _ in entries)
690
+
691
+ blocks: list[str] = []
692
+ for seq, entry_dir in entries:
693
+ reply_path = entry_dir / "USER_REPLY.md"
694
+ reply, errors = (
695
+ parse_user_reply(reply_path)
696
+ if reply_path.exists()
697
+ else (None, ["USER_REPLY.md missing"])
698
+ )
699
+
700
+ block_lines: list[str] = [f"[USER_REPLY {seq:04d}]"]
701
+ if errors:
702
+ block_lines.append("Errors:\n- " + "\n- ".join(errors))
703
+ if reply is not None:
704
+ if reply.title:
705
+ block_lines.append(f"Title: {reply.title}")
706
+ if reply.body:
707
+ block_lines.append(reply.body)
708
+
709
+ attachments: list[str] = []
710
+ try:
711
+ for child in sorted(entry_dir.iterdir(), key=lambda p: p.name):
712
+ try:
713
+ if child.name.startswith("."):
714
+ continue
715
+ if child.name == "USER_REPLY.md":
716
+ continue
717
+ if child.is_dir():
718
+ continue
719
+ attachments.append(safe_relpath(child, self._workspace_root))
720
+ except OSError:
721
+ continue
722
+ except OSError:
723
+ attachments = []
724
+
725
+ if attachments:
726
+ block_lines.append("Attachments:\n- " + "\n- ".join(attachments))
727
+
728
+ blocks.append("\n".join(block_lines).strip())
729
+
730
+ rendered = "\n\n".join(blocks).strip()
731
+ return rendered, max_seq
732
+
733
+ def _build_prompt(
734
+ self,
735
+ *,
736
+ ticket_path: Path,
737
+ ticket_doc,
738
+ last_agent_output: Optional[str],
739
+ last_checkpoint_error: Optional[str] = None,
740
+ commit_required: bool = False,
741
+ commit_attempt: int = 0,
742
+ commit_max_attempts: int = 2,
743
+ outbox_paths,
744
+ lint_errors: Optional[list[str]],
745
+ reply_context: Optional[str] = None,
746
+ previous_ticket_content: Optional[str] = None,
747
+ ) -> str:
748
+ rel_ticket = safe_relpath(ticket_path, self._workspace_root)
749
+ rel_dispatch_dir = safe_relpath(outbox_paths.dispatch_dir, self._workspace_root)
750
+ rel_dispatch_path = safe_relpath(
751
+ outbox_paths.dispatch_path, self._workspace_root
752
+ )
753
+
754
+ header = (
755
+ "You are running inside Codex AutoRunner (CAR) in a ticket-based workflow.\n"
756
+ "Complete the current ticket by making changes in the repo.\n\n"
757
+ "How to operate within CAR:\n"
758
+ f"- Current ticket file: {rel_ticket}\n"
759
+ "- Ticket completion is controlled by YAML frontmatter: set 'done: true' when finished.\n"
760
+ "- To message the user, optionally write attachments first to the dispatch directory, then write DISPATCH.md last.\n"
761
+ f" - Dispatch directory: {rel_dispatch_dir}\n"
762
+ f" - DISPATCH.md path: {rel_dispatch_path}\n"
763
+ " DISPATCH.md frontmatter supports: mode: notify|pause (pause will wait for a user response; notify will continue without waiting for user input).\n"
764
+ " Example: `---\\nmode: pause\\n---\\nNeed clarification on X before proceeding.`\n"
765
+ "- No need to dispatch a final notification to the user; your final turn summary is dispatched automatically. Only dispatch if you want something important to stand out to the user, or if you need their input (pause).\n"
766
+ "- If you are completely blocked (missing info, unclear requirements, external dependency), dispatch with mode: pause immediately rather than guessing.\n"
767
+ "- You may create new tickets only if blocking the current SPEC or if the current ticket is too ambiguous and you want to scope it out further. Keep tickets minimal and avoid scope creep.\n"
768
+ "- Avoid stubs, TODOs, or placeholder logic. Either implement fully, create a follow-up ticket, or pause for user input.\n"
769
+ "- Only set 'done: true' when the ticket is truly complete. If partially done, update the ticket body with progress so the next agent can continue.\n"
770
+ "- Each ticket is handled by a new series of agents in a loop, where each new agent gets the context of the previous agent. No context is shared across tickets EXCEPT via the workspace files.\n"
771
+ "- You may update or add new workspace docs and add files under `.codex-autorunner/workspace/` to leave context for future agents.\n"
772
+ "- active_context and spec are ALWAYS passed to each agent and should be considered the most precious context.\n"
773
+ "- decisions.md: can contain conditional decision context that many only be relevant to some tickets.\n"
774
+ "- If you create new documents that future agents should reference, modify their tickets and leave a pointer to your new files.\n"
775
+ "- All files and folders under `.codex-autorunner/workspace/` are viewable and editable by the user. If you need the user's input on something, make sure it's in the workspace including copies of any artifacts they should review.\n"
776
+ "- Do NOT add any files under `.codex-autorunner/` to git unless they are already tracked and not gitignored."
777
+ )
778
+
779
+ checkpoint_block = ""
780
+ if last_checkpoint_error:
781
+ checkpoint_block = (
782
+ "\n\n---\n\n"
783
+ "WARNING: The previous checkpoint git commit failed (often due to pre-commit hooks).\n"
784
+ "Resolve this before proceeding, or future turns may fail to checkpoint.\n\n"
785
+ "Checkpoint error:\n"
786
+ f"{last_checkpoint_error}\n"
787
+ )
788
+
789
+ commit_block = ""
790
+ if commit_required:
791
+ attempts_remaining = max(commit_max_attempts - commit_attempt + 1, 0)
792
+ commit_block = (
793
+ "\n\n---\n\n"
794
+ "ACTION REQUIRED: Commit your changes, ensuring any pre-commit hooks pass.\n"
795
+ "- Use a meaningful commit message that matches what you implemented.\n"
796
+ "- If hooks fail, fix the underlying issues and retry the commit.\n"
797
+ f"- Attempts remaining before user intervention: {attempts_remaining}\n"
798
+ )
799
+
800
+ if lint_errors:
801
+ lint_block = (
802
+ "\n\nTicket frontmatter lint failed. Fix ONLY the ticket frontmatter to satisfy:\n- "
803
+ + "\n- ".join(lint_errors)
804
+ + "\n"
805
+ )
806
+ else:
807
+ lint_block = ""
808
+
809
+ reply_block = ""
810
+ if reply_context:
811
+ reply_block = (
812
+ "\n\n---\n\nHUMAN REPLIES (from reply_history; newest since last turn):\n"
813
+ + reply_context
814
+ + "\n"
815
+ )
816
+
817
+ workspace_block = ""
818
+ workspace_docs: list[tuple[str, str, str]] = []
819
+ for key, label in (
820
+ ("active_context", "Active context"),
821
+ ("decisions", "Decisions"),
822
+ ("spec", "Spec"),
823
+ ):
824
+ path = workspace_doc_path(self._workspace_root, key)
825
+ try:
826
+ if not path.exists():
827
+ continue
828
+ content = path.read_text(encoding="utf-8")
829
+ except OSError as exc:
830
+ _logger.debug("workspace doc read failed for %s: %s", path, exc)
831
+ continue
832
+ snippet = (content or "").strip()
833
+ if not snippet:
834
+ continue
835
+ workspace_docs.append(
836
+ (
837
+ label,
838
+ safe_relpath(path, self._workspace_root),
839
+ snippet[:WORKSPACE_DOC_MAX_CHARS],
840
+ )
841
+ )
842
+
843
+ if workspace_docs:
844
+ blocks = ["Workspace docs (truncated; skip if not relevant):"]
845
+ for label, rel, body in workspace_docs:
846
+ blocks.append(f"{label} [{rel}]:\n{body}")
847
+ workspace_block = "\n\n---\n\n" + "\n\n".join(blocks) + "\n"
848
+
849
+ prev_ticket_block = ""
850
+ if previous_ticket_content:
851
+ prev_ticket_block = (
852
+ "\n\n---\n\n"
853
+ "PREVIOUS TICKET CONTEXT (for reference only; do not edit):\n"
854
+ + previous_ticket_content
855
+ + "\n"
856
+ )
857
+
858
+ ticket_block = (
859
+ "\n\n---\n\n"
860
+ "TICKET CONTENT (edit this file to track progress; update frontmatter.done when complete):\n"
861
+ f"PATH: {rel_ticket}\n"
862
+ "\n" + ticket_path.read_text(encoding="utf-8")
863
+ )
864
+
865
+ prev_block = ""
866
+ if last_agent_output:
867
+ prev_block = (
868
+ "\n\n---\n\nPREVIOUS AGENT OUTPUT (same ticket):\n" + last_agent_output
869
+ )
870
+
871
+ return (
872
+ header
873
+ + checkpoint_block
874
+ + commit_block
875
+ + lint_block
876
+ + workspace_block
877
+ + reply_block
878
+ + prev_ticket_block
879
+ + ticket_block
880
+ + prev_block
881
+ )