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
@@ -9,8 +9,7 @@ from pathlib import Path
9
9
  from typing import Callable, Optional
10
10
 
11
11
  from .....agents.registry import validate_agent_id
12
- from .....core.config import load_repo_config
13
- from .....core.engine import Engine
12
+ from .....core.config import load_hub_config, load_repo_config
14
13
  from .....core.flows import FlowController, FlowStore
15
14
  from .....core.flows.models import FlowRunStatus
16
15
  from .....core.flows.reconciler import reconcile_flow_run
@@ -22,19 +21,24 @@ from .....core.flows.ux_helpers import (
22
21
  issue_md_path,
23
22
  seed_issue_from_github,
24
23
  seed_issue_from_text,
24
+ ticket_progress,
25
25
  )
26
26
  from .....core.flows.worker_process import (
27
27
  FlowWorkerHealth,
28
28
  check_worker_health,
29
29
  clear_worker_metadata,
30
30
  )
31
+ from .....core.logging_utils import log_event
32
+ from .....core.runtime import RuntimeContext
31
33
  from .....core.state import now_iso
32
34
  from .....core.utils import atomic_write, canonicalize_path
33
35
  from .....flows.ticket_flow import build_ticket_flow_definition
36
+ from .....integrations.agents import build_backend_orchestrator
34
37
  from .....integrations.agents.wiring import (
35
38
  build_agent_backend_factory,
36
39
  build_app_server_supervisor_factory,
37
40
  )
41
+ from .....manifest import load_manifest
38
42
  from .....tickets import AgentPool
39
43
  from .....tickets.files import list_ticket_paths
40
44
  from .....tickets.outbox import resolve_outbox_paths
@@ -67,6 +71,11 @@ def _ticket_dir(repo_root: Path) -> Path:
67
71
  return repo_root.resolve() / ".codex-autorunner" / "tickets"
68
72
 
69
73
 
74
+ def _load_flow_store(repo_root: Path, hub_root: Optional[Path] = None) -> FlowStore:
75
+ config = load_repo_config(repo_root, hub_root)
76
+ return FlowStore(_flow_paths(repo_root)[0], durable=config.durable_writes)
77
+
78
+
70
79
  def _normalize_run_id(value: str) -> Optional[str]:
71
80
  try:
72
81
  return str(uuid.UUID(str(value)))
@@ -107,16 +116,70 @@ def _flow_help_lines() -> list[str]:
107
116
  "/flow restart",
108
117
  "/flow archive [run_id] [--force]",
109
118
  "/flow reply <message>",
110
- "Aliases: /flow start, /flow_status",
119
+ "Alias: /flow start",
111
120
  ]
112
121
 
113
122
 
123
+ def _discover_unregistered_worktrees(
124
+ manifest, hub_root: Optional[Path]
125
+ ) -> list[dict[str, object]]:
126
+ if not hub_root:
127
+ return []
128
+ try:
129
+ hub_config = load_hub_config(hub_root)
130
+ except Exception:
131
+ return []
132
+ worktrees_root = hub_config.worktrees_root
133
+ if not worktrees_root.exists() or not worktrees_root.is_dir():
134
+ return []
135
+
136
+ known_paths = {(hub_root / repo.path).resolve() for repo in manifest.repos}
137
+ known_ids = {repo.id for repo in manifest.repos}
138
+ extras: list[dict[str, object]] = []
139
+ for child in sorted(worktrees_root.iterdir()):
140
+ if not child.is_dir():
141
+ continue
142
+ if not (child / ".git").exists():
143
+ continue
144
+ resolved = child.resolve()
145
+ if resolved in known_paths:
146
+ continue
147
+
148
+ flows_root = child / ".codex-autorunner" / "flows"
149
+ flows_db = child / ".codex-autorunner" / "flows.db"
150
+ if not flows_root.exists() and not flows_db.exists():
151
+ continue
152
+
153
+ repo_id = child.name
154
+ label = repo_id
155
+ indent = ""
156
+ if "--" in repo_id:
157
+ _, suffix = repo_id.split("--", 1)
158
+ label = suffix or repo_id
159
+ indent = " - "
160
+ label = f"{label} (unregistered)"
161
+ if repo_id in known_ids:
162
+ label = f"{label} (duplicate id)"
163
+ extras.append(
164
+ {
165
+ "repo_id": repo_id,
166
+ "repo_root": resolved,
167
+ "label": label,
168
+ "indent": indent,
169
+ "unregistered": True,
170
+ }
171
+ )
172
+ return extras
173
+
174
+
114
175
  def _get_ticket_controller(repo_root: Path) -> FlowController:
115
176
  db_path, artifacts_root = _flow_paths(repo_root)
116
177
  config = load_repo_config(repo_root)
117
- engine = Engine(
178
+ backend_orchestrator = build_backend_orchestrator(repo_root, config)
179
+ engine = RuntimeContext(
118
180
  repo_root,
119
181
  config=config,
182
+ backend_orchestrator=backend_orchestrator,
120
183
  backend_factory=build_agent_backend_factory(repo_root, config),
121
184
  app_server_supervisor_factory=build_app_server_supervisor_factory(config),
122
185
  agent_id_validator=validate_agent_id,
@@ -251,61 +314,123 @@ class FlowCommands(SharedHelpers):
251
314
 
252
315
  async def _handle_flow(self, message: TelegramMessage, args: str) -> None:
253
316
  argv = self._parse_command_args(args)
317
+
318
+ target_repo_root = None
319
+ effective_args = args
320
+
321
+ if argv:
322
+ resolved = self._resolve_workspace(argv[0])
323
+ if resolved:
324
+ target_repo_root = Path(resolved[0])
325
+ argv = argv[1:]
326
+ # Reconstruct args for remainder logic (imperfect but sufficient for text commands)
327
+ effective_args = " ".join(argv)
328
+
254
329
  action_raw = argv[0] if argv else ""
330
+ if target_repo_root and not action_raw:
331
+ action_raw = "status"
332
+ argv = ["status"]
333
+ effective_args = "status"
255
334
  action = _normalize_flow_action(action_raw)
256
- _, remainder = _split_flow_action(args)
335
+ _, remainder = _split_flow_action(effective_args)
257
336
  rest_argv = argv[1:]
258
337
 
259
338
  key = await self._resolve_topic_key(message.chat_id, message.thread_id)
260
339
  record = await self._store.get_topic(key)
340
+ is_pma = bool(record and getattr(record, "pma_enabled", False))
341
+ is_unbound = bool(not record or not getattr(record, "workspace_path", None))
342
+
343
+ if not target_repo_root and not action_raw:
344
+ # Check if we should show Hub Overview
345
+ if is_pma or is_unbound:
346
+ await self._send_flow_hub_overview(message)
347
+ return
348
+ action = "status"
349
+ rest_argv = []
261
350
 
262
351
  if action == "help":
263
352
  await self._send_flow_overview(message, record)
264
353
  return
265
354
 
266
- if not record or not record.workspace_path:
355
+ if target_repo_root:
356
+ repo_root = canonicalize_path(target_repo_root)
357
+ elif record and record.workspace_path:
358
+ repo_root = canonicalize_path(Path(record.workspace_path))
359
+ else:
360
+ if action == "status" and (is_pma or is_unbound):
361
+ await self._send_flow_hub_overview(message)
362
+ return
267
363
  await self._send_message(
268
364
  message.chat_id,
269
- "No workspace bound. Use /bind to bind this topic to a repo first.",
365
+ "No workspace bound. Use /flow <repo-id> status to inspect a repo without binding, or /bind <repo-id> to attach this topic.",
270
366
  thread_id=message.thread_id,
271
367
  reply_to=message.message_id,
272
368
  )
273
369
  return
274
370
 
275
- repo_root = canonicalize_path(Path(record.workspace_path))
276
-
277
- if action == "status":
278
- await self._handle_flow_status_action(message, repo_root, rest_argv)
279
- return
280
- if action == "runs":
281
- await self._handle_flow_runs(message, repo_root, rest_argv)
282
- return
283
- if action == "bootstrap":
284
- await self._handle_flow_bootstrap(message, repo_root, rest_argv)
285
- return
286
- if action == "issue":
287
- await self._handle_flow_issue(message, repo_root, remainder)
288
- return
289
- if action == "plan":
290
- await self._handle_flow_plan(message, repo_root, remainder)
291
- return
292
- if action == "resume":
293
- await self._handle_flow_resume(message, repo_root, rest_argv)
294
- return
295
- if action == "stop":
296
- await self._handle_flow_stop(message, repo_root, rest_argv)
297
- return
298
- if action == "recover":
299
- await self._handle_flow_recover(message, repo_root, rest_argv)
300
- return
301
- if action == "restart":
302
- await self._handle_flow_restart(message, repo_root, rest_argv)
303
- return
304
- if action == "archive":
305
- await self._handle_flow_archive(message, repo_root, rest_argv)
306
- return
307
- if action == "reply":
308
- await self._handle_reply(message, remainder)
371
+ try:
372
+ if action == "status":
373
+ await self._handle_flow_status_action(message, repo_root, rest_argv)
374
+ return
375
+ if action == "runs":
376
+ await self._handle_flow_runs(message, repo_root, rest_argv)
377
+ return
378
+ if action == "bootstrap":
379
+ await self._handle_flow_bootstrap(message, repo_root, rest_argv)
380
+ return
381
+ if action == "issue":
382
+ await self._handle_flow_issue(message, repo_root, remainder)
383
+ return
384
+ if action == "plan":
385
+ await self._handle_flow_plan(message, repo_root, remainder)
386
+ return
387
+ if action == "resume":
388
+ await self._handle_flow_resume(message, repo_root, rest_argv)
389
+ return
390
+ if action == "stop":
391
+ await self._handle_flow_stop(message, repo_root, rest_argv)
392
+ return
393
+ if action == "recover":
394
+ await self._handle_flow_recover(message, repo_root, rest_argv)
395
+ return
396
+ if action == "restart":
397
+ await self._handle_flow_restart(message, repo_root, rest_argv)
398
+ return
399
+ if action == "archive":
400
+ await self._handle_flow_archive(message, repo_root, rest_argv)
401
+ return
402
+ if action == "reply":
403
+ await self._handle_reply(message, remainder)
404
+ return
405
+ except (asyncio.CancelledError, KeyboardInterrupt):
406
+ # Let cancellations propagate so shutdowns/timeouts are not masked.
407
+ raise
408
+ except Exception as exc: # pragma: no cover - defensive
409
+ log_event(
410
+ _logger,
411
+ logging.WARNING,
412
+ "telegram.flow.command_failed",
413
+ chat_id=message.chat_id,
414
+ thread_id=message.thread_id,
415
+ action=action or "unknown",
416
+ exc=exc,
417
+ )
418
+ format_msg = getattr(self, "_with_conversation_id", None)
419
+ error_text = (
420
+ format_msg(
421
+ "Flow command failed; check logs for details.",
422
+ chat_id=message.chat_id,
423
+ thread_id=message.thread_id,
424
+ )
425
+ if callable(format_msg)
426
+ else "Flow command failed; check logs for details."
427
+ )
428
+ await self._send_message(
429
+ message.chat_id,
430
+ error_text,
431
+ thread_id=message.thread_id,
432
+ reply_to=message.message_id,
433
+ )
309
434
  return
310
435
 
311
436
  await self._send_message(
@@ -323,7 +448,7 @@ class FlowCommands(SharedHelpers):
323
448
  repo_root: Path,
324
449
  run_id_raw: Optional[str],
325
450
  ) -> None:
326
- store = FlowStore(_flow_paths(repo_root)[0])
451
+ store = _load_flow_store(repo_root)
327
452
  try:
328
453
  store.initialize()
329
454
  record, error = self._resolve_status_record(store, run_id_raw)
@@ -365,7 +490,7 @@ class FlowCommands(SharedHelpers):
365
490
  error = None
366
491
  notice = None
367
492
  if action == "resume":
368
- store = FlowStore(_flow_paths(repo_root)[0])
493
+ store = _load_flow_store(repo_root)
369
494
  try:
370
495
  store.initialize()
371
496
  run_id, error = self._resolve_run_id_input(store, run_id_raw)
@@ -388,7 +513,7 @@ class FlowCommands(SharedHelpers):
388
513
  _spawn_flow_worker(repo_root, updated.id)
389
514
  notice = "Resumed."
390
515
  elif action == "stop":
391
- store = FlowStore(_flow_paths(repo_root)[0])
516
+ store = _load_flow_store(repo_root)
392
517
  try:
393
518
  store.initialize()
394
519
  run_id, error = self._resolve_run_id_input(store, run_id_raw)
@@ -411,7 +536,7 @@ class FlowCommands(SharedHelpers):
411
536
  await controller.stop_flow(record.id)
412
537
  notice = "Stopped."
413
538
  elif action == "recover":
414
- store = FlowStore(_flow_paths(repo_root)[0])
539
+ store = _load_flow_store(repo_root)
415
540
  try:
416
541
  store.initialize()
417
542
  run_id, error = self._resolve_run_id_input(store, run_id_raw)
@@ -435,7 +560,7 @@ class FlowCommands(SharedHelpers):
435
560
  finally:
436
561
  store.close()
437
562
  elif action == "archive":
438
- store = FlowStore(_flow_paths(repo_root)[0])
563
+ store = _load_flow_store(repo_root)
439
564
  record = None
440
565
  try:
441
566
  store.initialize()
@@ -479,7 +604,7 @@ class FlowCommands(SharedHelpers):
479
604
  archived_runs_dir = artifacts_root / record.id / "archived_runs"
480
605
  shutil.move(str(run_dir), str(archived_runs_dir))
481
606
 
482
- store = FlowStore(_flow_paths(repo_root)[0])
607
+ store = _load_flow_store(repo_root)
483
608
  try:
484
609
  store.initialize()
485
610
  store.delete_flow_run(record.id)
@@ -569,7 +694,16 @@ class FlowCommands(SharedHelpers):
569
694
  run = record
570
695
  status = getattr(run, "status", None)
571
696
  status_value = status.value if status else "unknown"
697
+ progress = snapshot.get("ticket_progress") if snapshot else None
698
+ progress_label = None
699
+ if isinstance(progress, dict):
700
+ done = progress.get("done")
701
+ total = progress.get("total")
702
+ if isinstance(done, int) and isinstance(total, int) and total >= 0:
703
+ progress_label = f"{done}/{total}"
572
704
  lines = [f"Run: {run.id}", f"Status: {status_value}"]
705
+ if progress_label:
706
+ lines.append(f"Tickets: {progress_label}")
573
707
  flow_type = getattr(run, "flow_type", None)
574
708
  if flow_type:
575
709
  lines.append(f"Flow: {flow_type}")
@@ -721,7 +855,7 @@ class FlowCommands(SharedHelpers):
721
855
  f"Workspace: {repo_root}" if repo_root else "Workspace: unbound",
722
856
  ]
723
857
  if repo_root:
724
- store = FlowStore(_flow_paths(repo_root)[0])
858
+ store = _load_flow_store(repo_root)
725
859
  try:
726
860
  store.initialize()
727
861
  runs = store.list_flow_runs(flow_type="ticket_flow")
@@ -741,10 +875,164 @@ class FlowCommands(SharedHelpers):
741
875
  reply_to=message.message_id,
742
876
  )
743
877
 
878
+ async def _send_flow_hub_overview(self, message: TelegramMessage) -> None:
879
+ if not self._manifest_path or not self._hub_root:
880
+ await self._send_message(
881
+ message.chat_id,
882
+ "Hub manifest not configured.",
883
+ thread_id=message.thread_id,
884
+ reply_to=message.message_id,
885
+ )
886
+ return
887
+
888
+ try:
889
+ manifest = load_manifest(self._manifest_path, self._hub_root)
890
+ except Exception:
891
+ await self._send_message(
892
+ message.chat_id,
893
+ "Failed to load manifest.",
894
+ thread_id=message.thread_id,
895
+ reply_to=message.message_id,
896
+ )
897
+ return
898
+
899
+ def _group_key(repo_id: str) -> tuple[str, Optional[str]]:
900
+ parts = repo_id.split("--", 1)
901
+ if len(parts) == 1:
902
+ return repo_id, None
903
+ return parts[0], parts[1]
904
+
905
+ def _format_status_line(
906
+ label: str,
907
+ *,
908
+ status_icon: str,
909
+ status_value: str,
910
+ progress_label: str,
911
+ run_id: Optional[str],
912
+ indent: str = "",
913
+ ) -> str:
914
+ run_suffix = f" run {run_id}" if run_id else ""
915
+ return f"{indent}{status_icon} {label}: {status_value} {progress_label}{run_suffix}"
916
+
917
+ lines = ["Hub Flow Overview:"]
918
+ groups: dict[str, list[tuple[str, str]]] = {}
919
+ group_order: list[str] = []
920
+
921
+ entries: list[dict[str, object]] = []
922
+ for repo in manifest.repos:
923
+ if not repo.enabled:
924
+ continue
925
+ repo_root = (self._hub_root / repo.path).resolve()
926
+ group, suffix = _group_key(repo.id)
927
+ label = suffix or repo.id
928
+ indent = " - " if suffix else ""
929
+ entries.append(
930
+ {
931
+ "repo_id": repo.id,
932
+ "repo_root": repo_root,
933
+ "label": label,
934
+ "indent": indent,
935
+ "group": group,
936
+ "unregistered": False,
937
+ }
938
+ )
939
+
940
+ extras = _discover_unregistered_worktrees(manifest, self._hub_root)
941
+ for extra in extras:
942
+ repo_id = str(extra["repo_id"])
943
+ group, _ = _group_key(repo_id)
944
+ extra["group"] = group
945
+ entries.append(extra)
946
+
947
+ for entry in entries:
948
+ repo_id = str(entry["repo_id"])
949
+ repo_root = Path(entry["repo_root"])
950
+ label = str(entry["label"])
951
+ indent = str(entry.get("indent", ""))
952
+ group = str(entry.get("group", repo_id))
953
+ if group not in groups:
954
+ groups[group] = []
955
+ group_order.append(group)
956
+
957
+ store = _load_flow_store(repo_root)
958
+ try:
959
+ store.initialize()
960
+ progress = ticket_progress(repo_root)
961
+ done = progress.get("done", 0)
962
+ total = progress.get("total", 0)
963
+ progress_label = f"{done}/{total}"
964
+ active = _select_latest_run(store, lambda run: run.status.is_active())
965
+ if active:
966
+ status_icon = (
967
+ "🟢" if active.status == FlowRunStatus.RUNNING else "🟡"
968
+ )
969
+ status_line = _format_status_line(
970
+ label,
971
+ status_icon=status_icon,
972
+ status_value=active.status.value,
973
+ progress_label=progress_label,
974
+ run_id=active.id,
975
+ indent=indent,
976
+ )
977
+ else:
978
+ paused = _select_latest_run(
979
+ store, lambda run: run.status == FlowRunStatus.PAUSED
980
+ )
981
+ if paused:
982
+ status_line = _format_status_line(
983
+ label,
984
+ status_icon="🔴",
985
+ status_value="PAUSED",
986
+ progress_label=progress_label,
987
+ run_id=paused.id,
988
+ indent=indent,
989
+ )
990
+ else:
991
+ status_line = _format_status_line(
992
+ label,
993
+ status_icon="⚪",
994
+ status_value="Idle",
995
+ progress_label=progress_label,
996
+ run_id=None,
997
+ indent=indent,
998
+ )
999
+ except Exception:
1000
+ status_line = f"{indent}❓ {label}: Error reading state"
1001
+ finally:
1002
+ store.close()
1003
+
1004
+ groups[group].append((label, status_line))
1005
+
1006
+ for group in group_order:
1007
+ entries = groups.get(group, [])
1008
+ if not entries:
1009
+ continue
1010
+ entries.sort(key=lambda pair: (0 if pair[0] == group else 1, pair[0]))
1011
+ lines.extend([line for _label, line in entries])
1012
+ lines.append("")
1013
+
1014
+ if lines and lines[-1] == "":
1015
+ lines.pop()
1016
+ if extras:
1017
+ lines.append("")
1018
+ lines.append(
1019
+ "Note: Unregistered worktrees detected. Run 'car hub scan' to register them."
1020
+ )
1021
+ lines.append("")
1022
+ lines.append("Tip: use /flow <repo-id> status for repo details.")
1023
+
1024
+ await self._send_message(
1025
+ message.chat_id,
1026
+ "\n".join(lines),
1027
+ thread_id=message.thread_id,
1028
+ reply_to=message.message_id,
1029
+ parse_mode=None,
1030
+ )
1031
+
744
1032
  async def _handle_flow_status_action(
745
1033
  self, message: TelegramMessage, repo_root: Path, argv: list[str]
746
1034
  ) -> None:
747
- store = FlowStore(_flow_paths(repo_root)[0])
1035
+ store = _load_flow_store(repo_root)
748
1036
  try:
749
1037
  store.initialize()
750
1038
  run_id_raw = self._first_non_flag(argv)
@@ -785,7 +1073,7 @@ class FlowCommands(SharedHelpers):
785
1073
  return
786
1074
  limit = min(limit_value, 50)
787
1075
 
788
- store = FlowStore(_flow_paths(repo_root)[0])
1076
+ store = _load_flow_store(repo_root)
789
1077
  try:
790
1078
  store.initialize()
791
1079
  runs = store.list_flow_runs(flow_type="ticket_flow")
@@ -838,7 +1126,7 @@ class FlowCommands(SharedHelpers):
838
1126
  tickets_exist = bool(existing_tickets)
839
1127
  issue_exists = issue_md_has_content(repo_root)
840
1128
 
841
- store = FlowStore(_flow_paths(repo_root)[0])
1129
+ store = _load_flow_store(repo_root)
842
1130
  active_run = None
843
1131
  try:
844
1132
  store.initialize()
@@ -865,7 +1153,7 @@ class FlowCommands(SharedHelpers):
865
1153
  if gh_available:
866
1154
  repo_label = f" for {repo_slug}" if repo_slug else ""
867
1155
  prompt = (
868
- "Enter GitHub issue number or URL" f"{repo_label} to seed ISSUE.md:"
1156
+ f"Enter GitHub issue number or URL{repo_label} to seed ISSUE.md:"
869
1157
  )
870
1158
  issue_ref = await self._prompt_flow_text_input(message, prompt)
871
1159
  if not issue_ref:
@@ -1057,7 +1345,7 @@ You are the first ticket in a new ticket_flow run.
1057
1345
  async def _handle_flow_resume(
1058
1346
  self, message: TelegramMessage, repo_root: Path, argv: list[str]
1059
1347
  ) -> None:
1060
- store = FlowStore(_flow_paths(repo_root)[0])
1348
+ store = _load_flow_store(repo_root)
1061
1349
  try:
1062
1350
  store.initialize()
1063
1351
  run_id_raw = self._first_non_flag(argv)
@@ -1117,7 +1405,7 @@ You are the first ticket in a new ticket_flow run.
1117
1405
  async def _handle_flow_stop(
1118
1406
  self, message: TelegramMessage, repo_root: Path, argv: list[str]
1119
1407
  ) -> None:
1120
- store = FlowStore(_flow_paths(repo_root)[0])
1408
+ store = _load_flow_store(repo_root)
1121
1409
  try:
1122
1410
  store.initialize()
1123
1411
  run_id_raw = self._first_non_flag(argv)
@@ -1165,7 +1453,7 @@ You are the first ticket in a new ticket_flow run.
1165
1453
  async def _handle_flow_recover(
1166
1454
  self, message: TelegramMessage, repo_root: Path, argv: list[str]
1167
1455
  ) -> None:
1168
- store = FlowStore(_flow_paths(repo_root)[0])
1456
+ store = _load_flow_store(repo_root)
1169
1457
  try:
1170
1458
  store.initialize()
1171
1459
  run_id_raw = self._first_non_flag(argv)
@@ -1218,7 +1506,7 @@ You are the first ticket in a new ticket_flow run.
1218
1506
  argv: Optional[list[str]] = None,
1219
1507
  ) -> None:
1220
1508
  argv = argv or []
1221
- store = FlowStore(_flow_paths(repo_root)[0])
1509
+ store = _load_flow_store(repo_root)
1222
1510
  record = None
1223
1511
  try:
1224
1512
  store.initialize()
@@ -1241,7 +1529,7 @@ You are the first ticket in a new ticket_flow run.
1241
1529
  self, message: TelegramMessage, repo_root: Path, argv: list[str]
1242
1530
  ) -> None:
1243
1531
  force = self._has_flag(argv, "--force")
1244
- store = FlowStore(_flow_paths(repo_root)[0])
1532
+ store = _load_flow_store(repo_root)
1245
1533
  record = None
1246
1534
  try:
1247
1535
  store.initialize()
@@ -1304,7 +1592,7 @@ You are the first ticket in a new ticket_flow run.
1304
1592
  archived_runs_dir = artifacts_root / record.id / "archived_runs"
1305
1593
  shutil.move(str(run_dir), str(archived_runs_dir))
1306
1594
 
1307
- store = FlowStore(_flow_paths(repo_root)[0])
1595
+ store = _load_flow_store(repo_root)
1308
1596
  try:
1309
1597
  store.initialize()
1310
1598
  store.delete_flow_run(record.id)