codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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 (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from textwrap import dedent
5
+
6
+ LINTER_BASENAME = "lint_tickets.py"
7
+ LINTER_REL_PATH = Path(".codex-autorunner/bin") / LINTER_BASENAME
8
+
9
+ # Self-contained portable linter (PyYAML optional but preferred).
10
+ _SCRIPT = dedent(
11
+ """\
12
+ #!/usr/bin/env python3
13
+ \"\"\"Portable ticket frontmatter linter (no project venv required).
14
+
15
+ - Validates ticket filenames (TICKET-<number>[suffix].md, e.g. TICKET-001-foo.md)
16
+ - Parses YAML frontmatter for each .codex-autorunner/tickets/TICKET-*.md
17
+ - Validates required keys: agent (string) and done (bool)
18
+ - Exits non-zero on any error
19
+ \"\"\"
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import Any, List, Optional, Tuple
27
+
28
+ try:
29
+ import yaml # type: ignore
30
+ except ImportError: # pragma: no cover
31
+ sys.stderr.write(
32
+ "PyYAML is required to lint tickets. Install with:\\n"
33
+ " python3 -m pip install --user pyyaml\\n"
34
+ )
35
+ sys.exit(2)
36
+
37
+
38
+ _TICKET_NAME_RE = re.compile(r"^TICKET-(\\d{3,})(?:[^/]*)\\.md$", re.IGNORECASE)
39
+
40
+
41
+ def _ticket_paths(tickets_dir: Path) -> Tuple[List[Path], List[str]]:
42
+ \"\"\"Return sorted ticket paths along with filename lint errors.\"\"\"
43
+
44
+ tickets: List[tuple[int, Path]] = []
45
+ errors: List[str] = []
46
+ index_to_paths: dict[int, List[Path]] = {}
47
+ for path in sorted(tickets_dir.iterdir()):
48
+ if not path.is_file():
49
+ continue
50
+ if path.name == "AGENTS.md":
51
+ continue
52
+ match = _TICKET_NAME_RE.match(path.name)
53
+ if not match:
54
+ errors.append(
55
+ f\"{path}: Invalid ticket filename; expected TICKET-<number>[suffix].md (e.g. TICKET-001-foo.md)\"
56
+ )
57
+ continue
58
+ try:
59
+ idx = int(match.group(1))
60
+ except ValueError:
61
+ errors.append(
62
+ f\"{path}: Invalid ticket filename; ticket number must be digits (e.g. 001)\"
63
+ )
64
+ continue
65
+ tickets.append((idx, path))
66
+ # Track paths by index to detect duplicates
67
+ if idx not in index_to_paths:
68
+ index_to_paths[idx] = []
69
+ index_to_paths[idx].append(path)
70
+ tickets.sort(key=lambda pair: pair[0])
71
+
72
+ # Check for duplicate indices
73
+ for idx, paths in index_to_paths.items():
74
+ if len(paths) > 1:
75
+ paths_str = ", ".join([str(p) for p in paths])
76
+ errors.append(
77
+ f\"Duplicate ticket index {idx:03d}: multiple files share the same index ({paths_str}). \"
78
+ \"Rename or remove duplicates to ensure deterministic ordering.\"
79
+ )
80
+
81
+ return [p for _, p in tickets], errors
82
+
83
+
84
+ def _split_frontmatter(text: str) -> Tuple[Optional[str], List[str]]:
85
+ if not text:
86
+ return None, ["Empty file; missing YAML frontmatter."]
87
+
88
+ lines = text.splitlines()
89
+ if not lines or lines[0].strip() != "---":
90
+ return None, ["Missing YAML frontmatter (expected leading '---')."]
91
+
92
+ end_idx: Optional[int] = None
93
+ for idx in range(1, len(lines)):
94
+ if lines[idx].strip() in ("---", "..."):
95
+ end_idx = idx
96
+ break
97
+
98
+ if end_idx is None:
99
+ return None, ["Frontmatter is not closed (missing trailing '---')."]
100
+
101
+ fm_yaml = "\\n".join(lines[1:end_idx])
102
+ return fm_yaml, []
103
+
104
+
105
+ def _parse_yaml(fm_yaml: Optional[str]) -> Tuple[dict[str, Any], List[str]]:
106
+ if fm_yaml is None:
107
+ return {}, ["Missing or invalid YAML frontmatter (expected a mapping)."]
108
+
109
+ try:
110
+ loaded = yaml.safe_load(fm_yaml)
111
+ except yaml.YAMLError as exc: # type: ignore[attr-defined]
112
+ return {}, [f"YAML parse error: {exc}"]
113
+
114
+ if loaded is None:
115
+ return {}, ["Missing or invalid YAML frontmatter (expected a mapping)."]
116
+
117
+ if not isinstance(loaded, dict):
118
+ return {}, ["Invalid YAML frontmatter (expected a mapping)."]
119
+
120
+ return loaded, []
121
+
122
+
123
+ def _lint_frontmatter(data: dict[str, Any]) -> List[str]:
124
+ errors: List[str] = []
125
+
126
+ agent = data.get("agent")
127
+ if not isinstance(agent, str) or not agent.strip():
128
+ errors.append("frontmatter.agent is required and must be a non-empty string.")
129
+
130
+ done = data.get("done")
131
+ if not isinstance(done, bool):
132
+ errors.append("frontmatter.done is required and must be a boolean.")
133
+
134
+ return errors
135
+
136
+
137
+ def lint_ticket(path: Path) -> List[str]:
138
+ try:
139
+ raw = path.read_text(encoding="utf-8")
140
+ except Exception as exc: # noqa: BLE001
141
+ return [f"{path}: Unable to read file ({exc})."]
142
+
143
+ fm_yaml, fm_errors = _split_frontmatter(raw)
144
+ if fm_errors:
145
+ return [f"{path}: {msg}" for msg in fm_errors]
146
+
147
+ data, parse_errors = _parse_yaml(fm_yaml)
148
+ if parse_errors:
149
+ return [f"{path}: {msg}" for msg in parse_errors]
150
+
151
+ lint_errors = _lint_frontmatter(data)
152
+ return [f"{path}: {msg}" for msg in lint_errors]
153
+
154
+
155
+ def main() -> int:
156
+ script_dir = Path(__file__).resolve().parent
157
+ tickets_dir = script_dir.parent / "tickets"
158
+
159
+ if not tickets_dir.exists():
160
+ sys.stderr.write(
161
+ f"Tickets directory not found: {tickets_dir}\\n"
162
+ "Run from a Codex Autorunner repo with .codex-autorunner/tickets present.\\n"
163
+ )
164
+ return 2
165
+
166
+ errors: List[str] = []
167
+ ticket_paths, name_errors = _ticket_paths(tickets_dir)
168
+ errors.extend(name_errors)
169
+
170
+ for path in ticket_paths:
171
+ errors.extend(lint_ticket(path))
172
+
173
+ if not ticket_paths:
174
+ if errors:
175
+ for msg in errors:
176
+ sys.stderr.write(msg + "\\n")
177
+ return 1
178
+ sys.stderr.write(f"No tickets found in {tickets_dir}\\n")
179
+ return 1
180
+
181
+ if errors:
182
+ for msg in errors:
183
+ sys.stderr.write(msg + "\\n")
184
+ return 1
185
+
186
+ sys.stdout.write(f\"OK: {len(ticket_paths)} ticket(s) linted.\\n\")
187
+ return 0
188
+
189
+
190
+ if __name__ == \"__main__\": # pragma: no cover
191
+ sys.exit(main())
192
+ """
193
+ )
194
+
195
+
196
+ def ensure_ticket_linter(repo_root: Path, *, force: bool = False) -> Path:
197
+ """
198
+ Ensure a portable ticket frontmatter linter exists under .codex-autorunner/bin.
199
+ The file is always considered generated; it may be refreshed when the content changes.
200
+ """
201
+
202
+ linter_path = repo_root / LINTER_REL_PATH
203
+ linter_path.parent.mkdir(parents=True, exist_ok=True)
204
+
205
+ existing = None
206
+ if linter_path.exists():
207
+ try:
208
+ existing = linter_path.read_text(encoding="utf-8")
209
+ except OSError:
210
+ existing = None
211
+ if not force and existing == _SCRIPT:
212
+ return linter_path
213
+
214
+ linter_path.write_text(_SCRIPT, encoding="utf-8")
215
+ # Ensure executable bit for user.
216
+ mode = linter_path.stat().st_mode
217
+ linter_path.chmod(mode | 0o111)
218
+ return linter_path
@@ -0,0 +1,494 @@
1
+ """Portable ticket management CLI (self-contained, no repo imports)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ MANAGER_BASENAME = "ticket_tool.py"
8
+ MANAGER_REL_PATH = Path(".codex-autorunner/bin") / MANAGER_BASENAME
9
+
10
+ _SCRIPT = """#!/usr/bin/env python3
11
+ \"\"\"Manage Codex Autorunner tickets (list, insert, move, create, lint).
12
+
13
+ Commands:
14
+ list Show ticket order with titles/done flags.
15
+ lint Validate ticket filenames and frontmatter.
16
+ insert --before N Shift tickets >= N up by COUNT (default 1).
17
+ insert --after N Shift tickets > N up by COUNT (default 1).
18
+ Optionally create a ticket in the new slot.
19
+ move --start A --to B Move ticket/block starting at A (or A..END)
20
+ so it begins at position B (1-indexed).
21
+ create --title "..." Create a new ticket at the next or specified
22
+ index. Use --at to place into a gap.
23
+
24
+ Examples:
25
+ ticket_tool.py list
26
+ ticket_tool.py insert --before 3
27
+ ticket_tool.py create --title "Investigate flaky test" --at 3
28
+ ticket_tool.py move --start 5 --end 7 --to 2
29
+ ticket_tool.py lint
30
+
31
+ Notes:
32
+ - Filenames must match TICKET-<number>[suffix].md.
33
+ - PyYAML is required (pip install pyyaml) for linting/title extraction.
34
+ - The tool is intentionally dependency-light and safe to run from any
35
+ virtualenv (or none).
36
+ \"\"\"
37
+
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import re
42
+ import sys
43
+ from dataclasses import dataclass
44
+ from pathlib import Path
45
+ from typing import List, Optional, Sequence, Tuple
46
+
47
+ try:
48
+ import yaml # type: ignore
49
+ except ImportError: # pragma: no cover
50
+ yaml = None
51
+
52
+ _TICKET_NAME_RE = re.compile(r"^TICKET-(\\d{3,})([^/]*)\\.md$", re.IGNORECASE)
53
+
54
+
55
+ @dataclass
56
+ class TicketFile:
57
+ index: int
58
+ path: Path
59
+ suffix: str
60
+ title: Optional[str]
61
+ done: Optional[bool]
62
+
63
+
64
+ def _ticket_dir(repo_root: Path) -> Path:
65
+ return repo_root / ".codex-autorunner" / "tickets"
66
+
67
+
68
+ def _ticket_paths(ticket_dir: Path) -> Tuple[List[Path], List[str]]:
69
+ tickets: List[tuple[int, Path, str]] = []
70
+ errors: List[str] = []
71
+ for path in sorted(ticket_dir.iterdir()):
72
+ if not path.is_file():
73
+ continue
74
+ if path.name == "AGENTS.md":
75
+ continue
76
+ m = _TICKET_NAME_RE.match(path.name)
77
+ if not m:
78
+ errors.append(
79
+ f"{path}: Invalid ticket filename; expected TICKET-<number>[suffix].md"
80
+ )
81
+ continue
82
+ try:
83
+ idx = int(m.group(1))
84
+ except ValueError:
85
+ errors.append(f"{path}: Invalid ticket filename; number must be digits")
86
+ continue
87
+ tickets.append((idx, path, m.group(2)))
88
+ tickets.sort(key=lambda t: t[0])
89
+ return [p for _, p, _ in tickets], errors
90
+
91
+
92
+ def _split_frontmatter(text: str):
93
+ if not text or not text.lstrip().startswith("---"):
94
+ return None, ["Missing YAML frontmatter (expected leading '---')."]
95
+ lines = text.splitlines()
96
+ end_idx = None
97
+ for idx in range(1, len(lines)):
98
+ if lines[idx].strip() in ("---", "..."):
99
+ end_idx = idx
100
+ break
101
+ if end_idx is None:
102
+ return None, ["Frontmatter is not closed (missing trailing '---')."]
103
+ fm_yaml = "\\n".join(lines[1:end_idx])
104
+ return fm_yaml, []
105
+
106
+
107
+ def _parse_yaml(fm_yaml: Optional[str]):
108
+ if fm_yaml is None:
109
+ return {}, ["Missing or invalid YAML frontmatter (expected a mapping)."]
110
+ if yaml is None:
111
+ return {}, [
112
+ "PyYAML is required to lint tickets. Install with: python3 -m pip install --user pyyaml"
113
+ ]
114
+ try:
115
+ loaded = yaml.safe_load(fm_yaml)
116
+ except Exception as exc: # noqa: BLE001
117
+ return {}, [f"YAML parse error: {exc}"]
118
+ if loaded is None or not isinstance(loaded, dict):
119
+ return {}, ["Invalid YAML frontmatter (expected a mapping)."]
120
+ return loaded, []
121
+
122
+
123
+ def _lint_frontmatter(data: dict):
124
+ errors: List[str] = []
125
+ agent = data.get("agent")
126
+ if not isinstance(agent, str) or not agent.strip():
127
+ errors.append("frontmatter.agent is required and must be a non-empty string.")
128
+ done = data.get("done")
129
+ if not isinstance(done, bool):
130
+ errors.append("frontmatter.done is required and must be a boolean.")
131
+ return errors
132
+
133
+
134
+ def _read_ticket(path: Path) -> Tuple[Optional[TicketFile], List[str]]:
135
+ try:
136
+ raw = path.read_text(encoding="utf-8")
137
+ except OSError as exc:
138
+ return None, [f"{path}: Unable to read file ({exc})."]
139
+
140
+ fm_yaml, fm_errors = _split_frontmatter(raw)
141
+ if fm_errors:
142
+ return None, [f"{path}: {msg}" for msg in fm_errors]
143
+
144
+ data, parse_errors = _parse_yaml(fm_yaml)
145
+ if parse_errors:
146
+ return None, [f"{path}: {msg}" for msg in parse_errors]
147
+
148
+ lint_errors = _lint_frontmatter(data)
149
+ if lint_errors:
150
+ return None, [f"{path}: {msg}" for msg in lint_errors]
151
+
152
+ title = data.get("title") if isinstance(data, dict) else None
153
+ done_val = data.get("done") if isinstance(data, dict) else None
154
+
155
+ m = _TICKET_NAME_RE.match(path.name)
156
+ idx = int(m.group(1)) if m else 0
157
+ suffix = m.group(2) if m else ""
158
+ return TicketFile(index=idx, path=path, suffix=suffix, title=title, done=done_val), []
159
+
160
+
161
+ def _ticket_files(ticket_dir: Path) -> Tuple[List[TicketFile], List[str]]:
162
+ paths, name_errors = _ticket_paths(ticket_dir)
163
+ tickets: List[TicketFile] = []
164
+ errors = list(name_errors)
165
+ for path in paths:
166
+ ticket, errs = _read_ticket(path)
167
+ if ticket:
168
+ tickets.append(ticket)
169
+ errors.extend(errs)
170
+ tickets.sort(key=lambda t: t.index)
171
+ return tickets, errors
172
+
173
+
174
+ def _pad_width(indices: Sequence[int]) -> int:
175
+ if not indices:
176
+ return 3
177
+ return max(3, max(len(str(i)) for i in indices))
178
+
179
+
180
+ def _fmt_name(index: int, suffix: str, width: int) -> str:
181
+ return f"TICKET-{index:0{width}d}{suffix}.md"
182
+
183
+
184
+ def _safe_renames(mapping: Sequence[tuple[Path, Path]]) -> None:
185
+ temp_pairs: list[tuple[Path, Path]] = []
186
+ for src, dst in mapping:
187
+ if src == dst:
188
+ continue
189
+ temp = src.with_name(src.name + ".tmp-move")
190
+ counter = 0
191
+ while temp.exists():
192
+ counter += 1
193
+ temp = src.with_name(f"{src.name}.tmp-move-{counter}")
194
+ src.rename(temp)
195
+ temp_pairs.append((temp, dst))
196
+
197
+ for temp, dst in temp_pairs:
198
+ dst.parent.mkdir(parents=True, exist_ok=True)
199
+ temp.rename(dst)
200
+
201
+
202
+ def cmd_list(ticket_dir: Path) -> int:
203
+ tickets, errors = _ticket_files(ticket_dir)
204
+ if errors:
205
+ for msg in errors:
206
+ sys.stderr.write(msg + "\\n")
207
+ width = _pad_width([t.index for t in tickets])
208
+ for t in tickets:
209
+ status = "done" if t.done else "open"
210
+ title = f" - {t.title}" if t.title else ""
211
+ sys.stdout.write(f"{t.index:0{width}d} [{status}] {t.path.name}{title}\\n")
212
+ if errors:
213
+ return 1
214
+ return 0
215
+
216
+
217
+ def cmd_lint(ticket_dir: Path) -> int:
218
+ paths, name_errors = _ticket_paths(ticket_dir)
219
+ errors = list(name_errors)
220
+ for path in paths:
221
+ _, errs = _read_ticket(path)
222
+ errors.extend(errs)
223
+
224
+ if errors:
225
+ for msg in errors:
226
+ sys.stderr.write(msg + "\\n")
227
+ return 1
228
+ sys.stdout.write(f"OK: {len(paths)} ticket(s) linted.\\n")
229
+ return 0
230
+
231
+
232
+ def _shift(ticket_dir: Path, start_idx: int, delta: int) -> None:
233
+ if delta == 0:
234
+ return
235
+ paths, errors = _ticket_paths(ticket_dir)
236
+ if errors:
237
+ raise ValueError("Cannot shift while filenames are invalid; run lint first.")
238
+ iterable = reversed(paths) if delta > 0 else paths
239
+ width = _pad_width([_parse_index(p.name) for p in paths] + [start_idx + delta])
240
+ mapping: list[tuple[Path, Path]] = []
241
+ for path in iterable:
242
+ idx = _parse_index(path.name)
243
+ if idx is None or idx < start_idx:
244
+ continue
245
+ new_idx = idx + delta
246
+ if new_idx <= 0:
247
+ raise ValueError("Shift would create non-positive ticket index")
248
+ suffix = _parse_suffix(path.name)
249
+ target = path.with_name(_fmt_name(new_idx, suffix, width))
250
+ mapping.append((path, target))
251
+ _safe_renames(mapping)
252
+
253
+
254
+ def _parse_index(name: str) -> Optional[int]:
255
+ m = _TICKET_NAME_RE.match(name)
256
+ return int(m.group(1)) if m else None
257
+
258
+
259
+ def _parse_suffix(name: str) -> str:
260
+ m = _TICKET_NAME_RE.match(name)
261
+ return m.group(2) if m else ""
262
+
263
+
264
+ def _create_ticket_file(ticket_dir: Path, *, index: int, title: str, agent: str, existing_indices: List[int]) -> Path:
265
+ width = _pad_width(existing_indices + [index])
266
+ name = _fmt_name(index, "", width)
267
+ path = ticket_dir / name
268
+ if path.exists():
269
+ raise ValueError(f"Ticket index {index} already exists: {path}")
270
+ path.parent.mkdir(parents=True, exist_ok=True)
271
+ title_scalar = _yaml_scalar(title)
272
+ agent_scalar = _yaml_scalar(agent)
273
+ body = (
274
+ f"---\\n"
275
+ f"title: {title_scalar}\\n"
276
+ f"agent: {agent_scalar}\\n"
277
+ f"done: false\\n"
278
+ f"---\\n\\n"
279
+ f"## Goal\\n- \\n"
280
+ )
281
+ path.write_text(body, encoding="utf-8")
282
+ return path
283
+
284
+
285
+ def cmd_insert(
286
+ ticket_dir: Path,
287
+ *,
288
+ before: Optional[int],
289
+ after: Optional[int],
290
+ count: int,
291
+ title: Optional[str],
292
+ agent: str,
293
+ ) -> int:
294
+ if (before is None) == (after is None):
295
+ sys.stderr.write("Specify exactly one of --before or --after.\\n")
296
+ return 2
297
+ if title and count != 1:
298
+ sys.stderr.write("--title is only supported with --count 1.\\n")
299
+ return 2
300
+ anchor = before if before is not None else after + 1 # type: ignore[operator]
301
+ if anchor is None or anchor < 1:
302
+ sys.stderr.write("Anchor index must be >= 1.\\n")
303
+ return 2
304
+ try:
305
+ _shift(ticket_dir, anchor, count)
306
+ except ValueError as exc:
307
+ sys.stderr.write(str(exc) + "\\n")
308
+ return 1
309
+ if title:
310
+ tickets, errors = _ticket_files(ticket_dir)
311
+ if errors:
312
+ for msg in errors:
313
+ sys.stderr.write(msg + "\\n")
314
+ return 1
315
+ existing_indices = [t.index for t in tickets]
316
+ try:
317
+ path = _create_ticket_file(
318
+ ticket_dir, index=anchor, title=title, agent=agent, existing_indices=existing_indices
319
+ )
320
+ except ValueError as exc:
321
+ sys.stderr.write(str(exc) + "\\n")
322
+ return 1
323
+ sys.stdout.write(f"Inserted gap and created {path}\\n")
324
+ else:
325
+ sys.stdout.write(
326
+ f"Inserted gap at index {anchor}; run create --at {anchor} to add a ticket.\\n"
327
+ )
328
+ return 0
329
+
330
+
331
+ def _yaml_scalar(value: str) -> str:
332
+ '''Render a Python string as a safe single-line YAML scalar.
333
+
334
+ Returns a double-quoted value with backslashes, quotes, and newlines escaped.
335
+ '''
336
+
337
+ escaped = (
338
+ value.replace("\\\\", "\\\\\\\\")
339
+ .replace('"', '\\\\"')
340
+ .replace("\\n", "\\\\n")
341
+ )
342
+ return f'"{escaped}"'
343
+
344
+
345
+ def cmd_create(ticket_dir: Path, *, title: str, agent: str, at: Optional[int]) -> int:
346
+ tickets, errors = _ticket_files(ticket_dir)
347
+ if errors:
348
+ for msg in errors:
349
+ sys.stderr.write(msg + "\\n")
350
+ return 1
351
+ existing_indices = [t.index for t in tickets]
352
+ next_index = max(existing_indices) + 1 if existing_indices else 1
353
+ index = at or next_index
354
+ if index in existing_indices:
355
+ sys.stderr.write(
356
+ f"Ticket index {index} already exists. Use insert to open a gap or choose --at another index.\\n"
357
+ )
358
+ return 1
359
+ try:
360
+ path = _create_ticket_file(
361
+ ticket_dir, index=index, title=title, agent=agent, existing_indices=existing_indices
362
+ )
363
+ except ValueError as exc:
364
+ sys.stderr.write(str(exc) + "\\n")
365
+ return 1
366
+ sys.stdout.write(f"Created {path}\\n")
367
+ return 0
368
+
369
+
370
+ def cmd_move(ticket_dir: Path, *, start: int, end: Optional[int], to: int) -> int:
371
+ if start < 1 or to < 1:
372
+ sys.stderr.write("Indices must be >= 1.\\n")
373
+ return 2
374
+ tickets, errors = _ticket_files(ticket_dir)
375
+ if errors:
376
+ for msg in errors:
377
+ sys.stderr.write(msg + "\\n")
378
+ return 1
379
+ indices = [t.index for t in tickets]
380
+ if start not in indices:
381
+ sys.stderr.write(f"No ticket at index {start}.\\n")
382
+ return 1
383
+ end_idx = end if end is not None else start
384
+ if end_idx < start:
385
+ sys.stderr.write("--end must be >= --start.\\n")
386
+ return 2
387
+ block = [t for t in tickets if start <= t.index <= end_idx]
388
+ if not block:
389
+ sys.stderr.write("No tickets in the specified move range.\\n")
390
+ return 1
391
+ remaining = [t for t in tickets if t not in block]
392
+ insert_pos = to - 1
393
+ if insert_pos < 0 or insert_pos > len(remaining):
394
+ sys.stderr.write("Target position is out of range.\\n")
395
+ return 1
396
+ new_order = remaining[:insert_pos] + block + remaining[insert_pos:]
397
+ width = _pad_width([t.index for t in new_order])
398
+
399
+ mapping: list[tuple[Path, Path]] = []
400
+ for new_idx, ticket in enumerate(new_order, start=1):
401
+ target = ticket.path.with_name(_fmt_name(new_idx, ticket.suffix, width))
402
+ mapping.append((ticket.path, target))
403
+ _safe_renames(mapping)
404
+ return 0
405
+
406
+
407
+ def main(argv: Optional[Sequence[str]] = None) -> int:
408
+ parser = argparse.ArgumentParser(description="Manage Codex Autorunner tickets.")
409
+ sub = parser.add_subparsers(dest="cmd", required=True)
410
+
411
+ sub.add_parser("list", help="List tickets in order")
412
+ sub.add_parser("lint", help="Validate ticket filenames and frontmatter")
413
+
414
+ insert_p = sub.add_parser("insert", help="Insert gap by shifting tickets")
415
+ insert_group = insert_p.add_mutually_exclusive_group(required=True)
416
+ insert_group.add_argument("--before", type=int, help="First index to shift upward")
417
+ insert_group.add_argument("--after", type=int, help="Shift tickets after this index")
418
+ insert_p.add_argument("--count", type=int, default=1, help="How many slots to insert (default 1)")
419
+ insert_p.add_argument(
420
+ "--title",
421
+ help="Create a ticket in the new slot (requires --count 1)",
422
+ )
423
+ insert_p.add_argument(
424
+ "--agent",
425
+ default="codex",
426
+ help="Frontmatter agent when creating with --title (default: codex)",
427
+ )
428
+
429
+ create_p = sub.add_parser("create", help="Create a new ticket")
430
+ create_p.add_argument("--title", required=True, help="Ticket title")
431
+ create_p.add_argument("--agent", default="codex", help="Frontmatter agent (default: codex)")
432
+ create_p.add_argument(
433
+ "--at",
434
+ type=int,
435
+ help="Index to use (must be unused). Defaults to next available index.",
436
+ )
437
+
438
+ move_p = sub.add_parser("move", help="Move a ticket or block to a new position")
439
+ move_p.add_argument("--start", type=int, required=True, help="First index in the block to move")
440
+ move_p.add_argument("--end", type=int, help="Last index in the block (defaults to start)")
441
+ move_p.add_argument("--to", type=int, required=True, help="Destination position (1-indexed)")
442
+
443
+ args = parser.parse_args(argv)
444
+ repo_root = Path.cwd()
445
+ ticket_dir = _ticket_dir(repo_root)
446
+ if not ticket_dir.exists():
447
+ sys.stderr.write(f"Tickets directory not found: {ticket_dir}\\n")
448
+ return 2
449
+
450
+ if args.cmd == "list":
451
+ return cmd_list(ticket_dir)
452
+ if args.cmd == "lint":
453
+ return cmd_lint(ticket_dir)
454
+ if args.cmd == "insert":
455
+ return cmd_insert(
456
+ ticket_dir,
457
+ before=args.before,
458
+ after=args.after,
459
+ count=args.count,
460
+ title=args.title,
461
+ agent=args.agent,
462
+ )
463
+ if args.cmd == "create":
464
+ return cmd_create(ticket_dir, title=args.title, agent=args.agent, at=args.at)
465
+ if args.cmd == "move":
466
+ return cmd_move(ticket_dir, start=args.start, end=args.end, to=args.to)
467
+ parser.error("Unknown command")
468
+ return 2
469
+
470
+
471
+ if __name__ == "__main__": # pragma: no cover
472
+ sys.exit(main())
473
+ """
474
+
475
+
476
+ def ensure_ticket_manager(repo_root: Path, *, force: bool = False) -> Path:
477
+ """Ensure the ticket management CLI exists under .codex-autorunner/bin."""
478
+
479
+ path = repo_root / MANAGER_REL_PATH
480
+ path.parent.mkdir(parents=True, exist_ok=True)
481
+
482
+ existing = None
483
+ if path.exists():
484
+ try:
485
+ existing = path.read_text(encoding="utf-8")
486
+ except OSError:
487
+ existing = None
488
+
489
+ if force or existing != _SCRIPT:
490
+ path.write_text(_SCRIPT, encoding="utf-8")
491
+ mode = path.stat().st_mode
492
+ path.chmod(mode | 0o111)
493
+
494
+ return path