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