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,432 @@
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
+ move --start A --to B Move ticket/block starting at A (or A..END)
19
+ so it begins at position B (1-indexed).
20
+ create --title \"...\" Create a new ticket at the next or specified
21
+ index. Use --at to place into a gap.
22
+
23
+ Examples:
24
+ ticket_tool.py list
25
+ ticket_tool.py insert --before 3
26
+ ticket_tool.py create --title \"Investigate flaky test\" --at 3
27
+ ticket_tool.py move --start 5 --end 7 --to 2
28
+ ticket_tool.py lint
29
+
30
+ Notes:
31
+ - Filenames must match TICKET-<number>[suffix].md.
32
+ - PyYAML is required (pip install pyyaml) for linting/title extraction.
33
+ - The tool is intentionally dependency-light and safe to run from any
34
+ virtualenv (or none).
35
+ \"\"\"
36
+
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import re
41
+ import sys
42
+ from dataclasses import dataclass
43
+ from pathlib import Path
44
+ from typing import List, Optional, Sequence, Tuple
45
+
46
+ try:
47
+ import yaml # type: ignore
48
+ except ImportError: # pragma: no cover
49
+ yaml = None
50
+
51
+ _TICKET_NAME_RE = re.compile(r"^TICKET-(\\d{3,})([^/]*)\\.md$", re.IGNORECASE)
52
+
53
+
54
+ @dataclass
55
+ class TicketFile:
56
+ index: int
57
+ path: Path
58
+ suffix: str
59
+ title: Optional[str]
60
+ done: Optional[bool]
61
+
62
+
63
+ def _ticket_dir(repo_root: Path) -> Path:
64
+ return repo_root / ".codex-autorunner" / "tickets"
65
+
66
+
67
+ def _ticket_paths(ticket_dir: Path) -> Tuple[List[Path], List[str]]:
68
+ tickets: List[tuple[int, Path, str]] = []
69
+ errors: List[str] = []
70
+ for path in sorted(ticket_dir.iterdir()):
71
+ if not path.is_file():
72
+ continue
73
+ m = _TICKET_NAME_RE.match(path.name)
74
+ if not m:
75
+ errors.append(
76
+ f\"{path}: Invalid ticket filename; expected TICKET-<number>[suffix].md\"
77
+ )
78
+ continue
79
+ try:
80
+ idx = int(m.group(1))
81
+ except ValueError:
82
+ errors.append(f\"{path}: Invalid ticket filename; number must be digits\")
83
+ continue
84
+ tickets.append((idx, path, m.group(2)))
85
+ tickets.sort(key=lambda t: t[0])
86
+ return [p for _, p, _ in tickets], errors
87
+
88
+
89
+ def _split_frontmatter(text: str):
90
+ if not text or not text.lstrip().startswith(\"---\"):
91
+ return None, [\"Missing YAML frontmatter (expected leading '---').\"]
92
+ lines = text.splitlines()
93
+ end_idx = None
94
+ for idx in range(1, len(lines)):
95
+ if lines[idx].strip() in (\"---\", \"...\"):
96
+ end_idx = idx
97
+ break
98
+ if end_idx is None:
99
+ return None, [\"Frontmatter is not closed (missing trailing '---').\"]
100
+ fm_yaml = \"\\n\".join(lines[1:end_idx])
101
+ return fm_yaml, []
102
+
103
+
104
+ def _parse_yaml(fm_yaml: Optional[str]):
105
+ if fm_yaml is None:
106
+ return {}, [\"Missing or invalid YAML frontmatter (expected a mapping).\"]
107
+ if yaml is None:
108
+ return {}, [
109
+ \"PyYAML is required to lint tickets. Install with: python3 -m pip install --user pyyaml\"
110
+ ]
111
+ try:
112
+ loaded = yaml.safe_load(fm_yaml)
113
+ except Exception as exc: # noqa: BLE001
114
+ return {}, [f\"YAML parse error: {exc}\"]
115
+ if loaded is None or not isinstance(loaded, dict):
116
+ return {}, [\"Invalid YAML frontmatter (expected a mapping).\"]
117
+ return loaded, []
118
+
119
+
120
+ def _lint_frontmatter(data: dict):
121
+ errors: List[str] = []
122
+ agent = data.get(\"agent\")
123
+ if not isinstance(agent, str) or not agent.strip():
124
+ errors.append(\"frontmatter.agent is required and must be a non-empty string.\")
125
+ done = data.get(\"done\")
126
+ if not isinstance(done, bool):
127
+ errors.append(\"frontmatter.done is required and must be a boolean.\")
128
+ return errors
129
+
130
+
131
+ def _read_ticket(path: Path) -> Tuple[Optional[TicketFile], List[str]]:
132
+ try:
133
+ raw = path.read_text(encoding=\"utf-8\")
134
+ except OSError as exc:
135
+ return None, [f\"{path}: Unable to read file ({exc}).\"]
136
+
137
+ fm_yaml, fm_errors = _split_frontmatter(raw)
138
+ if fm_errors:
139
+ return None, [f\"{path}: {msg}\" for msg in fm_errors]
140
+
141
+ data, parse_errors = _parse_yaml(fm_yaml)
142
+ if parse_errors:
143
+ return None, [f\"{path}: {msg}\" for msg in parse_errors]
144
+
145
+ lint_errors = _lint_frontmatter(data)
146
+ if lint_errors:
147
+ return None, [f\"{path}: {msg}\" for msg in lint_errors]
148
+
149
+ title = data.get(\"title\") if isinstance(data, dict) else None
150
+ done_val = data.get(\"done\") if isinstance(data, dict) else None
151
+
152
+ m = _TICKET_NAME_RE.match(path.name)
153
+ idx = int(m.group(1)) if m else 0
154
+ suffix = m.group(2) if m else \"\"
155
+ return TicketFile(index=idx, path=path, suffix=suffix, title=title, done=done_val), []
156
+
157
+
158
+ def _ticket_files(ticket_dir: Path) -> Tuple[List[TicketFile], List[str]]:
159
+ paths, name_errors = _ticket_paths(ticket_dir)
160
+ tickets: List[TicketFile] = []
161
+ errors = list(name_errors)
162
+ for path in paths:
163
+ ticket, errs = _read_ticket(path)
164
+ if ticket:
165
+ tickets.append(ticket)
166
+ errors.extend(errs)
167
+ tickets.sort(key=lambda t: t.index)
168
+ return tickets, errors
169
+
170
+
171
+ def _pad_width(indices: Sequence[int]) -> int:
172
+ if not indices:
173
+ return 3
174
+ return max(3, max(len(str(i)) for i in indices))
175
+
176
+
177
+ def _fmt_name(index: int, suffix: str, width: int) -> str:
178
+ return f\"TICKET-{index:0{width}d}{suffix}.md\"
179
+
180
+
181
+ def _safe_renames(mapping: Sequence[tuple[Path, Path]]) -> None:
182
+ temp_pairs: list[tuple[Path, Path]] = []
183
+ for src, dst in mapping:
184
+ if src == dst:
185
+ continue
186
+ temp = src.with_name(src.name + \".tmp-move\")
187
+ counter = 0
188
+ while temp.exists():
189
+ counter += 1
190
+ temp = src.with_name(f\"{src.name}.tmp-move-{counter}\")
191
+ src.rename(temp)
192
+ temp_pairs.append((temp, dst))
193
+
194
+ for temp, dst in temp_pairs:
195
+ dst.parent.mkdir(parents=True, exist_ok=True)
196
+ temp.rename(dst)
197
+
198
+
199
+ def cmd_list(ticket_dir: Path) -> int:
200
+ tickets, errors = _ticket_files(ticket_dir)
201
+ if errors:
202
+ for msg in errors:
203
+ sys.stderr.write(msg + \"\\n\")
204
+ width = _pad_width([t.index for t in tickets])
205
+ for t in tickets:
206
+ status = \"done\" if t.done else \"open\"
207
+ title = f\" - {t.title}\" if t.title else \"\"
208
+ sys.stdout.write(f\"{t.index:0{width}d} [{status}] {t.path.name}{title}\\n\")
209
+ if errors:
210
+ return 1
211
+ return 0
212
+
213
+
214
+ def cmd_lint(ticket_dir: Path) -> int:
215
+ paths, name_errors = _ticket_paths(ticket_dir)
216
+ errors = list(name_errors)
217
+ for path in paths:
218
+ _, errs = _read_ticket(path)
219
+ errors.extend(errs)
220
+
221
+ if errors:
222
+ for msg in errors:
223
+ sys.stderr.write(msg + \"\\n\")
224
+ return 1
225
+ sys.stdout.write(f\"OK: {len(paths)} ticket(s) linted.\\n\")
226
+ return 0
227
+
228
+
229
+ def _shift(ticket_dir: Path, start_idx: int, delta: int) -> None:
230
+ if delta == 0:
231
+ return
232
+ paths, errors = _ticket_paths(ticket_dir)
233
+ if errors:
234
+ raise ValueError(\"Cannot shift while filenames are invalid; run lint first.\")
235
+ iterable = reversed(paths) if delta > 0 else paths
236
+ width = _pad_width([_parse_index(p.name) for p in paths] + [start_idx + delta])
237
+ mapping: list[tuple[Path, Path]] = []
238
+ for path in iterable:
239
+ idx = _parse_index(path.name)
240
+ if idx is None or idx < start_idx:
241
+ continue
242
+ new_idx = idx + delta
243
+ if new_idx <= 0:
244
+ raise ValueError(\"Shift would create non-positive ticket index\")
245
+ suffix = _parse_suffix(path.name)
246
+ target = path.with_name(_fmt_name(new_idx, suffix, width))
247
+ mapping.append((path, target))
248
+ _safe_renames(mapping)
249
+
250
+
251
+ def _parse_index(name: str) -> Optional[int]:
252
+ m = _TICKET_NAME_RE.match(name)
253
+ return int(m.group(1)) if m else None
254
+
255
+
256
+ def _parse_suffix(name: str) -> str:
257
+ m = _TICKET_NAME_RE.match(name)
258
+ return m.group(2) if m else \"\"
259
+
260
+
261
+ def cmd_insert(ticket_dir: Path, *, before: Optional[int], after: Optional[int], count: int) -> int:
262
+ if (before is None) == (after is None):
263
+ sys.stderr.write(\"Specify exactly one of --before or --after.\\n\")
264
+ return 2
265
+ anchor = before if before is not None else after + 1 # type: ignore[operator]
266
+ if anchor is None or anchor < 1:
267
+ sys.stderr.write(\"Anchor index must be >= 1.\\n\")
268
+ return 2
269
+ try:
270
+ _shift(ticket_dir, anchor, count)
271
+ except ValueError as exc:
272
+ sys.stderr.write(str(exc) + \"\\n\")
273
+ return 1
274
+ return 0
275
+
276
+
277
+ def _yaml_scalar(value: str) -> str:
278
+ '''Render a Python string as a safe single-line YAML scalar.
279
+
280
+ Returns a double-quoted value with backslashes, quotes, and newlines escaped.
281
+ '''
282
+
283
+ escaped = (
284
+ value.replace("\\\\", "\\\\\\\\")
285
+ .replace('"', '\\\\\"')
286
+ .replace("\\n", "\\\\n")
287
+ )
288
+ return f'"{escaped}"'
289
+
290
+
291
+ def cmd_create(ticket_dir: Path, *, title: str, agent: str, at: Optional[int]) -> int:
292
+ tickets, errors = _ticket_files(ticket_dir)
293
+ if errors:
294
+ for msg in errors:
295
+ sys.stderr.write(msg + \"\\n\")
296
+ return 1
297
+ existing_indices = [t.index for t in tickets]
298
+ next_index = max(existing_indices) + 1 if existing_indices else 1
299
+ index = at or next_index
300
+ if index in existing_indices:
301
+ sys.stderr.write(
302
+ f\"Ticket index {index} already exists. Use insert to open a gap or choose --at another index.\\n\"
303
+ )
304
+ return 1
305
+ width = _pad_width(existing_indices + [index])
306
+ name = _fmt_name(index, \"\", width)
307
+ path = ticket_dir / name
308
+ path.parent.mkdir(parents=True, exist_ok=True)
309
+ title_scalar = _yaml_scalar(title)
310
+ agent_scalar = _yaml_scalar(agent)
311
+ body = (
312
+ f\"---\\n\"
313
+ f\"title: {title_scalar}\\n\"
314
+ f\"agent: {agent_scalar}\\n\"
315
+ f\"done: false\\n\"
316
+ f\"---\\n\\n\"
317
+ f\"## Goal\\n- \\n\"
318
+ )
319
+ path.write_text(body, encoding=\"utf-8\")
320
+ sys.stdout.write(f\"Created {path}\\n\")
321
+ return 0
322
+
323
+
324
+ def cmd_move(ticket_dir: Path, *, start: int, end: Optional[int], to: int) -> int:
325
+ if start < 1 or to < 1:
326
+ sys.stderr.write(\"Indices must be >= 1.\\n\")
327
+ return 2
328
+ tickets, errors = _ticket_files(ticket_dir)
329
+ if errors:
330
+ for msg in errors:
331
+ sys.stderr.write(msg + \"\\n\")
332
+ return 1
333
+ indices = [t.index for t in tickets]
334
+ if start not in indices:
335
+ sys.stderr.write(f\"No ticket at index {start}.\\n\")
336
+ return 1
337
+ end_idx = end if end is not None else start
338
+ if end_idx < start:
339
+ sys.stderr.write(\"--end must be >= --start.\\n\")
340
+ return 2
341
+ block = [t for t in tickets if start <= t.index <= end_idx]
342
+ if not block:
343
+ sys.stderr.write(\"No tickets in the specified move range.\\n\")
344
+ return 1
345
+ remaining = [t for t in tickets if t not in block]
346
+ insert_pos = to - 1
347
+ if insert_pos < 0 or insert_pos > len(remaining):
348
+ sys.stderr.write(\"Target position is out of range.\\n\")
349
+ return 1
350
+ new_order = remaining[:insert_pos] + block + remaining[insert_pos:]
351
+ width = _pad_width([t.index for t in new_order])
352
+
353
+ mapping: list[tuple[Path, Path]] = []
354
+ for new_idx, ticket in enumerate(new_order, start=1):
355
+ target = ticket.path.with_name(_fmt_name(new_idx, ticket.suffix, width))
356
+ mapping.append((ticket.path, target))
357
+ _safe_renames(mapping)
358
+ return 0
359
+
360
+
361
+ def main(argv: Optional[Sequence[str]] = None) -> int:
362
+ parser = argparse.ArgumentParser(description=\"Manage Codex Autorunner tickets.\")
363
+ sub = parser.add_subparsers(dest=\"cmd\", required=True)
364
+
365
+ sub.add_parser(\"list\", help=\"List tickets in order\")
366
+ sub.add_parser(\"lint\", help=\"Validate ticket filenames and frontmatter\")
367
+
368
+ insert_p = sub.add_parser(\"insert\", help=\"Insert gap by shifting tickets\")
369
+ insert_group = insert_p.add_mutually_exclusive_group(required=True)
370
+ insert_group.add_argument(\"--before\", type=int, help=\"First index to shift upward\")
371
+ insert_group.add_argument(\"--after\", type=int, help=\"Shift tickets after this index\")
372
+ insert_p.add_argument(\"--count\", type=int, default=1, help=\"How many slots to insert (default 1)\")
373
+
374
+ create_p = sub.add_parser(\"create\", help=\"Create a new ticket\")
375
+ create_p.add_argument(\"--title\", required=True, help=\"Ticket title\")
376
+ create_p.add_argument(\"--agent\", default=\"codex\", help=\"Frontmatter agent (default: codex)\")
377
+ create_p.add_argument(
378
+ \"--at\",
379
+ type=int,
380
+ help=\"Index to use (must be unused). Defaults to next available index.\",
381
+ )
382
+
383
+ move_p = sub.add_parser(\"move\", help=\"Move a ticket or block to a new position\")
384
+ move_p.add_argument(\"--start\", type=int, required=True, help=\"First index in the block to move\")
385
+ move_p.add_argument(\"--end\", type=int, help=\"Last index in the block (defaults to start)\")
386
+ move_p.add_argument(\"--to\", type=int, required=True, help=\"Destination position (1-indexed)\")
387
+
388
+ args = parser.parse_args(argv)
389
+ repo_root = Path.cwd()
390
+ ticket_dir = _ticket_dir(repo_root)
391
+ if not ticket_dir.exists():
392
+ sys.stderr.write(f\"Tickets directory not found: {ticket_dir}\\n\")
393
+ return 2
394
+
395
+ if args.cmd == \"list\":
396
+ return cmd_list(ticket_dir)
397
+ if args.cmd == \"lint\":
398
+ return cmd_lint(ticket_dir)
399
+ if args.cmd == \"insert\":
400
+ return cmd_insert(ticket_dir, before=args.before, after=args.after, count=args.count)
401
+ if args.cmd == \"create\":
402
+ return cmd_create(ticket_dir, title=args.title, agent=args.agent, at=args.at)
403
+ if args.cmd == \"move\":
404
+ return cmd_move(ticket_dir, start=args.start, end=args.end, to=args.to)
405
+ parser.error(\"Unknown command\")
406
+ return 2
407
+
408
+
409
+ if __name__ == \"__main__\": # pragma: no cover
410
+ sys.exit(main())
411
+ """
412
+
413
+
414
+ def ensure_ticket_manager(repo_root: Path, *, force: bool = False) -> Path:
415
+ """Ensure the ticket management CLI exists under .codex-autorunner/bin."""
416
+
417
+ path = repo_root / MANAGER_REL_PATH
418
+ path.parent.mkdir(parents=True, exist_ok=True)
419
+
420
+ existing = None
421
+ if path.exists():
422
+ try:
423
+ existing = path.read_text(encoding="utf-8")
424
+ except OSError:
425
+ existing = None
426
+
427
+ if force or existing != _SCRIPT:
428
+ path.write_text(_SCRIPT, encoding="utf-8")
429
+ mode = path.stat().st_mode
430
+ path.chmod(mode | 0o111)
431
+
432
+ return path
@@ -11,6 +11,7 @@ from typing import Optional
11
11
  from urllib.parse import unquote, urlparse
12
12
 
13
13
  from .git_utils import GitError, run_git
14
+ from .update_paths import resolve_update_paths
14
15
 
15
16
 
16
17
  class UpdateInProgressError(RuntimeError):
@@ -55,7 +56,7 @@ def _normalize_update_ref(raw: Optional[str]) -> str:
55
56
 
56
57
 
57
58
  def _update_status_path() -> Path:
58
- return Path.home() / ".codex-autorunner" / "update_status.json"
59
+ return resolve_update_paths().status_path
59
60
 
60
61
 
61
62
  def _write_update_status(status: str, message: str, **extra) -> None:
@@ -134,7 +135,7 @@ def _read_update_status() -> Optional[dict[str, object]]:
134
135
 
135
136
 
136
137
  def _update_lock_path() -> Path:
137
- return Path.home() / ".codex-autorunner" / "update.lock"
138
+ return resolve_update_paths().lock_path
138
139
 
139
140
 
140
141
  def _read_update_lock() -> Optional[dict[str, object]]:
@@ -281,9 +282,7 @@ def _system_update_check(
281
282
  update_cache_dir: Optional[Path] = None,
282
283
  ) -> dict:
283
284
  module_dir = module_dir or Path(__file__).resolve().parent
284
- update_cache_dir = update_cache_dir or (
285
- Path.home() / ".codex-autorunner" / "update_cache"
286
- )
285
+ update_cache_dir = update_cache_dir or resolve_update_paths().cache_dir
287
286
  repo_ref = _normalize_update_ref(repo_ref)
288
287
 
289
288
  repo_root = _resolve_local_repo_root(
@@ -378,6 +377,7 @@ def _system_update_worker(
378
377
  update_dir: Path,
379
378
  logger: logging.Logger,
380
379
  update_target: str = "both",
380
+ skip_checks: bool = False,
381
381
  ) -> None:
382
382
  status_path = _update_status_path()
383
383
  lock_acquired = False
@@ -457,10 +457,14 @@ def _system_update_worker(
457
457
  _run_cmd(["git", "fetch", "origin", repo_ref], cwd=update_dir)
458
458
  _run_cmd(["git", "reset", "--hard", "FETCH_HEAD"], cwd=update_dir)
459
459
 
460
- if os.environ.get("CODEX_AUTORUNNER_SKIP_UPDATE_CHECKS") == "1":
461
- logger.info(
462
- "Skipping update checks (CODEX_AUTORUNNER_SKIP_UPDATE_CHECKS=1)."
463
- )
460
+ skip_checks_env = os.environ.get("CODEX_AUTORUNNER_SKIP_UPDATE_CHECKS") == "1"
461
+ if skip_checks_env or skip_checks:
462
+ if skip_checks_env:
463
+ logger.info(
464
+ "Skipping update checks (CODEX_AUTORUNNER_SKIP_UPDATE_CHECKS=1)."
465
+ )
466
+ else:
467
+ logger.info("Skipping update checks (update.skip_checks=true).")
464
468
  else:
465
469
  logger.info("Running checks...")
466
470
  try:
@@ -526,6 +530,7 @@ def _spawn_update_process(
526
530
  update_dir: Path,
527
531
  logger: logging.Logger,
528
532
  update_target: str = "both",
533
+ skip_checks: bool = False,
529
534
  notify_chat_id: Optional[int] = None,
530
535
  notify_thread_id: Optional[int] = None,
531
536
  notify_reply_to: Optional[int] = None,
@@ -565,14 +570,17 @@ def _spawn_update_process(
565
570
  "--log-path",
566
571
  str(log_path),
567
572
  ]
573
+ if skip_checks:
574
+ cmd.append("--skip-checks")
568
575
  try:
569
- subprocess.Popen(
570
- cmd,
571
- cwd=str(update_dir.parent),
572
- start_new_session=True,
573
- stdout=subprocess.DEVNULL,
574
- stderr=subprocess.DEVNULL,
575
- )
576
+ with log_path.open("a", encoding="utf-8") as log_file:
577
+ subprocess.Popen(
578
+ cmd,
579
+ cwd=str(update_dir.parent),
580
+ start_new_session=True,
581
+ stdout=log_file,
582
+ stderr=log_file,
583
+ )
576
584
  except Exception:
577
585
  logger.exception("Failed to spawn update worker")
578
586
  _write_update_status(
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any, Optional
6
+
7
+ from .state_roots import resolve_global_state_root
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class UpdatePaths:
12
+ status_path: Path
13
+ lock_path: Path
14
+ cache_dir: Path
15
+ compact_status_path: Path
16
+
17
+
18
+ def resolve_update_paths(
19
+ *, config: Optional[Any] = None, repo_root: Optional[Path] = None
20
+ ) -> UpdatePaths:
21
+ """Resolve update status, lock, cache, and compact status paths."""
22
+ root = resolve_global_state_root(config=config, repo_root=repo_root)
23
+ return UpdatePaths(
24
+ status_path=root / "update_status.json",
25
+ lock_path=root / "update.lock",
26
+ cache_dir=root / "update_cache",
27
+ compact_status_path=root / "compact_status.json",
28
+ )
@@ -23,6 +23,7 @@ def main(argv: list[str] | None = None) -> int:
23
23
  parser.add_argument("--update-dir", required=True)
24
24
  parser.add_argument("--log-path", required=True)
25
25
  parser.add_argument("--target", default="both")
26
+ parser.add_argument("--skip-checks", action="store_true")
26
27
  args = parser.parse_args(argv)
27
28
 
28
29
  update_dir = Path(args.update_dir).expanduser()
@@ -36,6 +37,7 @@ def main(argv: list[str] | None = None) -> int:
36
37
  update_dir=update_dir,
37
38
  logger=logger,
38
39
  update_target=args.target,
40
+ skip_checks=bool(args.skip_checks),
39
41
  )
40
42
  return 0
41
43