ai-push-hooks 0.1.19 → 0.2.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.
- package/CHANGELOG.md +85 -0
- package/README.md +314 -62
- package/SECURITY.md +35 -0
- package/ai-push-hooks.toml +1 -1
- package/bin/ai-push-hooks.js +20 -5
- package/package.json +25 -4
- package/pyproject.toml +11 -4
- package/src/ai_push_hooks/artifacts.py +57 -9
- package/src/ai_push_hooks/cli.py +60 -3
- package/src/ai_push_hooks/config.py +281 -15
- package/src/ai_push_hooks/engine.py +0 -2
- package/src/ai_push_hooks/executors/apply.py +845 -43
- package/src/ai_push_hooks/executors/exec.py +742 -114
- package/src/ai_push_hooks/executors/llm.py +368 -42
- package/src/ai_push_hooks/hook.py +129 -22
- package/src/ai_push_hooks/install.py +205 -0
- package/src/ai_push_hooks/modules/beads.py +21 -6
- package/src/ai_push_hooks/modules/docs.py +147 -27
- package/src/ai_push_hooks/modules/pr.py +53 -7
- package/src/ai_push_hooks/paths.py +182 -0
- package/src/ai_push_hooks/prompts_builtin.py +6 -2
- package/src/ai_push_hooks/types.py +82 -3
|
@@ -1,15 +1,125 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
import pathlib
|
|
5
6
|
import re
|
|
6
7
|
import shutil
|
|
8
|
+
import tempfile
|
|
7
9
|
from dataclasses import dataclass
|
|
8
10
|
from typing import Any
|
|
9
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
|
+
)
|
|
10
19
|
from ..types import HookError, RuntimeContext, StepConfig
|
|
11
20
|
from .exec import ensure_dir, extract_pr_url, resolve_storage_path, run_command
|
|
12
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
|
+
|
|
13
123
|
|
|
14
124
|
@dataclass
|
|
15
125
|
class OpenCodeRunResult:
|
|
@@ -135,31 +245,58 @@ def export_opencode_session_json(
|
|
|
135
245
|
session_id: str,
|
|
136
246
|
export_path: pathlib.Path,
|
|
137
247
|
) -> bool:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
)
|
|
144
264
|
if completed.returncode != 0:
|
|
145
265
|
return False
|
|
146
266
|
payload = (completed.stdout or "").strip()
|
|
147
267
|
if not payload:
|
|
148
268
|
return False
|
|
149
|
-
export_path
|
|
269
|
+
write_text_no_follow(export_path, payload + "\n")
|
|
150
270
|
return True
|
|
151
271
|
|
|
152
272
|
|
|
153
273
|
def delete_opencode_session(context: RuntimeContext, session_id: str) -> None:
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
+
)
|
|
160
291
|
|
|
161
292
|
|
|
162
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
|
+
"""
|
|
163
300
|
if not session_id:
|
|
164
301
|
return
|
|
165
302
|
transcript_dir = _transcript_dir(context)
|
|
@@ -169,26 +306,192 @@ def finalize_opencode_session(context: RuntimeContext, stage_name: str, session_
|
|
|
169
306
|
f"{sanitize_filename_component(stage_name)}-"
|
|
170
307
|
f"{sanitize_filename_component(session_id)}.json"
|
|
171
308
|
)
|
|
172
|
-
|
|
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
|
+
)
|
|
173
328
|
if context.config.llm.delete_session_after_run:
|
|
174
329
|
delete_opencode_session(context, session_id)
|
|
175
330
|
|
|
176
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
|
+
|
|
177
436
|
def call_opencode(
|
|
178
437
|
context: RuntimeContext,
|
|
179
438
|
stage_name: str,
|
|
180
439
|
purpose: str,
|
|
181
440
|
prompt: str,
|
|
182
441
|
files: list[pathlib.Path],
|
|
442
|
+
*,
|
|
443
|
+
agent: str,
|
|
444
|
+
allow_paths: tuple[str, ...] = (),
|
|
445
|
+
working_directory: pathlib.Path | None = None,
|
|
183
446
|
attempt: int | None = None,
|
|
184
447
|
total_attempts: int | None = None,
|
|
185
448
|
existing_session_id: str | None = None,
|
|
186
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
|
+
)
|
|
187
486
|
executable = context.opencode_executable or resolve_opencode_executable()
|
|
188
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)
|
|
189
489
|
cmd = [
|
|
190
490
|
executable,
|
|
191
491
|
"run",
|
|
492
|
+
"--agent",
|
|
493
|
+
agent_name,
|
|
494
|
+
"--pure",
|
|
192
495
|
"--format",
|
|
193
496
|
"json",
|
|
194
497
|
"--model",
|
|
@@ -200,17 +503,29 @@ def call_opencode(
|
|
|
200
503
|
cmd.extend(["--session", existing_session_id])
|
|
201
504
|
else:
|
|
202
505
|
cmd.extend(["--title", f"{context.config.llm.session_title_prefix} {context.run_id} {stage_name}"])
|
|
203
|
-
for file_path in
|
|
506
|
+
for file_path in validated_files:
|
|
204
507
|
cmd.extend(["--file", str(file_path)])
|
|
205
508
|
cmd.extend(["--", prompt])
|
|
206
509
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
+
)
|
|
214
529
|
session_id, text_output = parse_opencode_json_run_output(completed.stdout or "")
|
|
215
530
|
stdout = completed.stdout or ""
|
|
216
531
|
stderr = completed.stderr or ""
|
|
@@ -240,16 +555,21 @@ def run_llm_step(
|
|
|
240
555
|
wants_json = bool(step.schema)
|
|
241
556
|
expects_json_array = step.schema in {"string_array", "docs_issue_array"}
|
|
242
557
|
for attempt in range(1, total_attempts + 1):
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
253
573
|
session_id = result.session_id
|
|
254
574
|
if result.return_code != 0:
|
|
255
575
|
finalize_opencode_session(context, stage_name, session_id)
|
|
@@ -257,19 +577,22 @@ def run_llm_step(
|
|
|
257
577
|
raise HookError(f"OpenCode command failed: {details}")
|
|
258
578
|
try:
|
|
259
579
|
if not wants_json:
|
|
260
|
-
|
|
261
|
-
return result.output_text
|
|
262
|
-
if expects_json_array:
|
|
263
|
-
payload = extract_json_array(result.output_text)
|
|
580
|
+
payload = result.output_text
|
|
264
581
|
else:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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)
|
|
268
587
|
except HookError as exc:
|
|
269
588
|
last_error = str(exc)
|
|
270
589
|
last_output = result.output_text
|
|
271
590
|
if attempt >= total_attempts:
|
|
272
|
-
|
|
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
|
|
273
596
|
snippet = last_output[: context.config.llm.invalid_json_feedback_max_chars]
|
|
274
597
|
if expects_json_array:
|
|
275
598
|
suffix = "Return ONLY valid JSON array."
|
|
@@ -285,6 +608,7 @@ def run_llm_step(
|
|
|
285
608
|
+ "\n```"
|
|
286
609
|
)
|
|
287
610
|
if context.config.llm.json_retry_new_session:
|
|
611
|
+
finalize_opencode_session(context, stage_name, session_id)
|
|
288
612
|
session_id = None
|
|
289
613
|
pr_url = extract_pr_url(last_output)
|
|
290
614
|
if pr_url:
|
|
@@ -294,5 +618,7 @@ def run_llm_step(
|
|
|
294
618
|
stage_name=stage_name,
|
|
295
619
|
url=pr_url,
|
|
296
620
|
)
|
|
297
|
-
|
|
298
|
-
|
|
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
|