ai-push-hooks 0.2.1 → 0.3.1

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