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.
- codex_autorunner/agents/opencode/client.py +113 -4
- codex_autorunner/agents/opencode/supervisor.py +4 -0
- codex_autorunner/agents/registry.py +17 -7
- codex_autorunner/bootstrap.py +219 -1
- codex_autorunner/core/__init__.py +17 -1
- codex_autorunner/core/about_car.py +114 -1
- codex_autorunner/core/app_server_threads.py +6 -0
- codex_autorunner/core/config.py +236 -1
- codex_autorunner/core/context_awareness.py +38 -0
- codex_autorunner/core/docs.py +0 -122
- codex_autorunner/core/filebox.py +265 -0
- codex_autorunner/core/flows/controller.py +71 -1
- codex_autorunner/core/flows/reconciler.py +4 -1
- codex_autorunner/core/flows/runtime.py +22 -0
- codex_autorunner/core/flows/store.py +61 -9
- codex_autorunner/core/flows/transition.py +23 -16
- codex_autorunner/core/flows/ux_helpers.py +18 -3
- codex_autorunner/core/flows/worker_process.py +32 -6
- codex_autorunner/core/hub.py +198 -41
- codex_autorunner/core/lifecycle_events.py +253 -0
- 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/agent_backend.py +2 -5
- codex_autorunner/core/ports/run_event.py +1 -4
- 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 +5 -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/ticket_linter_cli.py +17 -0
- codex_autorunner/core/ticket_manager_cli.py +154 -92
- codex_autorunner/core/time_utils.py +11 -0
- codex_autorunner/core/types.py +18 -0
- codex_autorunner/core/utils.py +34 -6
- codex_autorunner/flows/review/service.py +23 -25
- codex_autorunner/flows/ticket_flow/definition.py +43 -1
- codex_autorunner/integrations/agents/__init__.py +2 -0
- codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
- codex_autorunner/integrations/agents/codex_backend.py +19 -8
- codex_autorunner/integrations/agents/runner.py +3 -8
- codex_autorunner/integrations/agents/wiring.py +8 -0
- codex_autorunner/integrations/telegram/doctor.py +228 -6
- 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 +346 -58
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
- codex_autorunner/integrations/telegram/handlers/messages.py +26 -1
- codex_autorunner/integrations/telegram/helpers.py +1 -3
- codex_autorunner/integrations/telegram/runtime.py +9 -4
- codex_autorunner/integrations/telegram/service.py +30 -0
- codex_autorunner/integrations/telegram/state.py +38 -0
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
- codex_autorunner/integrations/telegram/transport.py +10 -3
- codex_autorunner/integrations/templates/__init__.py +27 -0
- codex_autorunner/integrations/templates/scan_agent.py +312 -0
- codex_autorunner/server.py +2 -2
- codex_autorunner/static/agentControls.js +21 -5
- codex_autorunner/static/app.js +115 -11
- codex_autorunner/static/chatUploads.js +137 -0
- 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 +46 -81
- codex_autorunner/static/index.html +303 -24
- codex_autorunner/static/messages.js +82 -4
- codex_autorunner/static/notifications.js +255 -0
- codex_autorunner/static/pma.js +1167 -0
- codex_autorunner/static/settings.js +3 -0
- codex_autorunner/static/streamUtils.js +57 -0
- codex_autorunner/static/styles.css +9125 -6742
- codex_autorunner/static/templateReposSettings.js +225 -0
- codex_autorunner/static/ticketChatActions.js +165 -3
- codex_autorunner/static/ticketChatStream.js +17 -119
- codex_autorunner/static/ticketEditor.js +41 -13
- codex_autorunner/static/ticketTemplates.js +798 -0
- codex_autorunner/static/tickets.js +69 -19
- codex_autorunner/static/turnEvents.js +27 -0
- codex_autorunner/static/turnResume.js +33 -0
- codex_autorunner/static/utils.js +28 -0
- codex_autorunner/static/workspace.js +258 -44
- codex_autorunner/static/workspaceFileBrowser.js +6 -4
- codex_autorunner/surfaces/cli/cli.py +1465 -155
- codex_autorunner/surfaces/cli/pma_cli.py +817 -0
- codex_autorunner/surfaces/web/app.py +253 -49
- codex_autorunner/surfaces/web/routes/__init__.py +4 -0
- codex_autorunner/surfaces/web/routes/analytics.py +29 -22
- codex_autorunner/surfaces/web/routes/file_chat.py +317 -36
- codex_autorunner/surfaces/web/routes/filebox.py +227 -0
- codex_autorunner/surfaces/web/routes/flows.py +219 -29
- codex_autorunner/surfaces/web/routes/messages.py +70 -39
- codex_autorunner/surfaces/web/routes/pma.py +1652 -0
- codex_autorunner/surfaces/web/routes/repos.py +1 -1
- codex_autorunner/surfaces/web/routes/shared.py +0 -3
- codex_autorunner/surfaces/web/routes/templates.py +634 -0
- codex_autorunner/surfaces/web/runner_manager.py +2 -2
- codex_autorunner/surfaces/web/schemas.py +70 -18
- codex_autorunner/tickets/agent_pool.py +27 -0
- codex_autorunner/tickets/files.py +33 -16
- codex_autorunner/tickets/lint.py +50 -0
- codex_autorunner/tickets/models.py +3 -0
- codex_autorunner/tickets/outbox.py +41 -5
- codex_autorunner/tickets/runner.py +350 -69
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/METADATA +15 -19
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/RECORD +125 -94
- codex_autorunner/core/adapter_utils.py +0 -21
- codex_autorunner/core/engine.py +0 -3302
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
|
@@ -13,9 +13,19 @@ from .config import (
|
|
|
13
13
|
|
|
14
14
|
ABOUT_CAR_BASENAME = "ABOUT_CAR.md"
|
|
15
15
|
ABOUT_CAR_REL_PATH = Path(".codex-autorunner") / ABOUT_CAR_BASENAME
|
|
16
|
+
TICKET_FLOW_QUICKSTART_BASENAME = "TICKET_FLOW_QUICKSTART.md"
|
|
17
|
+
TICKET_FLOW_QUICKSTART_REL_PATH = (
|
|
18
|
+
Path(".codex-autorunner") / TICKET_FLOW_QUICKSTART_BASENAME
|
|
19
|
+
)
|
|
20
|
+
TICKETS_AGENTS_BASENAME = "AGENTS.md"
|
|
21
|
+
TICKETS_AGENTS_REL_PATH = (
|
|
22
|
+
Path(".codex-autorunner") / "tickets" / TICKETS_AGENTS_BASENAME
|
|
23
|
+
)
|
|
16
24
|
|
|
17
25
|
# If this marker is present, codex-autorunner may safely refresh the file content.
|
|
18
26
|
ABOUT_CAR_GENERATED_MARKER = "<!-- CAR:AUTOGENERATED -->"
|
|
27
|
+
TICKET_FLOW_QUICKSTART_GENERATED_MARKER = "<!-- CAR:TICKET_FLOW_QUICKSTART -->"
|
|
28
|
+
TICKETS_AGENTS_GENERATED_MARKER = "<!-- CAR:TICKETS_AGENTS -->"
|
|
19
29
|
|
|
20
30
|
CAR_CONTEXT_KEYWORDS = (
|
|
21
31
|
"car",
|
|
@@ -76,8 +86,11 @@ def build_about_car_markdown(
|
|
|
76
86
|
"You are running inside **Codex Autorunner (CAR)**.\n\n"
|
|
77
87
|
"CAR uses a ticket-first workflow.\n\n"
|
|
78
88
|
"## Required for operation\n"
|
|
79
|
-
"- Tickets live under `.codex-autorunner/tickets
|
|
89
|
+
"- Tickets live under `.codex-autorunner/tickets/` (per-repo/worktree).\n"
|
|
90
|
+
"- If the user provides ticket files, place them in the repo's `.codex-autorunner/tickets/` folder.\n"
|
|
80
91
|
"- Lint ticket frontmatter after edits (runs against all tickets): `python3 .codex-autorunner/bin/lint_tickets.py`.\n\n"
|
|
92
|
+
"## Ticket flow quickstart\n"
|
|
93
|
+
f"- Read `{_display_path(repo_root, TICKET_FLOW_QUICKSTART_REL_PATH)}` for CLI entrypoints + gotchas.\n\n"
|
|
81
94
|
"## Optional workspace docs\n"
|
|
82
95
|
"- **Active context**: "
|
|
83
96
|
f"`{active_context_disp}`\n"
|
|
@@ -96,6 +109,12 @@ def build_about_car_markdown(
|
|
|
96
109
|
"- Use `.codex-autorunner/bin/ticket_tool.py` to list/create/insert/move tickets; it is portable and venv-free.\n"
|
|
97
110
|
'- Common workflows: insert gap before N (`python3 .codex-autorunner/bin/ticket_tool.py insert --before N`); move a block (`... move --start A --end B --to T`); create with auto-quoted frontmatter (`... create --title "Fix #123" --agent codex`).\n'
|
|
98
111
|
"- After any ticket edits, lint all tickets: `python3 .codex-autorunner/bin/lint_tickets.py`.\n\n"
|
|
112
|
+
"## Ticket templates (optional)\n"
|
|
113
|
+
"- CAR can fetch ticket templates from configured git repos (treat templates as code).\n"
|
|
114
|
+
"- Fetch (prints template to stdout): `car templates fetch <repo_id>:<path>[@<ref>]`\n"
|
|
115
|
+
"- Pin to a commit for determinism: `...@<commit_sha>`\n"
|
|
116
|
+
"- Trusted repos skip scanning. Untrusted repos are scanned (cached by blob SHA) before content is returned.\n"
|
|
117
|
+
"- If fetch or scan fails, pause and notify the user rather than guessing.\n\n"
|
|
99
118
|
"## How CAR works (short)\n"
|
|
100
119
|
"- The web UI provides ticket editing + unified file chat.\n"
|
|
101
120
|
"- `car serve` starts the hub web UI. The **Terminal** tab launches the configured `codex` binary in a PTY.\n"
|
|
@@ -144,6 +163,54 @@ def ensure_about_car_file_for_repo(
|
|
|
144
163
|
return path
|
|
145
164
|
|
|
146
165
|
|
|
166
|
+
def build_ticket_flow_quickstart_markdown(*, repo_root: Path) -> str:
|
|
167
|
+
ticket_dir = ".codex-autorunner/tickets/"
|
|
168
|
+
return (
|
|
169
|
+
f"{TICKET_FLOW_QUICKSTART_GENERATED_MARKER}\n"
|
|
170
|
+
"# Ticket Flow Quickstart\n\n"
|
|
171
|
+
"## Start ticket flow via CLI\n"
|
|
172
|
+
"- Bootstrap the first run (creates TICKET-001 if needed):\n"
|
|
173
|
+
" `car flow ticket_flow bootstrap --repo <path>`\n"
|
|
174
|
+
" (alias: `car ticket-flow bootstrap --repo <path>`)\n"
|
|
175
|
+
"- Start/resume without seeding tickets:\n"
|
|
176
|
+
" `car flow ticket_flow start --repo <path>`\n"
|
|
177
|
+
"- Check status:\n"
|
|
178
|
+
" `car flow ticket_flow status --repo <path> [--run-id <uuid>]`\n"
|
|
179
|
+
"- Resume/stop:\n"
|
|
180
|
+
" `car flow ticket_flow resume --repo <path> [--run-id <uuid>]`\n"
|
|
181
|
+
" `car flow ticket_flow stop --repo <path> [--run-id <uuid>]`\n\n"
|
|
182
|
+
"## Where tickets live\n"
|
|
183
|
+
f"- Tickets are per-repo/worktree under `{ticket_dir}`.\n"
|
|
184
|
+
"- If the user provides ticket files, save them directly into that folder.\n\n"
|
|
185
|
+
"## Common gotchas\n"
|
|
186
|
+
"- Hub vs repo: ticket flows run per-repo; CLI commands need a repo path.\n"
|
|
187
|
+
"- `--repo` expects a filesystem path, not a hub repo_id.\n"
|
|
188
|
+
"- Each worktree has its own `.codex-autorunner/` directory.\n"
|
|
189
|
+
"- If this repo/worktree lives under a hub, it must be registered in the hub manifest to show up in the hub UI. Run: `car hub scan` (or create it via `car hub worktree create`).\n"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def build_tickets_agents_markdown(*, repo_root: Path) -> str:
|
|
194
|
+
quickstart_path = _display_path(repo_root, TICKET_FLOW_QUICKSTART_REL_PATH)
|
|
195
|
+
return (
|
|
196
|
+
f"{TICKETS_AGENTS_GENERATED_MARKER}\n"
|
|
197
|
+
"# Tickets — AGENTS\n\n"
|
|
198
|
+
"This folder is the authoritative ticket queue for this repo/worktree.\n\n"
|
|
199
|
+
"## Ticket files\n"
|
|
200
|
+
"- Store work items as `TICKET-###*.md` (ordered by number).\n"
|
|
201
|
+
"- Keep frontmatter `done: true|false` in sync with completion.\n"
|
|
202
|
+
"- After edits, lint tickets: `python3 .codex-autorunner/bin/lint_tickets.py`.\n\n"
|
|
203
|
+
"## Ticket CLI (portable)\n"
|
|
204
|
+
"- List: `python3 .codex-autorunner/bin/ticket_tool.py list`\n"
|
|
205
|
+
'- Create: `python3 .codex-autorunner/bin/ticket_tool.py create --title "..." --agent codex`\n'
|
|
206
|
+
"- Insert gap: `python3 .codex-autorunner/bin/ticket_tool.py insert --before N`\n"
|
|
207
|
+
"- Move block: `python3 .codex-autorunner/bin/ticket_tool.py move --start A --end B --to T`\n"
|
|
208
|
+
"- Lint: `python3 .codex-autorunner/bin/ticket_tool.py lint`\n\n"
|
|
209
|
+
"## Ticket flow (runner)\n"
|
|
210
|
+
f"- See `{quickstart_path}` for `car flow ticket_flow ...` commands.\n"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
147
214
|
def ensure_about_car_file(config: Config, *, force: bool = False) -> Path:
|
|
148
215
|
"""Config-aware wrapper that uses configured doc paths."""
|
|
149
216
|
repo_root = config.root
|
|
@@ -153,3 +220,49 @@ def ensure_about_car_file(config: Config, *, force: bool = False) -> Path:
|
|
|
153
220
|
"spec": config.doc_path("spec"),
|
|
154
221
|
}
|
|
155
222
|
return ensure_about_car_file_for_repo(repo_root, doc_paths=docs, force=force)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def ensure_ticket_flow_quickstart_file_for_repo(
|
|
226
|
+
repo_root: Path, *, force: bool = False
|
|
227
|
+
) -> Path:
|
|
228
|
+
path = repo_root / TICKET_FLOW_QUICKSTART_REL_PATH
|
|
229
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
content = build_ticket_flow_quickstart_markdown(repo_root=repo_root)
|
|
231
|
+
if content and not content.endswith("\n"):
|
|
232
|
+
content += "\n"
|
|
233
|
+
|
|
234
|
+
if path.exists() and not force:
|
|
235
|
+
try:
|
|
236
|
+
existing = path.read_text(encoding="utf-8")
|
|
237
|
+
except OSError:
|
|
238
|
+
existing = ""
|
|
239
|
+
if TICKET_FLOW_QUICKSTART_GENERATED_MARKER not in existing:
|
|
240
|
+
return path
|
|
241
|
+
if existing == content:
|
|
242
|
+
return path
|
|
243
|
+
|
|
244
|
+
path.write_text(content, encoding="utf-8")
|
|
245
|
+
return path
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def ensure_tickets_agents_file_for_repo(
|
|
249
|
+
repo_root: Path, *, force: bool = False
|
|
250
|
+
) -> Path:
|
|
251
|
+
path = repo_root / TICKETS_AGENTS_REL_PATH
|
|
252
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
253
|
+
content = build_tickets_agents_markdown(repo_root=repo_root)
|
|
254
|
+
if content and not content.endswith("\n"):
|
|
255
|
+
content += "\n"
|
|
256
|
+
|
|
257
|
+
if path.exists() and not force:
|
|
258
|
+
try:
|
|
259
|
+
existing = path.read_text(encoding="utf-8")
|
|
260
|
+
except OSError:
|
|
261
|
+
existing = ""
|
|
262
|
+
if TICKETS_AGENTS_GENERATED_MARKER not in existing:
|
|
263
|
+
return path
|
|
264
|
+
if existing == content:
|
|
265
|
+
return path
|
|
266
|
+
|
|
267
|
+
path.write_text(content, encoding="utf-8")
|
|
268
|
+
return path
|
|
@@ -17,6 +17,8 @@ FILE_CHAT_KEY = "file_chat"
|
|
|
17
17
|
FILE_CHAT_OPENCODE_KEY = "file_chat.opencode"
|
|
18
18
|
FILE_CHAT_PREFIX = "file_chat."
|
|
19
19
|
FILE_CHAT_OPENCODE_PREFIX = "file_chat.opencode."
|
|
20
|
+
PMA_KEY = "pma"
|
|
21
|
+
PMA_OPENCODE_KEY = "pma.opencode"
|
|
20
22
|
|
|
21
23
|
LOGGER = logging.getLogger("codex_autorunner.app_server")
|
|
22
24
|
|
|
@@ -24,6 +26,8 @@ LOGGER = logging.getLogger("codex_autorunner.app_server")
|
|
|
24
26
|
FEATURE_KEYS = {
|
|
25
27
|
FILE_CHAT_KEY,
|
|
26
28
|
FILE_CHAT_OPENCODE_KEY,
|
|
29
|
+
PMA_KEY,
|
|
30
|
+
PMA_OPENCODE_KEY,
|
|
27
31
|
"autorunner",
|
|
28
32
|
"autorunner.opencode",
|
|
29
33
|
}
|
|
@@ -92,6 +96,8 @@ class AppServerThreadRegistry:
|
|
|
92
96
|
"file_chat_opencode": threads.get(FILE_CHAT_OPENCODE_KEY),
|
|
93
97
|
"autorunner": threads.get("autorunner"),
|
|
94
98
|
"autorunner_opencode": threads.get("autorunner.opencode"),
|
|
99
|
+
"pma": threads.get(PMA_KEY),
|
|
100
|
+
"pma_opencode": threads.get(PMA_OPENCODE_KEY),
|
|
95
101
|
}
|
|
96
102
|
notice = self.corruption_notice()
|
|
97
103
|
if notice:
|
codex_autorunner/core/config.py
CHANGED
|
@@ -12,6 +12,7 @@ import yaml
|
|
|
12
12
|
|
|
13
13
|
from ..housekeeping import HousekeepingConfig, parse_housekeeping_config
|
|
14
14
|
from .path_utils import ConfigPathError, resolve_config_path
|
|
15
|
+
from .utils import atomic_write
|
|
15
16
|
|
|
16
17
|
logger = logging.getLogger("codex_autorunner.core.config")
|
|
17
18
|
|
|
@@ -138,6 +139,7 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
|
|
|
138
139
|
"approval_mode": "yolo",
|
|
139
140
|
# Keep ticket_flow deterministic by default; surfaces can tighten this.
|
|
140
141
|
"default_approval_decision": "accept",
|
|
142
|
+
"include_previous_ticket_context": False,
|
|
141
143
|
},
|
|
142
144
|
"git": {
|
|
143
145
|
"auto_commit": False,
|
|
@@ -197,6 +199,7 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
|
|
|
197
199
|
},
|
|
198
200
|
"opencode": {
|
|
199
201
|
"session_stall_timeout_seconds": 60,
|
|
202
|
+
"max_text_chars": 20000,
|
|
200
203
|
},
|
|
201
204
|
"usage": {
|
|
202
205
|
"cache_scope": "global",
|
|
@@ -425,6 +428,9 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
|
|
|
425
428
|
},
|
|
426
429
|
],
|
|
427
430
|
},
|
|
431
|
+
"storage": {
|
|
432
|
+
"durable_writes": False,
|
|
433
|
+
},
|
|
428
434
|
}
|
|
429
435
|
|
|
430
436
|
REPO_DEFAULT_KEYS = {
|
|
@@ -460,12 +466,38 @@ REPO_SHARED_KEYS = {
|
|
|
460
466
|
"housekeeping",
|
|
461
467
|
"update",
|
|
462
468
|
"usage",
|
|
469
|
+
"templates",
|
|
463
470
|
}
|
|
464
471
|
|
|
465
472
|
DEFAULT_HUB_CONFIG: Dict[str, Any] = {
|
|
466
473
|
"version": CONFIG_VERSION,
|
|
467
474
|
"mode": "hub",
|
|
468
475
|
"repo_defaults": DEFAULT_REPO_DEFAULTS,
|
|
476
|
+
"pma": {
|
|
477
|
+
"enabled": True,
|
|
478
|
+
"default_agent": "codex",
|
|
479
|
+
"model": None,
|
|
480
|
+
"reasoning": None,
|
|
481
|
+
"max_upload_bytes": 10_000_000,
|
|
482
|
+
"max_repos": 25,
|
|
483
|
+
"max_messages": 10,
|
|
484
|
+
"max_text_chars": 800,
|
|
485
|
+
# PMA durable workspace docs (hub-level)
|
|
486
|
+
"docs_max_chars": 12_000,
|
|
487
|
+
"active_context_max_lines": 200,
|
|
488
|
+
"context_log_tail_lines": 120,
|
|
489
|
+
},
|
|
490
|
+
"templates": {
|
|
491
|
+
"enabled": True,
|
|
492
|
+
"repos": [
|
|
493
|
+
{
|
|
494
|
+
"id": "blessed",
|
|
495
|
+
"url": "https://github.com/Git-on-my-level/car-ticket-templates",
|
|
496
|
+
"trusted": True,
|
|
497
|
+
"default_ref": "main",
|
|
498
|
+
}
|
|
499
|
+
],
|
|
500
|
+
},
|
|
469
501
|
"agents": {
|
|
470
502
|
"codex": {
|
|
471
503
|
"binary": "codex",
|
|
@@ -618,6 +650,7 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
|
|
|
618
650
|
},
|
|
619
651
|
"opencode": {
|
|
620
652
|
"session_stall_timeout_seconds": 60,
|
|
653
|
+
"max_text_chars": 20000,
|
|
621
654
|
},
|
|
622
655
|
"usage": {
|
|
623
656
|
"cache_scope": "global",
|
|
@@ -724,6 +757,9 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
|
|
|
724
757
|
},
|
|
725
758
|
],
|
|
726
759
|
},
|
|
760
|
+
"storage": {
|
|
761
|
+
"durable_writes": False,
|
|
762
|
+
},
|
|
727
763
|
}
|
|
728
764
|
|
|
729
765
|
|
|
@@ -810,6 +846,24 @@ class AppServerConfig:
|
|
|
810
846
|
@dataclasses.dataclass
|
|
811
847
|
class OpenCodeConfig:
|
|
812
848
|
session_stall_timeout_seconds: Optional[float]
|
|
849
|
+
max_text_chars: Optional[int]
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
@dataclasses.dataclass
|
|
853
|
+
class PmaConfig:
|
|
854
|
+
enabled: bool
|
|
855
|
+
default_agent: str
|
|
856
|
+
model: Optional[str]
|
|
857
|
+
reasoning: Optional[str]
|
|
858
|
+
max_upload_bytes: int
|
|
859
|
+
max_repos: int
|
|
860
|
+
max_messages: int
|
|
861
|
+
max_text_chars: int
|
|
862
|
+
# Hub-level PMA durable workspace docs
|
|
863
|
+
docs_max_chars: int = 12_000
|
|
864
|
+
active_context_max_lines: int = 200
|
|
865
|
+
context_log_tail_lines: int = 120
|
|
866
|
+
dispatch_interception_enabled: bool = False
|
|
813
867
|
|
|
814
868
|
|
|
815
869
|
@dataclasses.dataclass
|
|
@@ -827,6 +881,20 @@ class AgentConfig:
|
|
|
827
881
|
subagent_models: Optional[Dict[str, str]]
|
|
828
882
|
|
|
829
883
|
|
|
884
|
+
@dataclasses.dataclass(frozen=True)
|
|
885
|
+
class TemplateRepoConfig:
|
|
886
|
+
id: str
|
|
887
|
+
url: str
|
|
888
|
+
trusted: bool
|
|
889
|
+
default_ref: str
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
@dataclasses.dataclass(frozen=True)
|
|
893
|
+
class TemplatesConfig:
|
|
894
|
+
enabled: bool
|
|
895
|
+
repos: List[TemplateRepoConfig]
|
|
896
|
+
|
|
897
|
+
|
|
830
898
|
@dataclasses.dataclass
|
|
831
899
|
class RepoConfig:
|
|
832
900
|
raw: Dict[str, Any]
|
|
@@ -869,6 +937,8 @@ class RepoConfig:
|
|
|
869
937
|
voice: Dict[str, Any]
|
|
870
938
|
static_assets: StaticAssetsConfig
|
|
871
939
|
housekeeping: HousekeepingConfig
|
|
940
|
+
durable_writes: bool
|
|
941
|
+
templates: TemplatesConfig
|
|
872
942
|
|
|
873
943
|
def doc_path(self, key: str) -> Path:
|
|
874
944
|
return self.root / self.docs[key]
|
|
@@ -894,6 +964,7 @@ class HubConfig:
|
|
|
894
964
|
mode: str
|
|
895
965
|
repo_defaults: Dict[str, Any]
|
|
896
966
|
agents: Dict[str, AgentConfig]
|
|
967
|
+
templates: TemplatesConfig
|
|
897
968
|
repos_root: Path
|
|
898
969
|
worktrees_root: Path
|
|
899
970
|
manifest_path: Path
|
|
@@ -905,6 +976,7 @@ class HubConfig:
|
|
|
905
976
|
update_skip_checks: bool
|
|
906
977
|
app_server: AppServerConfig
|
|
907
978
|
opencode: OpenCodeConfig
|
|
979
|
+
pma: PmaConfig
|
|
908
980
|
usage: UsageConfig
|
|
909
981
|
server_host: str
|
|
910
982
|
server_port: int
|
|
@@ -917,6 +989,7 @@ class HubConfig:
|
|
|
917
989
|
server_log: LogConfig
|
|
918
990
|
static_assets: StaticAssetsConfig
|
|
919
991
|
housekeeping: HousekeepingConfig
|
|
992
|
+
durable_writes: bool
|
|
920
993
|
|
|
921
994
|
def agent_binary(self, agent_id: str) -> str:
|
|
922
995
|
agent = self.agents.get(agent_id)
|
|
@@ -977,6 +1050,23 @@ def _load_root_config(root: Path) -> Dict[str, Any]:
|
|
|
977
1050
|
return merged
|
|
978
1051
|
|
|
979
1052
|
|
|
1053
|
+
def update_override_templates(repo_root: Path, repos: List[Dict[str, Any]]) -> None:
|
|
1054
|
+
"""
|
|
1055
|
+
Update templates.repos in the root override file, preserving other settings.
|
|
1056
|
+
|
|
1057
|
+
This writes to `codex-autorunner.override.yml` (gitignored) at the provided repo_root.
|
|
1058
|
+
"""
|
|
1059
|
+
override_path = repo_root / ROOT_OVERRIDE_FILENAME
|
|
1060
|
+
data = _load_yaml_dict(override_path)
|
|
1061
|
+
templates = data.get("templates")
|
|
1062
|
+
if templates is None or not isinstance(templates, dict):
|
|
1063
|
+
templates = {}
|
|
1064
|
+
data["templates"] = templates
|
|
1065
|
+
templates["repos"] = list(repos or [])
|
|
1066
|
+
rendered = yaml.safe_dump(data, sort_keys=False).rstrip() + "\n"
|
|
1067
|
+
atomic_write(override_path, rendered)
|
|
1068
|
+
|
|
1069
|
+
|
|
980
1070
|
def load_root_defaults(root: Path) -> Dict[str, Any]:
|
|
981
1071
|
"""Load hub defaults from the root config + override file."""
|
|
982
1072
|
return _load_root_config(root)
|
|
@@ -1271,7 +1361,77 @@ def _parse_opencode_config(
|
|
|
1271
1361
|
)
|
|
1272
1362
|
if stall_timeout_seconds is not None and stall_timeout_seconds <= 0:
|
|
1273
1363
|
stall_timeout_seconds = None
|
|
1274
|
-
|
|
1364
|
+
max_text_chars_raw = cfg.get("max_text_chars", defaults.get("max_text_chars"))
|
|
1365
|
+
max_text_chars = (
|
|
1366
|
+
int(max_text_chars_raw)
|
|
1367
|
+
if isinstance(max_text_chars_raw, int) and max_text_chars_raw > 0
|
|
1368
|
+
else None
|
|
1369
|
+
)
|
|
1370
|
+
return OpenCodeConfig(
|
|
1371
|
+
session_stall_timeout_seconds=stall_timeout_seconds,
|
|
1372
|
+
max_text_chars=max_text_chars,
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def _parse_pma_config(
|
|
1377
|
+
cfg: Optional[Dict[str, Any]],
|
|
1378
|
+
_root: Path,
|
|
1379
|
+
defaults: Optional[Dict[str, Any]],
|
|
1380
|
+
) -> PmaConfig:
|
|
1381
|
+
cfg = cfg if isinstance(cfg, dict) else {}
|
|
1382
|
+
defaults = defaults if isinstance(defaults, dict) else {}
|
|
1383
|
+
enabled = bool(cfg.get("enabled", defaults.get("enabled", True)))
|
|
1384
|
+
default_agent = str(
|
|
1385
|
+
cfg.get("default_agent", defaults.get("default_agent", "codex"))
|
|
1386
|
+
)
|
|
1387
|
+
model_raw = cfg.get("model", defaults.get("model"))
|
|
1388
|
+
model = str(model_raw).strip() or None if model_raw else None
|
|
1389
|
+
reasoning_raw = cfg.get("reasoning", defaults.get("reasoning"))
|
|
1390
|
+
reasoning = str(reasoning_raw).strip() or None if reasoning_raw else None
|
|
1391
|
+
max_upload_bytes_raw = cfg.get(
|
|
1392
|
+
"max_upload_bytes", defaults.get("max_upload_bytes", 10_000_000)
|
|
1393
|
+
)
|
|
1394
|
+
try:
|
|
1395
|
+
max_upload_bytes = int(max_upload_bytes_raw)
|
|
1396
|
+
except (ValueError, TypeError):
|
|
1397
|
+
max_upload_bytes = 10_000_000
|
|
1398
|
+
if max_upload_bytes <= 0:
|
|
1399
|
+
max_upload_bytes = 10_000_000
|
|
1400
|
+
|
|
1401
|
+
def _parse_positive_int(key: str, fallback: int) -> int:
|
|
1402
|
+
raw = cfg.get(key, defaults.get(key, fallback))
|
|
1403
|
+
try:
|
|
1404
|
+
value = int(raw)
|
|
1405
|
+
except (ValueError, TypeError):
|
|
1406
|
+
return fallback
|
|
1407
|
+
return value if value > 0 else fallback
|
|
1408
|
+
|
|
1409
|
+
max_repos = _parse_positive_int("max_repos", 25)
|
|
1410
|
+
max_messages = _parse_positive_int("max_messages", 10)
|
|
1411
|
+
max_text_chars = _parse_positive_int("max_text_chars", 800)
|
|
1412
|
+
docs_max_chars = _parse_positive_int("docs_max_chars", 12_000)
|
|
1413
|
+
active_context_max_lines = _parse_positive_int("active_context_max_lines", 200)
|
|
1414
|
+
context_log_tail_lines = _parse_positive_int("context_log_tail_lines", 120)
|
|
1415
|
+
dispatch_interception_enabled = bool(
|
|
1416
|
+
cfg.get(
|
|
1417
|
+
"dispatch_interception_enabled",
|
|
1418
|
+
defaults.get("dispatch_interception_enabled", False),
|
|
1419
|
+
)
|
|
1420
|
+
)
|
|
1421
|
+
return PmaConfig(
|
|
1422
|
+
enabled=enabled,
|
|
1423
|
+
default_agent=default_agent,
|
|
1424
|
+
model=model,
|
|
1425
|
+
reasoning=reasoning,
|
|
1426
|
+
max_upload_bytes=max_upload_bytes,
|
|
1427
|
+
max_repos=max_repos,
|
|
1428
|
+
max_messages=max_messages,
|
|
1429
|
+
max_text_chars=max_text_chars,
|
|
1430
|
+
docs_max_chars=docs_max_chars,
|
|
1431
|
+
active_context_max_lines=active_context_max_lines,
|
|
1432
|
+
context_log_tail_lines=context_log_tail_lines,
|
|
1433
|
+
dispatch_interception_enabled=dispatch_interception_enabled,
|
|
1434
|
+
)
|
|
1275
1435
|
|
|
1276
1436
|
|
|
1277
1437
|
def _parse_usage_config(
|
|
@@ -1337,6 +1497,55 @@ def _parse_agents_config(
|
|
|
1337
1497
|
return agents
|
|
1338
1498
|
|
|
1339
1499
|
|
|
1500
|
+
def _parse_templates_config(
|
|
1501
|
+
cfg: Optional[Dict[str, Any]],
|
|
1502
|
+
defaults: Optional[Dict[str, Any]],
|
|
1503
|
+
) -> TemplatesConfig:
|
|
1504
|
+
cfg = cfg if isinstance(cfg, dict) else {}
|
|
1505
|
+
defaults = defaults if isinstance(defaults, dict) else {}
|
|
1506
|
+
enabled_raw = cfg.get("enabled", defaults.get("enabled", True))
|
|
1507
|
+
if "enabled" in cfg and not isinstance(enabled_raw, bool):
|
|
1508
|
+
raise ConfigError("templates.enabled must be boolean")
|
|
1509
|
+
enabled = bool(enabled_raw)
|
|
1510
|
+
repos_raw = cfg.get("repos", defaults.get("repos", []))
|
|
1511
|
+
if repos_raw is None:
|
|
1512
|
+
repos_raw = []
|
|
1513
|
+
if not isinstance(repos_raw, list):
|
|
1514
|
+
raise ConfigError("templates.repos must be a list")
|
|
1515
|
+
repos: List[TemplateRepoConfig] = []
|
|
1516
|
+
seen_ids: set[str] = set()
|
|
1517
|
+
for idx, repo in enumerate(repos_raw):
|
|
1518
|
+
if not isinstance(repo, dict):
|
|
1519
|
+
raise ConfigError(f"templates.repos[{idx}] must be a mapping")
|
|
1520
|
+
repo_id = repo.get("id")
|
|
1521
|
+
if not isinstance(repo_id, str) or not repo_id.strip():
|
|
1522
|
+
raise ConfigError(f"templates.repos[{idx}].id must be a non-empty string")
|
|
1523
|
+
repo_id = repo_id.strip()
|
|
1524
|
+
if repo_id in seen_ids:
|
|
1525
|
+
raise ConfigError(f"templates.repos[{idx}].id must be unique")
|
|
1526
|
+
seen_ids.add(repo_id)
|
|
1527
|
+
url = repo.get("url")
|
|
1528
|
+
if not isinstance(url, str) or not url.strip():
|
|
1529
|
+
raise ConfigError(f"templates.repos[{idx}].url must be a non-empty string")
|
|
1530
|
+
trusted = repo.get("trusted", False)
|
|
1531
|
+
if "trusted" in repo and not isinstance(trusted, bool):
|
|
1532
|
+
raise ConfigError(f"templates.repos[{idx}].trusted must be boolean")
|
|
1533
|
+
default_ref = repo.get("default_ref", "main")
|
|
1534
|
+
if not isinstance(default_ref, str) or not default_ref.strip():
|
|
1535
|
+
raise ConfigError(
|
|
1536
|
+
f"templates.repos[{idx}].default_ref must be a non-empty string"
|
|
1537
|
+
)
|
|
1538
|
+
repos.append(
|
|
1539
|
+
TemplateRepoConfig(
|
|
1540
|
+
id=repo_id,
|
|
1541
|
+
url=url.strip(),
|
|
1542
|
+
trusted=bool(trusted),
|
|
1543
|
+
default_ref=default_ref.strip(),
|
|
1544
|
+
)
|
|
1545
|
+
)
|
|
1546
|
+
return TemplatesConfig(enabled=enabled, repos=repos)
|
|
1547
|
+
|
|
1548
|
+
|
|
1340
1549
|
def _parse_static_assets_config(
|
|
1341
1550
|
cfg: Optional[Dict[str, Any]],
|
|
1342
1551
|
root: Path,
|
|
@@ -1628,6 +1837,11 @@ def _build_repo_config(config_path: Path, cfg: Dict[str, Any]) -> RepoConfig:
|
|
|
1628
1837
|
autorunner_reuse_session = (
|
|
1629
1838
|
bool(reuse_session_value) if reuse_session_value is not None else False
|
|
1630
1839
|
)
|
|
1840
|
+
storage_cfg = cfg.get("storage")
|
|
1841
|
+
storage_cfg = cast(
|
|
1842
|
+
Dict[str, Any], storage_cfg if isinstance(storage_cfg, dict) else {}
|
|
1843
|
+
)
|
|
1844
|
+
durable_writes = bool(storage_cfg.get("durable_writes", False))
|
|
1631
1845
|
return RepoConfig(
|
|
1632
1846
|
raw=cfg,
|
|
1633
1847
|
root=root,
|
|
@@ -1701,6 +1915,10 @@ def _build_repo_config(config_path: Path, cfg: Dict[str, Any]) -> RepoConfig:
|
|
|
1701
1915
|
cfg.get("static_assets"), root, DEFAULT_REPO_CONFIG["static_assets"]
|
|
1702
1916
|
),
|
|
1703
1917
|
housekeeping=parse_housekeeping_config(cfg.get("housekeeping")),
|
|
1918
|
+
durable_writes=durable_writes,
|
|
1919
|
+
templates=_parse_templates_config(
|
|
1920
|
+
cfg.get("templates"), DEFAULT_HUB_CONFIG.get("templates")
|
|
1921
|
+
),
|
|
1704
1922
|
)
|
|
1705
1923
|
|
|
1706
1924
|
|
|
@@ -1738,6 +1956,11 @@ def _build_hub_config(config_path: Path, cfg: Dict[str, Any]) -> HubConfig:
|
|
|
1738
1956
|
Dict[str, Any], update_cfg if isinstance(update_cfg, dict) else {}
|
|
1739
1957
|
)
|
|
1740
1958
|
update_skip_checks = bool(update_cfg.get("skip_checks", False))
|
|
1959
|
+
storage_cfg = cfg.get("storage")
|
|
1960
|
+
storage_cfg = cast(
|
|
1961
|
+
Dict[str, Any], storage_cfg if isinstance(storage_cfg, dict) else {}
|
|
1962
|
+
)
|
|
1963
|
+
durable_writes = bool(storage_cfg.get("durable_writes", False))
|
|
1741
1964
|
|
|
1742
1965
|
return HubConfig(
|
|
1743
1966
|
raw=cfg,
|
|
@@ -1746,6 +1969,9 @@ def _build_hub_config(config_path: Path, cfg: Dict[str, Any]) -> HubConfig:
|
|
|
1746
1969
|
mode="hub",
|
|
1747
1970
|
repo_defaults=cast(Dict[str, Any], cfg.get("repo_defaults") or {}),
|
|
1748
1971
|
agents=_parse_agents_config(cfg, DEFAULT_HUB_CONFIG),
|
|
1972
|
+
templates=_parse_templates_config(
|
|
1973
|
+
cfg.get("templates"), DEFAULT_HUB_CONFIG.get("templates")
|
|
1974
|
+
),
|
|
1749
1975
|
repos_root=(root / hub_cfg["repos_root"]).resolve(),
|
|
1750
1976
|
worktrees_root=(root / hub_cfg["worktrees_root"]).resolve(),
|
|
1751
1977
|
manifest_path=root / hub_cfg["manifest"],
|
|
@@ -1755,6 +1981,7 @@ def _build_hub_config(config_path: Path, cfg: Dict[str, Any]) -> HubConfig:
|
|
|
1755
1981
|
update_repo_url=str(hub_cfg.get("update_repo_url", "")),
|
|
1756
1982
|
update_repo_ref=str(hub_cfg.get("update_repo_ref", "main")),
|
|
1757
1983
|
update_skip_checks=update_skip_checks,
|
|
1984
|
+
durable_writes=durable_writes,
|
|
1758
1985
|
app_server=_parse_app_server_config(
|
|
1759
1986
|
cfg.get("app_server"),
|
|
1760
1987
|
root,
|
|
@@ -1763,6 +1990,7 @@ def _build_hub_config(config_path: Path, cfg: Dict[str, Any]) -> HubConfig:
|
|
|
1763
1990
|
opencode=_parse_opencode_config(
|
|
1764
1991
|
cfg.get("opencode"), root, DEFAULT_HUB_CONFIG.get("opencode")
|
|
1765
1992
|
),
|
|
1993
|
+
pma=_parse_pma_config(cfg.get("pma"), root, DEFAULT_HUB_CONFIG.get("pma")),
|
|
1766
1994
|
usage=_parse_usage_config(
|
|
1767
1995
|
cfg.get("usage"), root, DEFAULT_HUB_CONFIG.get("usage")
|
|
1768
1996
|
),
|
|
@@ -1969,6 +2197,13 @@ def _validate_opencode_config(cfg: Dict[str, Any]) -> None:
|
|
|
1969
2197
|
raise ConfigError(
|
|
1970
2198
|
"opencode.session_stall_timeout_seconds must be a number or null"
|
|
1971
2199
|
)
|
|
2200
|
+
if (
|
|
2201
|
+
"max_text_chars" in opencode_cfg
|
|
2202
|
+
and opencode_cfg.get("max_text_chars") is not None
|
|
2203
|
+
):
|
|
2204
|
+
max_text_chars = opencode_cfg.get("max_text_chars")
|
|
2205
|
+
if not isinstance(max_text_chars, int):
|
|
2206
|
+
raise ConfigError("opencode.max_text_chars must be an integer or null")
|
|
1972
2207
|
|
|
1973
2208
|
|
|
1974
2209
|
def _validate_update_config(cfg: Dict[str, Any]) -> None:
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
CAR_AWARENESS_BLOCK = """<injected context>
|
|
6
|
+
You are operating inside a Codex Autorunner (CAR) managed repo.
|
|
7
|
+
|
|
8
|
+
CAR’s durable control-plane lives under `.codex-autorunner/`:
|
|
9
|
+
- `.codex-autorunner/ABOUT_CAR.md` — short repo-local briefing (ticket/workspace conventions + helper scripts).
|
|
10
|
+
- `.codex-autorunner/tickets/` — ordered ticket queue (`TICKET-###*.md`) used by the ticket flow runner.
|
|
11
|
+
- `.codex-autorunner/workspace/` — shared context docs:
|
|
12
|
+
- `active_context.md` — current “north star” context; kept fresh for ongoing work.
|
|
13
|
+
- `spec.md` — longer spec / acceptance criteria when needed.
|
|
14
|
+
- `decisions.md` — prior decisions / tradeoffs when relevant.
|
|
15
|
+
|
|
16
|
+
Intent signals: if the user mentions tickets, “dispatch”, “resume”, workspace docs, or `.codex-autorunner/`, they are likely referring to CAR artifacts/workflow rather than generic repo files.
|
|
17
|
+
|
|
18
|
+
Use the above as orientation. If you need the operational details (exact helper commands, what CAR auto-generates), read `.codex-autorunner/ABOUT_CAR.md`.
|
|
19
|
+
</injected context>"""
|
|
20
|
+
|
|
21
|
+
ROLE_ADDENDUM_START = "<role addendum>"
|
|
22
|
+
ROLE_ADDENDUM_END = "</role addendum>"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_file_role_addendum(
|
|
26
|
+
kind: Literal["ticket", "workspace", "other"],
|
|
27
|
+
rel_path: str,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Format a short role-specific addendum for prompts."""
|
|
30
|
+
if kind == "ticket":
|
|
31
|
+
text = f"This target is a CAR ticket at `{rel_path}`."
|
|
32
|
+
elif kind == "workspace":
|
|
33
|
+
text = f"This target is a CAR workspace doc at `{rel_path}`."
|
|
34
|
+
elif kind == "other":
|
|
35
|
+
text = f"This target file is `{rel_path}`."
|
|
36
|
+
else:
|
|
37
|
+
raise ValueError(f"Unsupported role addendum kind: {kind}")
|
|
38
|
+
return f"{ROLE_ADDENDUM_START}\n{text}\n{ROLE_ADDENDUM_END}"
|