ai-push-hooks 0.3.0 → 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 (40) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +80 -993
  3. package/SECURITY.md +21 -16
  4. package/ai-push-hooks.toml +1 -1
  5. package/bin/ai-push-hooks.js +6 -6
  6. package/docs/configuration.md +263 -0
  7. package/package.json +4 -3
  8. package/pyproject.toml +12 -3
  9. package/src/ai_push_hooks/artifacts.py +19 -19
  10. package/src/ai_push_hooks/cli.py +24 -8
  11. package/src/ai_push_hooks/config.py +239 -54
  12. package/src/ai_push_hooks/engine.py +94 -43
  13. package/src/ai_push_hooks/executors/apply.py +124 -46
  14. package/src/ai_push_hooks/executors/ask.py +30 -536
  15. package/src/ai_push_hooks/executors/exec.py +73 -818
  16. package/src/ai_push_hooks/executors/runner_workflow.py +42 -20
  17. package/src/ai_push_hooks/executors/runners/claude.py +18 -6
  18. package/src/ai_push_hooks/executors/runners/codex.py +9 -3
  19. package/src/ai_push_hooks/executors/runners/command.py +25 -9
  20. package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
  21. package/src/ai_push_hooks/executors/runners/opencode.py +109 -27
  22. package/src/ai_push_hooks/executors/runners/opencode_support.py +286 -0
  23. package/src/ai_push_hooks/executors/runners/process.py +97 -17
  24. package/src/ai_push_hooks/executors/runners/registry.py +31 -9
  25. package/src/ai_push_hooks/executors/step_commands.py +65 -20
  26. package/src/ai_push_hooks/git_utils.py +899 -0
  27. package/src/ai_push_hooks/hook.py +49 -13
  28. package/src/ai_push_hooks/install.py +40 -18
  29. package/src/ai_push_hooks/modules/beads.py +19 -8
  30. package/src/ai_push_hooks/modules/docs.py +146 -96
  31. package/src/ai_push_hooks/modules/pr.py +19 -8
  32. package/src/ai_push_hooks/paths.py +6 -2
  33. package/src/ai_push_hooks/plugin_loader.py +182 -105
  34. package/src/ai_push_hooks/plugins.py +3 -1
  35. package/src/ai_push_hooks/prompts_builtin.py +1 -1
  36. package/src/ai_push_hooks/types.py +48 -27
  37. package/vendor/README.md +15 -0
  38. package/vendor/requirements.txt +1 -0
  39. package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
  40. package/run.sh +0 -29
@@ -3,174 +3,10 @@ from __future__ import annotations
3
3
  import json
4
4
  import os
5
5
  import pathlib
6
- import re
7
- import shutil
8
6
  import tempfile
9
- from dataclasses import dataclass
10
7
  from typing import Any
11
8
 
12
- from ..paths import (
13
- ensure_private_directory,
14
- is_path_within,
15
- path_has_symlink,
16
- resolve_contained_path,
17
- write_text_no_follow,
18
- )
19
9
  from ..types import HookError, RuntimeContext, StepConfig
20
- from .exec import ensure_dir, resolve_storage_path, run_command
21
-
22
- OPENCODE_READ_ONLY_AGENT = "ai-push-hooks-readonly"
23
- OPENCODE_APPLY_AGENT = "ai-push-hooks-apply"
24
- OPENCODE_AGENT_POLICIES = frozenset({"read-only", "apply"})
25
- # A sentinel keeps the legacy helper's implicit flat-config behavior while
26
- # allowing the runner boundary to explicitly pass ``None`` and omit optional
27
- # OpenCode flags.
28
- _DEFAULT_MODEL = object()
29
- _DEFAULT_VARIANT = object()
30
- PROVIDER_ENV_PREFIXES = (
31
- "ANTHROPIC_",
32
- "AWS_",
33
- "AZURE_",
34
- "COHERE_",
35
- "DEEPSEEK_",
36
- "GEMINI_",
37
- "GOOGLE_",
38
- "GROQ_",
39
- "MISTRAL_",
40
- "OPENAI_",
41
- "OPENROUTER_",
42
- "VERTEX_",
43
- "XAI_",
44
- )
45
- SAFE_PROCESS_ENV_NAMES = frozenset(
46
- {
47
- "PATH",
48
- "TMPDIR",
49
- "TMP",
50
- "TEMP",
51
- "LANG",
52
- "LC_ALL",
53
- "LC_CTYPE",
54
- "SYSTEMROOT",
55
- }
56
- )
57
-
58
-
59
- def _actual_xdg_data_home() -> pathlib.Path:
60
- configured = os.environ.get("XDG_DATA_HOME", "").strip()
61
- if configured:
62
- return pathlib.Path(configured).expanduser().resolve(strict=False)
63
- return (pathlib.Path.home() / ".local" / "share").resolve(strict=False)
64
-
65
-
66
- def opencode_isolation_env(
67
- context: RuntimeContext,
68
- security_config: dict[str, Any],
69
- stage_name: str,
70
- ) -> dict[str, str | None]:
71
- lexical_isolation_root = (
72
- context.run_dir / "opencode-isolation" / sanitize_filename_component(stage_name)
73
- )
74
- if path_has_symlink(context.run_dir, lexical_isolation_root):
75
- raise HookError(f"OpenCode isolation directory must not traverse a symlink: {stage_name}")
76
- isolation_root = resolve_contained_path(
77
- context.run_dir,
78
- f"opencode-isolation/{sanitize_filename_component(stage_name)}",
79
- "OpenCode isolation directory",
80
- )
81
- home = isolation_root / "home"
82
- config_home = isolation_root / "config"
83
- cache_home = isolation_root / "cache"
84
- state_home = isolation_root / "state"
85
- ensure_private_directory(isolation_root)
86
- for path in (home, config_home, cache_home, state_home):
87
- ensure_private_directory(path, private_root=isolation_root)
88
-
89
- isolated: dict[str, str | None] = {
90
- name: value
91
- for name, value in os.environ.items()
92
- if name in SAFE_PROCESS_ENV_NAMES or name.startswith(PROVIDER_ENV_PREFIXES)
93
- }
94
- isolated.update({
95
- "HOME": str(home),
96
- "XDG_CONFIG_HOME": str(config_home),
97
- "XDG_CACHE_HOME": str(cache_home),
98
- "XDG_STATE_HOME": str(state_home),
99
- "XDG_DATA_HOME": str(_actual_xdg_data_home()),
100
- "OPENCODE_CONFIG_CONTENT": json.dumps(security_config, ensure_ascii=True),
101
- "OPENCODE_CONFIG_DIR": str(config_home),
102
- "OPENCODE_PURE": "true",
103
- "OPENCODE_DISABLE_PROJECT_CONFIG": "true",
104
- "OPENCODE_DISABLE_EXTERNAL_SKILLS": "true",
105
- "OPENCODE_DISABLE_CLAUDE_CODE": "true",
106
- "OPENCODE_DISABLE_CLAUDE_CODE_PROMPT": "true",
107
- "OPENCODE_DISABLE_CLAUDE_CODE_SKILLS": "true",
108
- "OPENCODE_DISABLE_LSP_DOWNLOAD": "true",
109
- "OPENCODE_DISABLE_SHARE": "true",
110
- "OPENCODE_DISABLE_AUTOUPDATE": "true",
111
- })
112
- return isolated
113
-
114
-
115
- def non_agent_opencode_config() -> dict[str, Any]:
116
- return {
117
- "$schema": "https://opencode.ai/config.json",
118
- "plugin": [],
119
- "mcp": {},
120
- "share": "disabled",
121
- "instructions": [],
122
- "formatter": False,
123
- "lsp": False,
124
- "command": {},
125
- "permission": {"*": "deny"},
126
- }
127
-
128
-
129
- @dataclass
130
- class OpenCodeRunResult:
131
- output_text: str
132
- session_id: str | None
133
- stdout: str
134
- stderr: str
135
- return_code: int
136
-
137
-
138
- def sanitize_filename_component(value: str) -> str:
139
- cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip())
140
- return cleaned.strip("-") or "value"
141
-
142
-
143
- def resolve_opencode_executable() -> str:
144
- opencode_path = shutil.which("opencode")
145
- if opencode_path:
146
- return opencode_path
147
- cli_path = shutil.which("opencode-cli")
148
- if cli_path:
149
- return cli_path
150
- raise HookError("opencode is required but not installed")
151
-
152
-
153
- def parse_opencode_json_run_output(raw: str) -> tuple[str | None, str]:
154
- session_id: str | None = None
155
- parts: list[str] = []
156
- for line in raw.splitlines():
157
- payload = line.strip()
158
- if not payload:
159
- continue
160
- try:
161
- event = json.loads(payload)
162
- except json.JSONDecodeError:
163
- continue
164
- if not isinstance(event, dict):
165
- continue
166
- if session_id is None and isinstance(event.get("sessionID"), str):
167
- session_id = str(event["sessionID"]).strip()
168
- if event.get("type") != "text":
169
- continue
170
- part = event.get("part")
171
- if isinstance(part, dict) and isinstance(part.get("text"), str) and part["text"].strip():
172
- parts.append(part["text"])
173
- return session_id, "\n".join(parts).strip()
174
10
 
175
11
 
176
12
  def extract_json_array(text: str) -> list[Any]:
@@ -195,7 +31,9 @@ def extract_json_object(text: str) -> dict[str, Any]:
195
31
  try:
196
32
  payload = json.loads(text[start : end + 1])
197
33
  except json.JSONDecodeError as exc:
198
- 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
199
37
  if not isinstance(payload, dict):
200
38
  raise HookError("Model output JSON is not an object")
201
39
  return payload
@@ -205,7 +43,9 @@ def validate_schema(schema: str | None, payload: Any) -> Any:
205
43
  if schema is None:
206
44
  return payload
207
45
  if schema == "string_array":
208
- 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
+ ):
209
49
  raise HookError("Expected schema string_array")
210
50
  return payload
211
51
  if schema == "docs_issue_array":
@@ -214,7 +54,10 @@ def validate_schema(schema: str | None, payload: Any) -> Any:
214
54
  for item in payload:
215
55
  if not isinstance(item, dict):
216
56
  raise HookError("docs_issue_array items must be objects")
217
- 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
+ ):
218
61
  raise HookError("docs_issue_array items require file and description")
219
62
  return payload
220
63
  if schema == "beads_alignment_result":
@@ -222,376 +65,25 @@ def validate_schema(schema: str | None, payload: Any) -> Any:
222
65
  raise HookError("Expected schema beads_alignment_result")
223
66
  commands = payload.get("commands", [])
224
67
  if commands is not None and (
225
- 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)
226
70
  ):
227
- 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
+ )
228
74
  return payload
229
75
  if schema == "pr_create_payload":
230
76
  if not isinstance(payload, dict):
231
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")
232
83
  return payload
233
84
  raise HookError(f"Unsupported schema: {schema}")
234
85
 
235
86
 
236
- def _transcript_dir(context: RuntimeContext) -> pathlib.Path | None:
237
- if not context.config.logging.capture_llm_transcript:
238
- return None
239
- return ensure_dir(
240
- resolve_storage_path(
241
- context.repo_root,
242
- context.git_dir,
243
- context.config.logging.transcript_dir,
244
- )
245
- )
246
-
247
-
248
- def export_opencode_session_json(
249
- context: RuntimeContext,
250
- session_id: str,
251
- export_path: pathlib.Path,
252
- ) -> bool:
253
- with tempfile.TemporaryDirectory(
254
- prefix="ai-push-hooks-session-export-"
255
- ) as temporary_directory:
256
- completed = run_command(
257
- [
258
- context.opencode_executable or resolve_opencode_executable(),
259
- "export",
260
- session_id,
261
- "--pure",
262
- ],
263
- cwd=pathlib.Path(temporary_directory).resolve(strict=True),
264
- timeout=context.config.llm.timeout_seconds,
265
- check=False,
266
- env=opencode_isolation_env(context, non_agent_opencode_config(), "session-export"),
267
- inherit_env=False,
268
- )
269
- if completed.returncode != 0:
270
- return False
271
- payload = (completed.stdout or "").strip()
272
- if not payload:
273
- return False
274
- write_text_no_follow(export_path, payload + "\n")
275
- return True
276
-
277
-
278
- def delete_opencode_session(context: RuntimeContext, session_id: str) -> bool:
279
- with tempfile.TemporaryDirectory(
280
- prefix="ai-push-hooks-session-delete-"
281
- ) as temporary_directory:
282
- completed = run_command(
283
- [
284
- context.opencode_executable or resolve_opencode_executable(),
285
- "session",
286
- "delete",
287
- session_id,
288
- "--pure",
289
- ],
290
- cwd=pathlib.Path(temporary_directory).resolve(strict=True),
291
- timeout=context.config.llm.timeout_seconds,
292
- check=False,
293
- env=opencode_isolation_env(context, non_agent_opencode_config(), "session-delete"),
294
- inherit_env=False,
295
- )
296
- return completed.returncode == 0
297
-
298
-
299
- def finalize_opencode_session(context: RuntimeContext, stage_name: str, session_id: str | None) -> None:
300
- """Capture and finalize a session without retaining it on export failure.
301
-
302
- Transcript capture is best effort. A failed or interrupted export emits a
303
- visible warning, then the configured deletion policy still runs so a
304
- failed capture does not silently retain provider data.
305
- """
306
- if not session_id:
307
- return
308
- transcript_dir = _transcript_dir(context)
309
- if transcript_dir is not None:
310
- export_name = (
311
- f"{sanitize_filename_component(context.run_id)}-"
312
- f"{sanitize_filename_component(stage_name)}-"
313
- f"{sanitize_filename_component(session_id)}.json"
314
- )
315
- export_path = resolve_contained_path(
316
- transcript_dir,
317
- export_name,
318
- "OpenCode transcript path",
319
- )
320
- export_failure: str | None = None
321
- try:
322
- exported = export_opencode_session_json(context, session_id, export_path)
323
- except Exception as exc: # noqa: BLE001
324
- exported = False
325
- export_failure = type(exc).__name__
326
- if not exported:
327
- context.logger.warn(
328
- "llm.transcript_export_failed",
329
- "Could not capture the OpenCode transcript; applying configured session deletion.",
330
- stage_name=stage_name,
331
- session_id=session_id,
332
- reason=export_failure or "export returned no transcript",
333
- )
334
- if context.config.llm.delete_session_after_run:
335
- delete_opencode_session(context, session_id)
336
-
337
-
338
- def build_opencode_security_config(
339
- agent_policy: str,
340
- allow_paths: tuple[str, ...] = (),
341
- *,
342
- non_vcs_working_directory: pathlib.Path | None = None,
343
- project_read_root: pathlib.Path | None = None,
344
- ) -> tuple[str, dict[str, Any]]:
345
- permissions: dict[str, Any] = {
346
- "*": "deny",
347
- "read": "deny",
348
- "glob": "deny",
349
- "grep": "deny",
350
- "list": "deny",
351
- "edit": "deny",
352
- "bash": "deny",
353
- "task": "deny",
354
- "external_directory": "deny",
355
- "webfetch": "deny",
356
- "websearch": "deny",
357
- "lsp": "deny",
358
- "skill": "deny",
359
- "todowrite": "deny",
360
- "question": "deny",
361
- "doom_loop": "deny",
362
- }
363
- if agent_policy == "read-only":
364
- agent_name = OPENCODE_READ_ONLY_AGENT
365
- description = "Read-only ai-push-hooks analysis agent"
366
- elif agent_policy == "apply":
367
- if not allow_paths:
368
- raise HookError("OpenCode apply agent requires an explicit non-empty allow_paths")
369
- agent_name = OPENCODE_APPLY_AGENT
370
- description = "Path-restricted ai-push-hooks apply agent"
371
- permissions["read"] = "allow"
372
- permission_patterns: set[str] = set()
373
- for pattern in allow_paths:
374
- permission_patterns.add(pattern)
375
- collapsed = pattern
376
- while "**/" in collapsed:
377
- collapsed = collapsed.replace("**/", "", 1)
378
- permission_patterns.add(collapsed)
379
- protected_patterns = {".git", ".git/**"}
380
- if non_vcs_working_directory is not None:
381
- # OpenCode 1.18.29 assigns `/` as the worktree for a directory
382
- # without VCS metadata. Its write and edit tools then request the
383
- # `edit` permission using paths relative to that filesystem root,
384
- # not relative to the process cwd. Keep the staging checkout free
385
- # of Git metadata and qualify only its allowlisted paths.
386
- anchor = pathlib.Path(non_vcs_working_directory.anchor)
387
- prefix = non_vcs_working_directory.relative_to(anchor).as_posix()
388
- permission_patterns = {
389
- f"{prefix}/{pattern}" if prefix else pattern
390
- for pattern in permission_patterns
391
- }
392
- protected_patterns = {
393
- f"{prefix}/{pattern}" if prefix else pattern
394
- for pattern in protected_patterns
395
- }
396
- edit_permissions = {
397
- "*": "deny",
398
- **{pattern: "allow" for pattern in sorted(permission_patterns)},
399
- }
400
- for pattern in sorted(protected_patterns):
401
- edit_permissions[pattern] = "deny"
402
- permissions["edit"] = edit_permissions
403
- else:
404
- raise HookError(f"Unsupported OpenCode agent policy: {agent_policy}")
405
-
406
- if project_read_root is not None:
407
- # OpenCode's file permission matcher uses paths relative to its
408
- # filesystem worktree. A real Git checkout is already rooted at the
409
- # requested worktree, so absolute filesystem-root-relative patterns
410
- # would never match README.md-style tool paths. A non-VCS projection
411
- # is treated as a filesystem worktree rooted at `/`, so qualify those
412
- # patterns with the projection's absolute prefix.
413
- root = project_read_root.resolve(strict=False)
414
- if (root / ".git").exists():
415
- rooted_permissions: str | dict[str, str] = "allow"
416
- else:
417
- anchor = pathlib.Path(root.anchor)
418
- prefix = root.relative_to(anchor).as_posix()
419
- rooted_permissions = {
420
- "*": "deny",
421
- prefix: "allow",
422
- f"{prefix}/**": "allow",
423
- }
424
- for tool in ("read", "list", "glob", "grep"):
425
- permissions[tool] = (
426
- dict(rooted_permissions)
427
- if isinstance(rooted_permissions, dict)
428
- else rooted_permissions
429
- )
430
-
431
- return agent_name, {
432
- "$schema": "https://opencode.ai/config.json",
433
- "plugin": [],
434
- "mcp": {},
435
- "share": "disabled",
436
- "instructions": [],
437
- "formatter": False,
438
- "lsp": False,
439
- "command": {},
440
- "agent": {
441
- agent_name: {
442
- "mode": "primary",
443
- "description": description,
444
- "permission": permissions,
445
- }
446
- },
447
- }
448
-
449
-
450
- def validate_opencode_attachments(
451
- context: RuntimeContext,
452
- files: list[pathlib.Path],
453
- ) -> list[pathlib.Path]:
454
- run_root = context.run_dir.resolve(strict=True)
455
- validated: list[pathlib.Path] = []
456
- for file_path in files:
457
- lexical_path = pathlib.Path(os.path.abspath(file_path))
458
- if not is_path_within(lexical_path, run_root):
459
- raise HookError(f"OpenCode attachment is not a hook-owned artifact: {file_path}")
460
- if path_has_symlink(run_root, lexical_path):
461
- raise HookError(f"OpenCode attachment must not traverse a symlink: {file_path}")
462
- resolved_path = lexical_path.resolve(strict=True)
463
- if not is_path_within(resolved_path, run_root) or not resolved_path.is_file():
464
- raise HookError(f"OpenCode attachment must be a regular hook-owned file: {file_path}")
465
- validated.append(resolved_path)
466
- return validated
467
-
468
-
469
- def call_opencode(
470
- context: RuntimeContext,
471
- stage_name: str,
472
- purpose: str,
473
- prompt: str,
474
- files: list[pathlib.Path],
475
- *,
476
- agent: str,
477
- allow_paths: tuple[str, ...] = (),
478
- working_directory: pathlib.Path | None = None,
479
- attempt: int | None = None,
480
- total_attempts: int | None = None,
481
- existing_session_id: str | None = None,
482
- model: str | None | object = _DEFAULT_MODEL,
483
- variant: str | None | object = _DEFAULT_VARIANT,
484
- project_access: str = "artifacts",
485
- ) -> OpenCodeRunResult:
486
- """Run OpenCode with a policy-specific isolated working directory.
487
-
488
- Apply callers must provide the hook-owned, non-VCS staging directory built
489
- by ``run_apply_step``; this is an internal precondition rather than a
490
- general repository-working-directory interface.
491
- """
492
- if agent not in OPENCODE_AGENT_POLICIES:
493
- raise HookError(f"Unsupported OpenCode agent policy: {agent}")
494
- if agent == "apply" and not allow_paths:
495
- raise HookError("OpenCode apply agent requires an explicit non-empty allow_paths")
496
- if agent == "apply" and working_directory is None:
497
- raise HookError("OpenCode apply agent requires an isolated staging directory")
498
- if agent == "read-only" and allow_paths:
499
- raise HookError("OpenCode read-only agent does not accept write paths")
500
- if project_access not in {"artifacts", "project"}:
501
- raise HookError(f"Unsupported OpenCode project access: {project_access}")
502
- if project_access == "project" and working_directory is None:
503
- raise HookError("OpenCode project access requires an explicit working directory")
504
-
505
- validated_files = validate_opencode_attachments(context, files)
506
- if working_directory is None:
507
- resolved_working_directory = None
508
- else:
509
- try:
510
- resolved_working_directory = working_directory.resolve(strict=True)
511
- except (OSError, RuntimeError) as exc:
512
- raise HookError(
513
- f"OpenCode working directory must be an existing directory: {working_directory}"
514
- ) from exc
515
- if not resolved_working_directory.is_dir():
516
- raise HookError(
517
- f"OpenCode working directory must be an existing directory: {working_directory}"
518
- )
519
- agent_name, security_config = build_opencode_security_config(
520
- agent,
521
- allow_paths,
522
- non_vcs_working_directory=(
523
- resolved_working_directory if agent == "apply" else None
524
- ),
525
- project_read_root=(
526
- resolved_working_directory if project_access == "project" else None
527
- ),
528
- )
529
- executable = context.opencode_executable or resolve_opencode_executable()
530
- effective_model = (
531
- context.config.llm.model if model is _DEFAULT_MODEL else model
532
- )
533
- effective_variant = (
534
- context.config.llm.variant if variant is _DEFAULT_VARIANT else variant
535
- )
536
- context.logger.llm_call(stage_name, purpose, effective_model or "", attempt, total_attempts)
537
- isolated_env = opencode_isolation_env(context, security_config, stage_name)
538
- cmd = [
539
- executable,
540
- "run",
541
- "--agent",
542
- agent_name,
543
- "--pure",
544
- "--format",
545
- "json",
546
- ]
547
- if effective_model:
548
- cmd.extend(["--model", effective_model])
549
- if effective_variant:
550
- cmd.extend(["--variant", effective_variant])
551
- if existing_session_id:
552
- cmd.extend(["--session", existing_session_id])
553
- else:
554
- cmd.extend(["--title", f"{context.config.llm.session_title_prefix} {context.run_id} {stage_name}"])
555
- for file_path in validated_files:
556
- cmd.extend(["--file", str(file_path)])
557
- cmd.extend(["--", prompt])
558
-
559
- if working_directory is None:
560
- with tempfile.TemporaryDirectory(prefix="ai-push-hooks-readonly-") as temporary_directory:
561
- completed = run_command(
562
- cmd,
563
- cwd=pathlib.Path(temporary_directory).resolve(strict=True),
564
- timeout=context.config.llm.timeout_seconds,
565
- check=False,
566
- env=isolated_env,
567
- inherit_env=False,
568
- )
569
- else:
570
- completed = run_command(
571
- cmd,
572
- cwd=resolved_working_directory,
573
- timeout=context.config.llm.timeout_seconds,
574
- check=False,
575
- env=isolated_env,
576
- inherit_env=False,
577
- )
578
- session_id, text_output = parse_opencode_json_run_output(completed.stdout or "")
579
- stdout = completed.stdout or ""
580
- stderr = completed.stderr or ""
581
- if context.config.logging.print_llm_output and text_output:
582
- # Print normalized assistant text only. OpenCode's JSONL stream can
583
- # contain provider diagnostics and tool payloads which must not become
584
- # an accidental credential/prompt log.
585
- print(text_output)
586
- return OpenCodeRunResult(
587
- output_text=text_output if text_output else stdout.strip(),
588
- session_id=session_id or existing_session_id,
589
- stdout=stdout,
590
- stderr=stderr,
591
- return_code=completed.returncode,
592
- )
593
-
594
-
595
87
  def _safe_invalid_output(invocation: Any, output: str) -> str:
596
88
  from .runners.contracts import request_sensitive_diagnostics
597
89
 
@@ -672,7 +164,9 @@ def run_ask_step(
672
164
  f"{_safe_invalid_output(invocation, last_output)}"
673
165
  ) from exc
674
166
 
675
- 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
+ ]
676
170
  suffix = (
677
171
  "Return ONLY valid JSON array."
678
172
  if expects_json_array
@@ -690,7 +184,11 @@ def run_ask_step(
690
184
 
691
185
  session = result.session
692
186
  can_resume = bool(
693
- getattr(getattr(invocation.runner, "capabilities", None), "supports_resume", False)
187
+ getattr(
188
+ getattr(invocation.runner, "capabilities", None),
189
+ "supports_resume",
190
+ False,
191
+ )
694
192
  and session is not None
695
193
  and session.session_id
696
194
  and session.resumable
@@ -704,13 +202,9 @@ def run_ask_step(
704
202
  retry_reason = "runner does not support resume"
705
203
  retry_message = "Retrying with a fresh runner invocation."
706
204
  if retry_reason == "runner does not support resume":
707
- retry_message = (
708
- "Retrying with a fresh runner invocation; unsupported session reuse."
709
- )
205
+ retry_message = "Retrying with a fresh runner invocation; unsupported session reuse."
710
206
  elif retry_reason == "session absent":
711
- retry_message = (
712
- "Retrying with a fresh runner invocation; no reusable session was captured."
713
- )
207
+ retry_message = "Retrying with a fresh runner invocation; no reusable session was captured."
714
208
  context.logger.status(
715
209
  "llm.retry_fresh_session",
716
210
  retry_message,