codex-autorunner 1.1.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 (127) hide show
  1. codex_autorunner/agents/opencode/client.py +113 -4
  2. codex_autorunner/agents/opencode/supervisor.py +4 -0
  3. codex_autorunner/agents/registry.py +17 -7
  4. codex_autorunner/bootstrap.py +219 -1
  5. codex_autorunner/core/__init__.py +17 -1
  6. codex_autorunner/core/about_car.py +114 -1
  7. codex_autorunner/core/app_server_threads.py +6 -0
  8. codex_autorunner/core/config.py +236 -1
  9. codex_autorunner/core/context_awareness.py +38 -0
  10. codex_autorunner/core/docs.py +0 -122
  11. codex_autorunner/core/filebox.py +265 -0
  12. codex_autorunner/core/flows/controller.py +71 -1
  13. codex_autorunner/core/flows/reconciler.py +4 -1
  14. codex_autorunner/core/flows/runtime.py +22 -0
  15. codex_autorunner/core/flows/store.py +61 -9
  16. codex_autorunner/core/flows/transition.py +23 -16
  17. codex_autorunner/core/flows/ux_helpers.py +18 -3
  18. codex_autorunner/core/flows/worker_process.py +32 -6
  19. codex_autorunner/core/hub.py +198 -41
  20. codex_autorunner/core/lifecycle_events.py +253 -0
  21. codex_autorunner/core/path_utils.py +2 -1
  22. codex_autorunner/core/pma_audit.py +224 -0
  23. codex_autorunner/core/pma_context.py +496 -0
  24. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  25. codex_autorunner/core/pma_lifecycle.py +527 -0
  26. codex_autorunner/core/pma_queue.py +367 -0
  27. codex_autorunner/core/pma_safety.py +221 -0
  28. codex_autorunner/core/pma_state.py +115 -0
  29. codex_autorunner/core/ports/agent_backend.py +2 -5
  30. codex_autorunner/core/ports/run_event.py +1 -4
  31. codex_autorunner/core/prompt.py +0 -80
  32. codex_autorunner/core/prompts.py +56 -172
  33. codex_autorunner/core/redaction.py +0 -4
  34. codex_autorunner/core/review_context.py +11 -9
  35. codex_autorunner/core/runner_controller.py +35 -33
  36. codex_autorunner/core/runner_state.py +147 -0
  37. codex_autorunner/core/runtime.py +829 -0
  38. codex_autorunner/core/sqlite_utils.py +13 -4
  39. codex_autorunner/core/state.py +7 -10
  40. codex_autorunner/core/state_roots.py +5 -0
  41. codex_autorunner/core/templates/__init__.py +39 -0
  42. codex_autorunner/core/templates/git_mirror.py +234 -0
  43. codex_autorunner/core/templates/provenance.py +56 -0
  44. codex_autorunner/core/templates/scan_cache.py +120 -0
  45. codex_autorunner/core/ticket_linter_cli.py +17 -0
  46. codex_autorunner/core/ticket_manager_cli.py +154 -92
  47. codex_autorunner/core/time_utils.py +11 -0
  48. codex_autorunner/core/types.py +18 -0
  49. codex_autorunner/core/utils.py +34 -6
  50. codex_autorunner/flows/review/service.py +23 -25
  51. codex_autorunner/flows/ticket_flow/definition.py +43 -1
  52. codex_autorunner/integrations/agents/__init__.py +2 -0
  53. codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
  54. codex_autorunner/integrations/agents/codex_backend.py +19 -8
  55. codex_autorunner/integrations/agents/runner.py +3 -8
  56. codex_autorunner/integrations/agents/wiring.py +8 -0
  57. codex_autorunner/integrations/telegram/doctor.py +228 -6
  58. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  59. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  60. codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
  61. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  62. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
  63. codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
  64. codex_autorunner/integrations/telegram/handlers/messages.py +26 -1
  65. codex_autorunner/integrations/telegram/helpers.py +1 -3
  66. codex_autorunner/integrations/telegram/runtime.py +9 -4
  67. codex_autorunner/integrations/telegram/service.py +30 -0
  68. codex_autorunner/integrations/telegram/state.py +38 -0
  69. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
  70. codex_autorunner/integrations/telegram/transport.py +10 -3
  71. codex_autorunner/integrations/templates/__init__.py +27 -0
  72. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  73. codex_autorunner/server.py +2 -2
  74. codex_autorunner/static/agentControls.js +21 -5
  75. codex_autorunner/static/app.js +115 -11
  76. codex_autorunner/static/chatUploads.js +137 -0
  77. codex_autorunner/static/docChatCore.js +185 -13
  78. codex_autorunner/static/fileChat.js +68 -40
  79. codex_autorunner/static/fileboxUi.js +159 -0
  80. codex_autorunner/static/hub.js +46 -81
  81. codex_autorunner/static/index.html +303 -24
  82. codex_autorunner/static/messages.js +82 -4
  83. codex_autorunner/static/notifications.js +255 -0
  84. codex_autorunner/static/pma.js +1167 -0
  85. codex_autorunner/static/settings.js +3 -0
  86. codex_autorunner/static/streamUtils.js +57 -0
  87. codex_autorunner/static/styles.css +9125 -6742
  88. codex_autorunner/static/templateReposSettings.js +225 -0
  89. codex_autorunner/static/ticketChatActions.js +165 -3
  90. codex_autorunner/static/ticketChatStream.js +17 -119
  91. codex_autorunner/static/ticketEditor.js +41 -13
  92. codex_autorunner/static/ticketTemplates.js +798 -0
  93. codex_autorunner/static/tickets.js +69 -19
  94. codex_autorunner/static/turnEvents.js +27 -0
  95. codex_autorunner/static/turnResume.js +33 -0
  96. codex_autorunner/static/utils.js +28 -0
  97. codex_autorunner/static/workspace.js +258 -44
  98. codex_autorunner/static/workspaceFileBrowser.js +6 -4
  99. codex_autorunner/surfaces/cli/cli.py +1465 -155
  100. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  101. codex_autorunner/surfaces/web/app.py +253 -49
  102. codex_autorunner/surfaces/web/routes/__init__.py +4 -0
  103. codex_autorunner/surfaces/web/routes/analytics.py +29 -22
  104. codex_autorunner/surfaces/web/routes/file_chat.py +317 -36
  105. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  106. codex_autorunner/surfaces/web/routes/flows.py +219 -29
  107. codex_autorunner/surfaces/web/routes/messages.py +70 -39
  108. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  109. codex_autorunner/surfaces/web/routes/repos.py +1 -1
  110. codex_autorunner/surfaces/web/routes/shared.py +0 -3
  111. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  112. codex_autorunner/surfaces/web/runner_manager.py +2 -2
  113. codex_autorunner/surfaces/web/schemas.py +70 -18
  114. codex_autorunner/tickets/agent_pool.py +27 -0
  115. codex_autorunner/tickets/files.py +33 -16
  116. codex_autorunner/tickets/lint.py +50 -0
  117. codex_autorunner/tickets/models.py +3 -0
  118. codex_autorunner/tickets/outbox.py +41 -5
  119. codex_autorunner/tickets/runner.py +350 -69
  120. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/METADATA +15 -19
  121. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/RECORD +125 -94
  122. codex_autorunner/core/adapter_utils.py +0 -21
  123. codex_autorunner/core/engine.py +0 -3302
  124. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  125. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  126. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  127. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -15,15 +15,16 @@ Commands:
15
15
  lint Validate ticket filenames and frontmatter.
16
16
  insert --before N Shift tickets >= N up by COUNT (default 1).
17
17
  insert --after N Shift tickets > N up by COUNT (default 1).
18
+ Optionally create a ticket in the new slot.
18
19
  move --start A --to B Move ticket/block starting at A (or A..END)
19
20
  so it begins at position B (1-indexed).
20
- create --title \"...\" Create a new ticket at the next or specified
21
+ create --title "..." Create a new ticket at the next or specified
21
22
  index. Use --at to place into a gap.
22
23
 
23
24
  Examples:
24
25
  ticket_tool.py list
25
26
  ticket_tool.py insert --before 3
26
- ticket_tool.py create --title \"Investigate flaky test\" --at 3
27
+ ticket_tool.py create --title "Investigate flaky test" --at 3
27
28
  ticket_tool.py move --start 5 --end 7 --to 2
28
29
  ticket_tool.py lint
29
30
 
@@ -70,16 +71,18 @@ def _ticket_paths(ticket_dir: Path) -> Tuple[List[Path], List[str]]:
70
71
  for path in sorted(ticket_dir.iterdir()):
71
72
  if not path.is_file():
72
73
  continue
74
+ if path.name == "AGENTS.md":
75
+ continue
73
76
  m = _TICKET_NAME_RE.match(path.name)
74
77
  if not m:
75
78
  errors.append(
76
- f\"{path}: Invalid ticket filename; expected TICKET-<number>[suffix].md\"
79
+ f"{path}: Invalid ticket filename; expected TICKET-<number>[suffix].md"
77
80
  )
78
81
  continue
79
82
  try:
80
83
  idx = int(m.group(1))
81
84
  except ValueError:
82
- errors.append(f\"{path}: Invalid ticket filename; number must be digits\")
85
+ errors.append(f"{path}: Invalid ticket filename; number must be digits")
83
86
  continue
84
87
  tickets.append((idx, path, m.group(2)))
85
88
  tickets.sort(key=lambda t: t[0])
@@ -87,71 +90,71 @@ def _ticket_paths(ticket_dir: Path) -> Tuple[List[Path], List[str]]:
87
90
 
88
91
 
89
92
  def _split_frontmatter(text: str):
90
- if not text or not text.lstrip().startswith(\"---\"):
91
- return None, [\"Missing YAML frontmatter (expected leading '---').\"]
93
+ if not text or not text.lstrip().startswith("---"):
94
+ return None, ["Missing YAML frontmatter (expected leading '---')."]
92
95
  lines = text.splitlines()
93
96
  end_idx = None
94
97
  for idx in range(1, len(lines)):
95
- if lines[idx].strip() in (\"---\", \"...\"):
98
+ if lines[idx].strip() in ("---", "..."):
96
99
  end_idx = idx
97
100
  break
98
101
  if end_idx is None:
99
- return None, [\"Frontmatter is not closed (missing trailing '---').\"]
100
- fm_yaml = \"\\n\".join(lines[1:end_idx])
102
+ return None, ["Frontmatter is not closed (missing trailing '---')."]
103
+ fm_yaml = "\\n".join(lines[1:end_idx])
101
104
  return fm_yaml, []
102
105
 
103
106
 
104
107
  def _parse_yaml(fm_yaml: Optional[str]):
105
108
  if fm_yaml is None:
106
- return {}, [\"Missing or invalid YAML frontmatter (expected a mapping).\"]
109
+ return {}, ["Missing or invalid YAML frontmatter (expected a mapping)."]
107
110
  if yaml is None:
108
111
  return {}, [
109
- \"PyYAML is required to lint tickets. Install with: python3 -m pip install --user pyyaml\"
112
+ "PyYAML is required to lint tickets. Install with: python3 -m pip install --user pyyaml"
110
113
  ]
111
114
  try:
112
115
  loaded = yaml.safe_load(fm_yaml)
113
116
  except Exception as exc: # noqa: BLE001
114
- return {}, [f\"YAML parse error: {exc}\"]
117
+ return {}, [f"YAML parse error: {exc}"]
115
118
  if loaded is None or not isinstance(loaded, dict):
116
- return {}, [\"Invalid YAML frontmatter (expected a mapping).\"]
119
+ return {}, ["Invalid YAML frontmatter (expected a mapping)."]
117
120
  return loaded, []
118
121
 
119
122
 
120
123
  def _lint_frontmatter(data: dict):
121
124
  errors: List[str] = []
122
- agent = data.get(\"agent\")
125
+ agent = data.get("agent")
123
126
  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\")
127
+ errors.append("frontmatter.agent is required and must be a non-empty string.")
128
+ done = data.get("done")
126
129
  if not isinstance(done, bool):
127
- errors.append(\"frontmatter.done is required and must be a boolean.\")
130
+ errors.append("frontmatter.done is required and must be a boolean.")
128
131
  return errors
129
132
 
130
133
 
131
134
  def _read_ticket(path: Path) -> Tuple[Optional[TicketFile], List[str]]:
132
135
  try:
133
- raw = path.read_text(encoding=\"utf-8\")
136
+ raw = path.read_text(encoding="utf-8")
134
137
  except OSError as exc:
135
- return None, [f\"{path}: Unable to read file ({exc}).\"]
138
+ return None, [f"{path}: Unable to read file ({exc})."]
136
139
 
137
140
  fm_yaml, fm_errors = _split_frontmatter(raw)
138
141
  if fm_errors:
139
- return None, [f\"{path}: {msg}\" for msg in fm_errors]
142
+ return None, [f"{path}: {msg}" for msg in fm_errors]
140
143
 
141
144
  data, parse_errors = _parse_yaml(fm_yaml)
142
145
  if parse_errors:
143
- return None, [f\"{path}: {msg}\" for msg in parse_errors]
146
+ return None, [f"{path}: {msg}" for msg in parse_errors]
144
147
 
145
148
  lint_errors = _lint_frontmatter(data)
146
149
  if lint_errors:
147
- return None, [f\"{path}: {msg}\" for msg in lint_errors]
150
+ return None, [f"{path}: {msg}" for msg in lint_errors]
148
151
 
149
- title = data.get(\"title\") if isinstance(data, dict) else None
150
- done_val = data.get(\"done\") if isinstance(data, dict) else None
152
+ title = data.get("title") if isinstance(data, dict) else None
153
+ done_val = data.get("done") if isinstance(data, dict) else None
151
154
 
152
155
  m = _TICKET_NAME_RE.match(path.name)
153
156
  idx = int(m.group(1)) if m else 0
154
- suffix = m.group(2) if m else \"\"
157
+ suffix = m.group(2) if m else ""
155
158
  return TicketFile(index=idx, path=path, suffix=suffix, title=title, done=done_val), []
156
159
 
157
160
 
@@ -175,7 +178,7 @@ def _pad_width(indices: Sequence[int]) -> int:
175
178
 
176
179
 
177
180
  def _fmt_name(index: int, suffix: str, width: int) -> str:
178
- return f\"TICKET-{index:0{width}d}{suffix}.md\"
181
+ return f"TICKET-{index:0{width}d}{suffix}.md"
179
182
 
180
183
 
181
184
  def _safe_renames(mapping: Sequence[tuple[Path, Path]]) -> None:
@@ -183,11 +186,11 @@ def _safe_renames(mapping: Sequence[tuple[Path, Path]]) -> None:
183
186
  for src, dst in mapping:
184
187
  if src == dst:
185
188
  continue
186
- temp = src.with_name(src.name + \".tmp-move\")
189
+ temp = src.with_name(src.name + ".tmp-move")
187
190
  counter = 0
188
191
  while temp.exists():
189
192
  counter += 1
190
- temp = src.with_name(f\"{src.name}.tmp-move-{counter}\")
193
+ temp = src.with_name(f"{src.name}.tmp-move-{counter}")
191
194
  src.rename(temp)
192
195
  temp_pairs.append((temp, dst))
193
196
 
@@ -200,12 +203,12 @@ def cmd_list(ticket_dir: Path) -> int:
200
203
  tickets, errors = _ticket_files(ticket_dir)
201
204
  if errors:
202
205
  for msg in errors:
203
- sys.stderr.write(msg + \"\\n\")
206
+ sys.stderr.write(msg + "\\n")
204
207
  width = _pad_width([t.index for t in tickets])
205
208
  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
+ 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")
209
212
  if errors:
210
213
  return 1
211
214
  return 0
@@ -220,9 +223,9 @@ def cmd_lint(ticket_dir: Path) -> int:
220
223
 
221
224
  if errors:
222
225
  for msg in errors:
223
- sys.stderr.write(msg + \"\\n\")
226
+ sys.stderr.write(msg + "\\n")
224
227
  return 1
225
- sys.stdout.write(f\"OK: {len(paths)} ticket(s) linted.\\n\")
228
+ sys.stdout.write(f"OK: {len(paths)} ticket(s) linted.\\n")
226
229
  return 0
227
230
 
228
231
 
@@ -231,7 +234,7 @@ def _shift(ticket_dir: Path, start_idx: int, delta: int) -> None:
231
234
  return
232
235
  paths, errors = _ticket_paths(ticket_dir)
233
236
  if errors:
234
- raise ValueError(\"Cannot shift while filenames are invalid; run lint first.\")
237
+ raise ValueError("Cannot shift while filenames are invalid; run lint first.")
235
238
  iterable = reversed(paths) if delta > 0 else paths
236
239
  width = _pad_width([_parse_index(p.name) for p in paths] + [start_idx + delta])
237
240
  mapping: list[tuple[Path, Path]] = []
@@ -241,7 +244,7 @@ def _shift(ticket_dir: Path, start_idx: int, delta: int) -> None:
241
244
  continue
242
245
  new_idx = idx + delta
243
246
  if new_idx <= 0:
244
- raise ValueError(\"Shift would create non-positive ticket index\")
247
+ raise ValueError("Shift would create non-positive ticket index")
245
248
  suffix = _parse_suffix(path.name)
246
249
  target = path.with_name(_fmt_name(new_idx, suffix, width))
247
250
  mapping.append((path, target))
@@ -255,22 +258,73 @@ def _parse_index(name: str) -> Optional[int]:
255
258
 
256
259
  def _parse_suffix(name: str) -> str:
257
260
  m = _TICKET_NAME_RE.match(name)
258
- return m.group(2) if m else \"\"
261
+ return m.group(2) if m else ""
259
262
 
260
263
 
261
- def cmd_insert(ticket_dir: Path, *, before: Optional[int], after: Optional[int], count: int) -> int:
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:
262
294
  if (before is None) == (after is None):
263
- sys.stderr.write(\"Specify exactly one of --before or --after.\\n\")
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")
264
299
  return 2
265
300
  anchor = before if before is not None else after + 1 # type: ignore[operator]
266
301
  if anchor is None or anchor < 1:
267
- sys.stderr.write(\"Anchor index must be >= 1.\\n\")
302
+ sys.stderr.write("Anchor index must be >= 1.\\n")
268
303
  return 2
269
304
  try:
270
305
  _shift(ticket_dir, anchor, count)
271
306
  except ValueError as exc:
272
- sys.stderr.write(str(exc) + \"\\n\")
307
+ sys.stderr.write(str(exc) + "\\n")
273
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
+ )
274
328
  return 0
275
329
 
276
330
 
@@ -282,7 +336,7 @@ def _yaml_scalar(value: str) -> str:
282
336
 
283
337
  escaped = (
284
338
  value.replace("\\\\", "\\\\\\\\")
285
- .replace('"', '\\\\\"')
339
+ .replace('"', '\\\\"')
286
340
  .replace("\\n", "\\\\n")
287
341
  )
288
342
  return f'"{escaped}"'
@@ -292,60 +346,52 @@ def cmd_create(ticket_dir: Path, *, title: str, agent: str, at: Optional[int]) -
292
346
  tickets, errors = _ticket_files(ticket_dir)
293
347
  if errors:
294
348
  for msg in errors:
295
- sys.stderr.write(msg + \"\\n\")
349
+ sys.stderr.write(msg + "\\n")
296
350
  return 1
297
351
  existing_indices = [t.index for t in tickets]
298
352
  next_index = max(existing_indices) + 1 if existing_indices else 1
299
353
  index = at or next_index
300
354
  if index in existing_indices:
301
355
  sys.stderr.write(
302
- f\"Ticket index {index} already exists. Use insert to open a gap or choose --at another index.\\n\"
356
+ f"Ticket index {index} already exists. Use insert to open a gap or choose --at another index.\\n"
303
357
  )
304
358
  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\")
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")
321
367
  return 0
322
368
 
323
369
 
324
370
  def cmd_move(ticket_dir: Path, *, start: int, end: Optional[int], to: int) -> int:
325
371
  if start < 1 or to < 1:
326
- sys.stderr.write(\"Indices must be >= 1.\\n\")
372
+ sys.stderr.write("Indices must be >= 1.\\n")
327
373
  return 2
328
374
  tickets, errors = _ticket_files(ticket_dir)
329
375
  if errors:
330
376
  for msg in errors:
331
- sys.stderr.write(msg + \"\\n\")
377
+ sys.stderr.write(msg + "\\n")
332
378
  return 1
333
379
  indices = [t.index for t in tickets]
334
380
  if start not in indices:
335
- sys.stderr.write(f\"No ticket at index {start}.\\n\")
381
+ sys.stderr.write(f"No ticket at index {start}.\\n")
336
382
  return 1
337
383
  end_idx = end if end is not None else start
338
384
  if end_idx < start:
339
- sys.stderr.write(\"--end must be >= --start.\\n\")
385
+ sys.stderr.write("--end must be >= --start.\\n")
340
386
  return 2
341
387
  block = [t for t in tickets if start <= t.index <= end_idx]
342
388
  if not block:
343
- sys.stderr.write(\"No tickets in the specified move range.\\n\")
389
+ sys.stderr.write("No tickets in the specified move range.\\n")
344
390
  return 1
345
391
  remaining = [t for t in tickets if t not in block]
346
392
  insert_pos = to - 1
347
393
  if insert_pos < 0 or insert_pos > len(remaining):
348
- sys.stderr.write(\"Target position is out of range.\\n\")
394
+ sys.stderr.write("Target position is out of range.\\n")
349
395
  return 1
350
396
  new_order = remaining[:insert_pos] + block + remaining[insert_pos:]
351
397
  width = _pad_width([t.index for t in new_order])
@@ -359,54 +405,70 @@ def cmd_move(ticket_dir: Path, *, start: int, end: Optional[int], to: int) -> in
359
405
 
360
406
 
361
407
  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)
408
+ parser = argparse.ArgumentParser(description="Manage Codex Autorunner tickets.")
409
+ sub = parser.add_subparsers(dest="cmd", required=True)
364
410
 
365
- sub.add_parser(\"list\", help=\"List tickets in order\")
366
- sub.add_parser(\"lint\", help=\"Validate ticket filenames and frontmatter\")
411
+ sub.add_parser("list", help="List tickets in order")
412
+ sub.add_parser("lint", help="Validate ticket filenames and frontmatter")
367
413
 
368
- insert_p = sub.add_parser(\"insert\", help=\"Insert gap by shifting tickets\")
414
+ insert_p = sub.add_parser("insert", help="Insert gap by shifting tickets")
369
415
  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)\")
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
+ )
373
428
 
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)\")
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)")
377
432
  create_p.add_argument(
378
- \"--at\",
433
+ "--at",
379
434
  type=int,
380
- help=\"Index to use (must be unused). Defaults to next available index.\",
435
+ help="Index to use (must be unused). Defaults to next available index.",
381
436
  )
382
437
 
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)\")
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)")
387
442
 
388
443
  args = parser.parse_args(argv)
389
444
  repo_root = Path.cwd()
390
445
  ticket_dir = _ticket_dir(repo_root)
391
446
  if not ticket_dir.exists():
392
- sys.stderr.write(f\"Tickets directory not found: {ticket_dir}\\n\")
447
+ sys.stderr.write(f"Tickets directory not found: {ticket_dir}\\n")
393
448
  return 2
394
449
 
395
- if args.cmd == \"list\":
450
+ if args.cmd == "list":
396
451
  return cmd_list(ticket_dir)
397
- if args.cmd == \"lint\":
452
+ if args.cmd == "lint":
398
453
  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\":
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":
402
464
  return cmd_create(ticket_dir, title=args.title, agent=args.agent, at=args.at)
403
- if args.cmd == \"move\":
465
+ if args.cmd == "move":
404
466
  return cmd_move(ticket_dir, start=args.start, end=args.end, to=args.to)
405
- parser.error(\"Unknown command\")
467
+ parser.error("Unknown command")
406
468
  return 2
407
469
 
408
470
 
409
- if __name__ == \"__main__\": # pragma: no cover
471
+ if __name__ == "__main__": # pragma: no cover
410
472
  sys.exit(main())
411
473
  """
412
474
 
@@ -0,0 +1,11 @@
1
+ from datetime import datetime, timezone
2
+
3
+
4
+ def now_iso_utc_z() -> str:
5
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
6
+
7
+
8
+ now_iso = now_iso_utc_z
9
+
10
+
11
+ __all__ = ["now_iso_utc_z", "now_iso"]
@@ -0,0 +1,18 @@
1
+ """Core type definitions.
2
+
3
+ This module provides type definitions that were previously in Engine.
4
+ """
5
+
6
+ from typing import Any, Callable, Optional
7
+
8
+ # Type aliases for factory functions
9
+ BackendFactory = Callable[[str, Any, Optional[Callable[[dict[str, Any]], Any]]], Any]
10
+ AppServerSupervisorFactory = Callable[
11
+ [str, Optional[Callable[[dict[str, Any]], Any]]], Any
12
+ ]
13
+
14
+
15
+ __all__ = [
16
+ "BackendFactory",
17
+ "AppServerSupervisorFactory",
18
+ ]
@@ -147,12 +147,21 @@ def is_within(root: Path, target: Path) -> bool:
147
147
  return False
148
148
 
149
149
 
150
- def atomic_write(path: Path, content: str) -> None:
150
+ def atomic_write(path: Path, content: str, durable: bool = False) -> None:
151
151
  path.parent.mkdir(parents=True, exist_ok=True)
152
152
  tmp_path = path.with_suffix(path.suffix + ".tmp")
153
153
  with tmp_path.open("w", encoding="utf-8") as f:
154
154
  f.write(content)
155
+ if durable:
156
+ f.flush()
157
+ os.fsync(f.fileno())
155
158
  tmp_path.replace(path)
159
+ if durable:
160
+ dir_fd = os.open(path.parent, os.O_RDONLY)
161
+ try:
162
+ os.fsync(dir_fd)
163
+ finally:
164
+ os.close(dir_fd)
156
165
 
157
166
 
158
167
  def read_json(path: Path) -> Optional[dict]:
@@ -175,7 +184,28 @@ def _default_path_prefixes() -> list[str]:
175
184
  str(home / ".opencode" / "bin"), # OpenCode default install
176
185
  str(home / ".local" / "bin"), # Common user-local installs
177
186
  ]
178
- return [p for p in candidates if os.path.isdir(p)]
187
+ repo_candidates: list[str] = []
188
+ repo_root = get_repo_root_context()
189
+ if repo_root is None:
190
+ try:
191
+ repo_root = find_repo_root()
192
+ except RepoNotFoundError:
193
+ repo_root = None
194
+ if repo_root is not None:
195
+ bin_dir = "Scripts" if os.name == "nt" else "bin"
196
+ repo_candidates = [
197
+ str(repo_root / ".venv" / bin_dir),
198
+ str(repo_root / ".codex-autorunner" / "bin"),
199
+ str(repo_root / "bin"),
200
+ ]
201
+ car_shim = repo_root / "car"
202
+ if car_shim.exists():
203
+ repo_candidates.append(str(repo_root))
204
+ return [
205
+ p
206
+ for p in (repo_candidates + candidates)
207
+ if (os.path.isdir(p) or os.path.isfile(p))
208
+ ]
179
209
 
180
210
 
181
211
  def augmented_path(path: Optional[str] = None) -> str:
@@ -227,10 +257,6 @@ def resolve_executable(
227
257
  return resolved
228
258
 
229
259
 
230
- def ensure_executable(binary: str) -> bool:
231
- return resolve_executable(binary) is not None
232
-
233
-
234
260
  def default_editor(*, fallback: str = "vi") -> str:
235
261
  return os.environ.get("EDITOR") or fallback
236
262
 
@@ -295,6 +321,7 @@ def build_opencode_supervisor(
295
321
  max_handles: Optional[int] = None,
296
322
  idle_ttl_seconds: Optional[float] = None,
297
323
  session_stall_timeout_seconds: Optional[float] = None,
324
+ max_text_chars: Optional[int] = None,
298
325
  base_env: Optional[MutableMapping[str, str]] = None,
299
326
  subagent_models: Optional[Mapping[str, str]] = None,
300
327
  ) -> Optional[Any]:
@@ -352,6 +379,7 @@ def build_opencode_supervisor(
352
379
  max_handles=max_handles,
353
380
  idle_ttl_seconds=idle_ttl_seconds,
354
381
  session_stall_timeout_seconds=session_stall_timeout_seconds,
382
+ max_text_chars=max_text_chars,
355
383
  username=username if password else None,
356
384
  password=password if password else None,
357
385
  base_env=base_env,