ai-push-hooks 0.3.1 → 0.3.2

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 (35) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/README.md +91 -80
  3. package/ai-push-hooks.toml +1 -1
  4. package/docs/configuration.md +263 -0
  5. package/package.json +3 -3
  6. package/pyproject.toml +12 -3
  7. package/src/ai_push_hooks/artifacts.py +19 -6
  8. package/src/ai_push_hooks/cli.py +24 -8
  9. package/src/ai_push_hooks/config.py +220 -49
  10. package/src/ai_push_hooks/engine.py +93 -43
  11. package/src/ai_push_hooks/executors/apply.py +121 -43
  12. package/src/ai_push_hooks/executors/ask.py +30 -13
  13. package/src/ai_push_hooks/executors/exec.py +61 -22
  14. package/src/ai_push_hooks/executors/runner_workflow.py +38 -16
  15. package/src/ai_push_hooks/executors/runners/claude.py +18 -6
  16. package/src/ai_push_hooks/executors/runners/codex.py +9 -3
  17. package/src/ai_push_hooks/executors/runners/command.py +25 -9
  18. package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
  19. package/src/ai_push_hooks/executors/runners/opencode.py +74 -21
  20. package/src/ai_push_hooks/executors/runners/opencode_support.py +15 -5
  21. package/src/ai_push_hooks/executors/runners/process.py +26 -7
  22. package/src/ai_push_hooks/executors/runners/registry.py +14 -5
  23. package/src/ai_push_hooks/executors/step_commands.py +58 -18
  24. package/src/ai_push_hooks/git_utils.py +92 -27
  25. package/src/ai_push_hooks/hook.py +48 -12
  26. package/src/ai_push_hooks/install.py +40 -18
  27. package/src/ai_push_hooks/modules/beads.py +18 -7
  28. package/src/ai_push_hooks/modules/docs.py +17 -7
  29. package/src/ai_push_hooks/modules/pr.py +18 -7
  30. package/src/ai_push_hooks/paths.py +6 -2
  31. package/src/ai_push_hooks/plugin_loader.py +79 -26
  32. package/src/ai_push_hooks/plugins.py +3 -1
  33. package/src/ai_push_hooks/prompts_builtin.py +1 -1
  34. package/src/ai_push_hooks/types.py +47 -27
  35. package/run.sh +0 -29
@@ -31,7 +31,9 @@ def extract_json_object(text: str) -> dict[str, Any]:
31
31
  try:
32
32
  payload = json.loads(text[start : end + 1])
33
33
  except json.JSONDecodeError as exc:
34
- raise HookError(f"Failed to parse JSON object from model output: {exc}") from exc
34
+ raise HookError(
35
+ f"Failed to parse JSON object from model output: {exc}"
36
+ ) from exc
35
37
  if not isinstance(payload, dict):
36
38
  raise HookError("Model output JSON is not an object")
37
39
  return payload
@@ -41,7 +43,9 @@ def validate_schema(schema: str | None, payload: Any) -> Any:
41
43
  if schema is None:
42
44
  return payload
43
45
  if schema == "string_array":
44
- if not isinstance(payload, list) or not all(isinstance(item, str) for item in payload):
46
+ if not isinstance(payload, list) or not all(
47
+ isinstance(item, str) for item in payload
48
+ ):
45
49
  raise HookError("Expected schema string_array")
46
50
  return payload
47
51
  if schema == "docs_issue_array":
@@ -50,7 +54,10 @@ def validate_schema(schema: str | None, payload: Any) -> Any:
50
54
  for item in payload:
51
55
  if not isinstance(item, dict):
52
56
  raise HookError("docs_issue_array items must be objects")
53
- if not str(item.get("file", "")).strip() or not str(item.get("description", "")).strip():
57
+ if (
58
+ not str(item.get("file", "")).strip()
59
+ or not str(item.get("description", "")).strip()
60
+ ):
54
61
  raise HookError("docs_issue_array items require file and description")
55
62
  return payload
56
63
  if schema == "beads_alignment_result":
@@ -58,13 +65,21 @@ def validate_schema(schema: str | None, payload: Any) -> Any:
58
65
  raise HookError("Expected schema beads_alignment_result")
59
66
  commands = payload.get("commands", [])
60
67
  if commands is not None and (
61
- not isinstance(commands, list) or not all(isinstance(item, str) for item in commands)
68
+ not isinstance(commands, list)
69
+ or not all(isinstance(item, str) for item in commands)
62
70
  ):
63
- raise HookError("beads_alignment_result.commands must be an array of strings")
71
+ raise HookError(
72
+ "beads_alignment_result.commands must be an array of strings"
73
+ )
64
74
  return payload
65
75
  if schema == "pr_create_payload":
66
76
  if not isinstance(payload, dict):
67
77
  raise HookError("Expected schema pr_create_payload")
78
+ for field in ("title", "body"):
79
+ if field in payload and not isinstance(payload[field], str):
80
+ raise HookError(f"pr_create_payload.{field} must be a string")
81
+ if "draft" in payload and not isinstance(payload["draft"], bool):
82
+ raise HookError("pr_create_payload.draft must be a boolean")
68
83
  return payload
69
84
  raise HookError(f"Unsupported schema: {schema}")
70
85
 
@@ -149,7 +164,9 @@ def run_ask_step(
149
164
  f"{_safe_invalid_output(invocation, last_output)}"
150
165
  ) from exc
151
166
 
152
- snippet = last_output[: context.config.llm.invalid_json_feedback_max_chars]
167
+ snippet = last_output[
168
+ : context.config.llm.invalid_json_feedback_max_chars
169
+ ]
153
170
  suffix = (
154
171
  "Return ONLY valid JSON array."
155
172
  if expects_json_array
@@ -167,7 +184,11 @@ def run_ask_step(
167
184
 
168
185
  session = result.session
169
186
  can_resume = bool(
170
- getattr(getattr(invocation.runner, "capabilities", None), "supports_resume", False)
187
+ getattr(
188
+ getattr(invocation.runner, "capabilities", None),
189
+ "supports_resume",
190
+ False,
191
+ )
171
192
  and session is not None
172
193
  and session.session_id
173
194
  and session.resumable
@@ -181,13 +202,9 @@ def run_ask_step(
181
202
  retry_reason = "runner does not support resume"
182
203
  retry_message = "Retrying with a fresh runner invocation."
183
204
  if retry_reason == "runner does not support resume":
184
- retry_message = (
185
- "Retrying with a fresh runner invocation; unsupported session reuse."
186
- )
205
+ retry_message = "Retrying with a fresh runner invocation; unsupported session reuse."
187
206
  elif retry_reason == "session absent":
188
- retry_message = (
189
- "Retrying with a fresh runner invocation; no reusable session was captured."
190
- )
207
+ retry_message = "Retrying with a fresh runner invocation; no reusable session was captured."
191
208
  context.logger.status(
192
209
  "llm.retry_fresh_session",
193
210
  retry_message,
@@ -20,6 +20,7 @@ from ..paths import (
20
20
  resolve_contained_path,
21
21
  )
22
22
  from ..types import HookError, ModuleRuntimeState, RuntimeContext, StepConfig
23
+ from .ask import validate_schema
23
24
 
24
25
  BEADS_ALIGNMENT_TIMEOUT_SECONDS = 30
25
26
  BEADS_ALIGNMENT_TOTAL_TIMEOUT_SECONDS = 120
@@ -72,10 +73,14 @@ BEADS_MIGRATION_OVERRIDE_ENV_NAMES = frozenset(
72
73
  )
73
74
 
74
75
 
75
- def _report_file_path(context: RuntimeContext, state: ModuleRuntimeState) -> pathlib.Path:
76
+ def _report_file_path(
77
+ context: RuntimeContext, state: ModuleRuntimeState
78
+ ) -> pathlib.Path:
76
79
  branch_context = state.artifacts.get("collect/branch-context.txt")
77
80
  if branch_context and branch_context.exists():
78
- payload = git_utils.parse_key_value_text(branch_context.read_text(encoding="utf-8"))
81
+ payload = git_utils.parse_key_value_text(
82
+ branch_context.read_text(encoding="utf-8")
83
+ )
79
84
  report_file = payload.get("report_file", "BEADS_STATUS_ACTION_REQUIRED.md")
80
85
  else:
81
86
  report_file = "BEADS_STATUS_ACTION_REQUIRED.md"
@@ -101,13 +106,19 @@ def _validate_beads_issue_ids(values: list[str]) -> None:
101
106
  raise HookError("Beads alignment commands require between 1 and 20 issue ids")
102
107
  for issue_id in values:
103
108
  if not BEADS_ISSUE_ID_PATTERN.fullmatch(issue_id):
104
- raise HookError(f"Invalid Beads issue id in alignment command: {issue_id!r}")
109
+ raise HookError(
110
+ f"Invalid Beads issue id in alignment command: {issue_id!r}"
111
+ )
105
112
 
106
113
 
107
114
  def validate_beads_alignment_command(command: str) -> list[str]:
108
115
  if not isinstance(command, str) or not command.strip():
109
116
  raise HookError("Beads alignment commands must be non-empty strings")
110
- if len(command) > 4096 or "\x00" in command or any(ord(char) < 32 for char in command):
117
+ if (
118
+ len(command) > 4096
119
+ or "\x00" in command
120
+ or any(ord(char) < 32 for char in command)
121
+ ):
111
122
  raise HookError("Beads alignment command contains invalid or excessive input")
112
123
  try:
113
124
  argv = shlex.split(command, posix=True)
@@ -119,7 +130,11 @@ def validate_beads_alignment_command(command: str) -> list[str]:
119
130
 
120
131
  subcommand = argv[1]
121
132
  if subcommand == "update":
122
- if len(argv) < 5 or argv[-2] != "--status" or argv[-1] not in BEADS_UPDATE_STATUSES:
133
+ if (
134
+ len(argv) < 5
135
+ or argv[-2] != "--status"
136
+ or argv[-1] not in BEADS_UPDATE_STATUSES
137
+ ):
123
138
  raise HookError(
124
139
  "Allowed Beads update form is: bd update <issue-id> [<issue-id> ...] "
125
140
  "--status <open|in_progress|blocked>"
@@ -154,14 +169,18 @@ def resolve_beads_executable(repo_root: pathlib.Path) -> str:
154
169
  lexical_candidate = pathlib.Path(os.path.abspath(candidate))
155
170
  resolved_repo_root = repo_root.resolve(strict=True)
156
171
  if is_path_within(lexical_candidate, resolved_repo_root):
157
- raise HookError(f"Refusing repository-contained `bd` executable: {lexical_candidate}")
172
+ raise HookError(
173
+ f"Refusing repository-contained `bd` executable: {lexical_candidate}"
174
+ )
158
175
  try:
159
176
  executable = lexical_candidate.resolve(strict=True)
160
177
  except (OSError, RuntimeError) as exc:
161
178
  raise HookError("Unable to safely resolve the `bd` executable") from exc
162
179
  if is_path_within(executable, resolved_repo_root):
163
180
  raise HookError(f"Refusing repository-contained `bd` executable: {executable}")
164
- if path_is_link_or_reparse(executable) or not stat.S_ISREG(executable.stat().st_mode):
181
+ if path_is_link_or_reparse(executable) or not stat.S_ISREG(
182
+ executable.stat().st_mode
183
+ ):
165
184
  raise HookError(f"Resolved `bd` executable is not a regular file: {executable}")
166
185
  if not os.access(executable, os.X_OK):
167
186
  raise HookError(f"Resolved `bd` executable is not executable: {executable}")
@@ -184,7 +203,12 @@ def beads_alignment_executor(
184
203
  inputs: list[pathlib.Path],
185
204
  ) -> dict[str, Any]:
186
205
  if state.metadata.get("skip_module"):
187
- return {"skipped": True, "commands_run": [], "report_written": False, "unresolved": False}
206
+ return {
207
+ "skipped": True,
208
+ "commands_run": [],
209
+ "report_written": False,
210
+ "unresolved": False,
211
+ }
188
212
  payload = json.loads(inputs[0].read_text(encoding="utf-8"))
189
213
  if not isinstance(payload, dict):
190
214
  raise HookError("beads_alignment payload must be an object")
@@ -195,14 +219,18 @@ def beads_alignment_executor(
195
219
  raise HookError(
196
220
  f"beads_alignment accepts at most {BEADS_ALIGNMENT_MAX_COMMANDS} commands"
197
221
  )
198
- validated_commands = [validate_beads_alignment_command(command) for command in commands]
222
+ validated_commands = [
223
+ validate_beads_alignment_command(command) for command in commands
224
+ ]
199
225
  beads_executable = resolve_beads_executable(context.repo_root) if commands else ""
200
226
  command_env = beads_alignment_env()
201
227
  report_path = _report_file_path(context, state)
202
228
  commands_run: list[str] = []
203
229
  started_at = time.monotonic()
204
230
  for command, argv in zip(commands, validated_commands):
205
- remaining = BEADS_ALIGNMENT_TOTAL_TIMEOUT_SECONDS - (time.monotonic() - started_at)
231
+ remaining = BEADS_ALIGNMENT_TOTAL_TIMEOUT_SECONDS - (
232
+ time.monotonic() - started_at
233
+ )
206
234
  if remaining <= 0:
207
235
  raise HookError(
208
236
  f"Beads alignment exceeded its {BEADS_ALIGNMENT_TOTAL_TIMEOUT_SECONDS}-second total budget"
@@ -223,7 +251,9 @@ def beads_alignment_executor(
223
251
  if report_markdown:
224
252
  if not report_markdown.endswith("\n"):
225
253
  report_markdown += "\n"
226
- if not git_utils.write_text_file(report_path, report_markdown, root=context.repo_root):
254
+ if not git_utils.write_text_file(
255
+ report_path, report_markdown, root=context.repo_root
256
+ ):
227
257
  raise HookError(f"Failed to write Beads alignment report: {report_path}")
228
258
  report_written = True
229
259
  elif report_path.exists() and not unresolved:
@@ -253,7 +283,9 @@ def gh_pr_create_executor(
253
283
  branch_name = str(context.cache.get("branch_name", "")).strip()
254
284
  if not branch_name:
255
285
  reason = str(
256
- context.cache.get("branch_selection_reason", "no single pushed branch is available")
286
+ context.cache.get(
287
+ "branch_selection_reason", "no single pushed branch is available"
288
+ )
257
289
  )
258
290
  raise HookError(f"PR creation requires one pushed branch: {reason}")
259
291
  default_base_branch = context.config.general.base_branch.strip() or "main"
@@ -266,14 +298,14 @@ def gh_pr_create_executor(
266
298
  "deferred_until_remote": True,
267
299
  "reason": reason,
268
300
  }
301
+ payload = validate_schema(
302
+ "pr_create_payload", json.loads(inputs[0].read_text(encoding="utf-8"))
303
+ )
269
304
  if shutil.which("gh") is None:
270
305
  raise HookError("`gh` is required for PR creation but is not installed")
271
306
  repository = git_utils.resolve_github_repository(
272
307
  context.repo_root, context.remote_name, context.remote_url
273
308
  )
274
- payload = json.loads(inputs[0].read_text(encoding="utf-8"))
275
- if not isinstance(payload, dict):
276
- raise HookError("PR creation payload must be an object")
277
309
  existing_pr = git_utils.lookup_open_pr_url(
278
310
  context.repo_root, branch_name, default_base_branch, repository
279
311
  )
@@ -282,8 +314,8 @@ def gh_pr_create_executor(
282
314
 
283
315
  base_branch = default_base_branch
284
316
  head_branch = branch_name
285
- title = git_utils.sanitize_pr_title(str(payload.get("title", "")).strip(), branch_name)
286
- body = str(payload.get("body", "")).strip()
317
+ title = git_utils.sanitize_pr_title(payload.get("title", "").strip(), branch_name)
318
+ body = payload.get("body", "").strip()
287
319
  if not body:
288
320
  commits = git_utils.collect_commit_messages_for_ranges(
289
321
  context.repo_root,
@@ -312,17 +344,24 @@ def gh_pr_create_executor(
312
344
  "--body",
313
345
  body,
314
346
  ]
315
- if bool(payload.get("draft", False)):
347
+ if payload.get("draft", False):
316
348
  args.append("--draft")
317
349
  created = git_utils.run_command(args, cwd=context.repo_root, check=False)
318
- combined_output = "\n".join([(created.stdout or "").strip(), (created.stderr or "").strip()])
319
- pr_url = git_utils.extract_pr_url(combined_output)
320
- if created.returncode != 0 and not pr_url:
350
+ combined_output = "\n".join(
351
+ [(created.stdout or "").strip(), (created.stderr or "").strip()]
352
+ )
353
+ if created.returncode != 0:
354
+ # A URL in failed-command output is not proof that the create operation
355
+ # succeeded. Reconcile against GitHub before accepting the result.
321
356
  pr_url = git_utils.lookup_open_pr_url(
322
357
  context.repo_root, branch_name, default_base_branch, repository
323
358
  )
359
+ else:
360
+ pr_url = git_utils.extract_pr_url(combined_output)
324
361
  if not pr_url:
325
- details = git_utils._command_diagnostics(args, created.stdout or "", created.stderr or "")
362
+ details = git_utils._command_diagnostics(
363
+ args, created.stdout or "", created.stderr or ""
364
+ )
326
365
  raise HookError(
327
366
  details or f"gh pr create failed with exit code {created.returncode}"
328
367
  )
@@ -76,29 +76,36 @@ def _safe_input_artifacts(
76
76
  # parents immediately before opening each file; this is a same-user
77
77
  # race defense, not an OS sandbox. O_NOFOLLOW and descriptor checks
78
78
  # below protect the final open even if the leaf changes concurrently.
79
- if (
80
- not is_path_within(lexical_path, run_root)
81
- or path_has_symlink(run_root, lexical_path)
79
+ if not is_path_within(lexical_path, run_root) or path_has_symlink(
80
+ run_root, lexical_path
82
81
  ):
83
82
  raise HookError(f"Runner artifact must not traverse a symlink: {name}")
84
83
  try:
85
84
  resolved_path = lexical_path.resolve(strict=True)
86
85
  except (OSError, RuntimeError) as exc:
87
- raise HookError(f"Unable to safely resolve runner artifact: {name}") from exc
86
+ raise HookError(
87
+ f"Unable to safely resolve runner artifact: {name}"
88
+ ) from exc
88
89
  if (
89
90
  resolved_path != lexical_path
90
91
  or not is_path_within(resolved_path, run_root)
91
92
  or not resolved_path.is_file()
92
93
  ):
93
- raise HookError(f"Runner artifact must be a regular hook-owned file: {name}")
94
+ raise HookError(
95
+ f"Runner artifact must be a regular hook-owned file: {name}"
96
+ )
94
97
 
95
98
  descriptor: int | None = None
96
99
  try:
97
- flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
100
+ flags = (
101
+ os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
102
+ )
98
103
  descriptor = os.open(lexical_path, flags)
99
104
  metadata = os.fstat(descriptor)
100
105
  if not os.path.isfile(lexical_path) or not stat.S_ISREG(metadata.st_mode):
101
- raise HookError(f"Runner artifact must be a regular hook-owned file: {name}")
106
+ raise HookError(
107
+ f"Runner artifact must be a regular hook-owned file: {name}"
108
+ )
102
109
  remaining = RUNNER_INPUT_MAX_BYTES - total_bytes
103
110
  if metadata.st_size > remaining:
104
111
  raise HookError(
@@ -121,7 +128,9 @@ def _safe_input_artifacts(
121
128
  total_bytes += len(content_bytes)
122
129
  content = bytes(content_bytes).decode("utf-8")
123
130
  except (OSError, UnicodeError) as exc:
124
- raise HookError(f"Unable to read hook-owned runner artifact: {name}") from exc
131
+ raise HookError(
132
+ f"Unable to read hook-owned runner artifact: {name}"
133
+ ) from exc
125
134
  finally:
126
135
  if descriptor is not None:
127
136
  os.close(descriptor)
@@ -143,7 +152,9 @@ def _build_request(
143
152
  profile = resolve_runner_profile(context.config, step)
144
153
  cwd = pathlib.Path(working_directory).resolve(strict=True)
145
154
  if not cwd.is_dir():
146
- raise HookError(f"Runner working directory is not a directory: {working_directory}")
155
+ raise HookError(
156
+ f"Runner working directory is not a directory: {working_directory}"
157
+ )
147
158
  request = RunnerRequest(
148
159
  profile_id=profile.name,
149
160
  runner_type=profile.type,
@@ -206,7 +217,9 @@ def _failure_result(
206
217
  if not isinstance(session_id, str) or not session_id.strip():
207
218
  session_id = fallback_session_id
208
219
  session = _session_metadata(runner, session_id.strip()) if session_id else None
209
- return RunnerResult(final_text="", returncode=1, stdout="", stderr="", session=session)
220
+ return RunnerResult(
221
+ final_text="", returncode=1, stdout="", stderr="", session=session
222
+ )
210
223
 
211
224
 
212
225
  def _preserve_failure_session(
@@ -214,7 +227,9 @@ def _preserve_failure_session(
214
227
  result: RunnerResult,
215
228
  fallback_session_id: str | None,
216
229
  ) -> RunnerResult:
217
- if (result.session is not None and result.session.session_id) or not fallback_session_id:
230
+ if (
231
+ result.session is not None and result.session.session_id
232
+ ) or not fallback_session_id:
218
233
  return result
219
234
  return RunnerResult(
220
235
  final_text=result.final_text,
@@ -250,7 +265,9 @@ def _completion(
250
265
  )
251
266
 
252
267
 
253
- def _print_normalized_output(context: RuntimeContext, invocation: _RunnerInvocation) -> None:
268
+ def _print_normalized_output(
269
+ context: RuntimeContext, invocation: _RunnerInvocation
270
+ ) -> None:
254
271
  if not context.config.logging.print_llm_output or not invocation.result.final_text:
255
272
  return
256
273
  # This is an explicit opt-in. Only normalized final text is printed and
@@ -270,7 +287,14 @@ def _request_sensitive_values(request: RunnerRequest) -> tuple[str, ...]:
270
287
  if value
271
288
  and any(
272
289
  marker in name.upper()
273
- for marker in ("API_KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH", "CREDENTIAL")
290
+ for marker in (
291
+ "API_KEY",
292
+ "TOKEN",
293
+ "SECRET",
294
+ "PASSWORD",
295
+ "AUTH",
296
+ "CREDENTIAL",
297
+ )
274
298
  )
275
299
  )
276
300
  return (
@@ -316,9 +340,7 @@ def _named_error(
316
340
  "\n".join(part for part in (reason, streams) if part),
317
341
  max_chars=1_200,
318
342
  )
319
- message = (
320
- f"Runner profile `{profile_name}` ({runner_type}) failed at stage `{stage_name}`"
321
- )
343
+ message = f"Runner profile `{profile_name}` ({runner_type}) failed at stage `{stage_name}`"
322
344
  if details:
323
345
  message += f": {details}"
324
346
  return HookError(message)
@@ -65,7 +65,9 @@ def resolve_claude_executable() -> str:
65
65
  )
66
66
 
67
67
 
68
- def _capability_error(reason: str, *, details: str = "") -> RunnerAdapterUnavailableError:
68
+ def _capability_error(
69
+ reason: str, *, details: str = ""
70
+ ) -> RunnerAdapterUnavailableError:
69
71
  return RunnerAdapterUnavailableError(
70
72
  "Claude Code CLI does not satisfy the required non-interactive contract",
71
73
  details=f"{reason}{(': ' + details) if details else ''}",
@@ -102,9 +104,13 @@ def check_claude_capabilities(
102
104
  raise _capability_error("help output was truncated")
103
105
 
104
106
  help_text = f"{help_result.stdout}\n{help_result.stderr}"
105
- missing = tuple(marker for marker in REQUIRED_HELP_MARKERS if marker not in help_text)
107
+ missing = tuple(
108
+ marker for marker in REQUIRED_HELP_MARKERS if marker not in help_text
109
+ )
106
110
  if missing:
107
- raise _capability_error("missing required flags or modes", details=", ".join(missing))
111
+ raise _capability_error(
112
+ "missing required flags or modes", details=", ".join(missing)
113
+ )
108
114
 
109
115
 
110
116
  def _protocol_failure(
@@ -158,13 +164,19 @@ def parse_claude_result(
158
164
  try:
159
165
  payload: Any = json.loads(raw)
160
166
  except json.JSONDecodeError as exc:
161
- raise _protocol_failure(request, process_result, "stdout was not valid JSON") from exc
167
+ raise _protocol_failure(
168
+ request, process_result, "stdout was not valid JSON"
169
+ ) from exc
162
170
  if not isinstance(payload, dict):
163
- raise _protocol_failure(request, process_result, "top-level JSON value was not an object")
171
+ raise _protocol_failure(
172
+ request, process_result, "top-level JSON value was not an object"
173
+ )
164
174
 
165
175
  message_type = payload.get("type")
166
176
  if message_type != "result":
167
- raise _protocol_failure(request, process_result, "top-level JSON type was not result")
177
+ raise _protocol_failure(
178
+ request, process_result, "top-level JSON type was not result"
179
+ )
168
180
 
169
181
  is_error = payload.get("is_error")
170
182
  if not isinstance(is_error, bool):
@@ -111,14 +111,18 @@ def _parse_codex_jsonl(request: RunnerRequest, process: ProcessResult) -> _Codex
111
111
  thread_id = value
112
112
  elif event_type == "turn.started":
113
113
  if turn_open:
114
- raise _protocol_error(request, process, "Codex JSONL turn order was invalid")
114
+ raise _protocol_error(
115
+ request, process, "Codex JSONL turn order was invalid"
116
+ )
115
117
  turn_open = True
116
118
  if not terminal_failure:
117
119
  terminal_success = False
118
120
  elif event_type == "item.completed":
119
121
  item = event.get("item")
120
122
  if not isinstance(item, dict):
121
- raise _protocol_error(request, process, "Codex item.completed event had no item")
123
+ raise _protocol_error(
124
+ request, process, "Codex item.completed event had no item"
125
+ )
122
126
  if item.get("type") == "agent_message":
123
127
  text = item.get("text")
124
128
  if not isinstance(text, str):
@@ -213,7 +217,9 @@ class CodexRunner:
213
217
  if process.stdout_truncated or process.stderr_truncated:
214
218
  raise _protocol_error(request, process, "Codex output stream was truncated")
215
219
  if not process.stdout.strip():
216
- raise _protocol_error(request, process, "Codex emitted an empty JSONL stream")
220
+ raise _protocol_error(
221
+ request, process, "Codex emitted an empty JSONL stream"
222
+ )
217
223
 
218
224
  parsed = _parse_codex_jsonl(request, process)
219
225
  if not parsed.terminal_success:
@@ -25,8 +25,10 @@ _PLACEHOLDER_PATTERN = re.compile(r"\{[^{}]*\}")
25
25
 
26
26
 
27
27
  def _is_plain_text(value: object, *, allow_empty: bool = False) -> bool:
28
- return isinstance(value, str) and (allow_empty or bool(value.strip())) and not any(
29
- ord(character) < 32 for character in value
28
+ return (
29
+ isinstance(value, str)
30
+ and (allow_empty or bool(value.strip()))
31
+ and not any(ord(character) < 32 for character in value)
30
32
  )
31
33
 
32
34
 
@@ -69,10 +71,14 @@ def _validate_request(request: object) -> RunnerRequest:
69
71
  or not math.isfinite(request.timeout_seconds)
70
72
  or request.timeout_seconds <= 0
71
73
  ):
72
- raise RunnerContractError("timeout_seconds must be finite and greater than zero")
74
+ raise RunnerContractError(
75
+ "timeout_seconds must be finite and greater than zero"
76
+ )
73
77
 
74
78
  if request.model is not None and not _is_plain_text(request.model):
75
- raise RunnerContractError("model must be a non-empty NUL-free string when provided")
79
+ raise RunnerContractError(
80
+ "model must be a non-empty NUL-free string when provided"
81
+ )
76
82
  if request.variant is not None:
77
83
  raise RunnerContractError("variant is not valid for runner type command")
78
84
  if request.prompt_transport not in {"stdin", "argv"}:
@@ -85,11 +91,17 @@ def _validate_request(request: object) -> RunnerRequest:
85
91
  prompt_count = 0
86
92
  for index, argument in enumerate(command, start=1):
87
93
  if not isinstance(argument, str) or not argument.strip() or "\x00" in argument:
88
- raise RunnerContractError(f"command[{index}] must be a non-empty NUL-free string")
94
+ raise RunnerContractError(
95
+ f"command[{index}] must be a non-empty NUL-free string"
96
+ )
89
97
  for placeholder in _PLACEHOLDER_PATTERN.findall(argument):
90
98
  if placeholder not in _ALLOWED_PLACEHOLDERS:
91
- raise RunnerContractError(f"unknown placeholder {placeholder!r} in command[{index}]")
92
- if ("{" in argument or "}" in argument) and argument not in _ALLOWED_PLACEHOLDERS:
99
+ raise RunnerContractError(
100
+ f"unknown placeholder {placeholder!r} in command[{index}]"
101
+ )
102
+ if (
103
+ "{" in argument or "}" in argument
104
+ ) and argument not in _ALLOWED_PLACEHOLDERS:
93
105
  raise RunnerContractError(
94
106
  f"placeholders in command[{index}] must be whole argv elements"
95
107
  )
@@ -97,7 +109,9 @@ def _validate_request(request: object) -> RunnerRequest:
97
109
  prompt_count += 1
98
110
 
99
111
  if request.prompt_transport == "stdin" and prompt_count:
100
- raise RunnerContractError("command must not contain {prompt} with stdin transport")
112
+ raise RunnerContractError(
113
+ "command must not contain {prompt} with stdin transport"
114
+ )
101
115
  if request.prompt_transport == "argv" and prompt_count != 1:
102
116
  raise RunnerContractError(
103
117
  "command must contain exactly one {prompt} with argv transport"
@@ -142,7 +156,9 @@ class CommandRunner:
142
156
  except RunnerExecutableNotFoundError as exc:
143
157
  # The rendered argv can contain prompt/model values. Do not
144
158
  # repeat the lower-level adapter's executable detail here.
145
- raise RunnerExecutableNotFoundError("runner executable was not found") from exc
159
+ raise RunnerExecutableNotFoundError(
160
+ "runner executable was not found"
161
+ ) from exc
146
162
  result = RunnerResult(
147
163
  final_text=process_result.stdout,
148
164
  returncode=process_result.returncode,