ai-push-hooks 0.2.0 → 0.3.0

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.
@@ -0,0 +1,597 @@
1
+ """Runner-neutral request, result, lifecycle, and diagnostic contracts.
2
+
3
+ This package is intentionally independent from workflow configuration. The
4
+ configuration layer resolves a profile into the flat :class:`RunnerRequest`;
5
+ runner adapters only consume that request and return a :class:`RunnerResult`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ import pathlib
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from typing import Literal, Mapping, Protocol, Sequence
16
+
17
+
18
+ RunnerMode = Literal["ask", "apply"]
19
+ ProjectAccess = Literal["artifacts", "project"]
20
+ PromptTransport = Literal["stdin", "argv"]
21
+ SessionState = Literal["persisted", "ephemeral", "deleted"]
22
+
23
+ MAX_DIAGNOSTIC_CHARS = 4_000
24
+ DIAGNOSTIC_TRUNCATION_MARKER = "[truncated]"
25
+ # Request bodies can be much larger than a useful error excerpt. Do not let
26
+ # redaction turn each word in a diff into another full-stream scan.
27
+ MAX_DIAGNOSTIC_REQUEST_CHARS = MAX_DIAGNOSTIC_CHARS * 2
28
+
29
+
30
+ class RunnerContractError(ValueError):
31
+ """The caller supplied a request that does not satisfy the runner contract."""
32
+
33
+
34
+ class RunnerError(RuntimeError):
35
+ """Base class for fail-closed runner and process errors.
36
+
37
+ Error details are deliberately bounded and redacted at construction time.
38
+ Request prompts, artifact bodies, and environments are not accepted by this
39
+ class and therefore cannot accidentally appear in its message.
40
+ """
41
+
42
+ def __init__(self, message: str, *, details: str = "") -> None:
43
+ safe_message = bounded_diagnostic(message, max_chars=MAX_DIAGNOSTIC_CHARS)
44
+ safe_details = bounded_diagnostic(details, max_chars=MAX_DIAGNOSTIC_CHARS)
45
+ if safe_details:
46
+ safe_message = f"{safe_message}: {safe_details}"
47
+ super().__init__(safe_message)
48
+
49
+
50
+ class RunnerExecutableNotFoundError(RunnerError):
51
+ """The selected executable could not be spawned."""
52
+
53
+
54
+ class RunnerTimeoutError(RunnerError):
55
+ """The selected process exceeded its timeout and was terminated."""
56
+
57
+
58
+ class RunnerSignalError(RunnerError):
59
+ """The selected process terminated because of a signal."""
60
+
61
+
62
+ class RunnerNonzeroExitError(RunnerError):
63
+ """A process returned a non-zero status when success was required."""
64
+
65
+
66
+ class RunnerProtocolError(RunnerError):
67
+ """A runner emitted malformed or incomplete protocol output."""
68
+
69
+
70
+ class RunnerMissingOutputError(RunnerProtocolError):
71
+ """An analysis request completed without a final response."""
72
+
73
+
74
+ class RunnerAdapterUnavailableError(RunnerError):
75
+ """A known adapter has not been installed/implemented in this build."""
76
+
77
+
78
+ def _validate_text(
79
+ value: object,
80
+ label: str,
81
+ *,
82
+ allow_empty: bool = False,
83
+ allow_line_breaks: bool = False,
84
+ allow_controls: bool = False,
85
+ ) -> str:
86
+ if not isinstance(value, str) or (not allow_empty and not value.strip()):
87
+ raise RunnerContractError(f"{label} must be a non-empty string")
88
+ if allow_controls:
89
+ return value
90
+ allowed_controls = "\r\n\t" if allow_line_breaks else ""
91
+ if "\x00" in value or any(ord(character) < 32 and character not in allowed_controls for character in value):
92
+ raise RunnerContractError(f"{label} contains control characters")
93
+ return value
94
+
95
+
96
+ def _validate_argv(values: Sequence[str], label: str) -> tuple[str, ...]:
97
+ if not isinstance(values, (tuple, list)) or not values:
98
+ raise RunnerContractError(f"{label} must be a non-empty argv sequence")
99
+ result: list[str] = []
100
+ for index, value in enumerate(values):
101
+ if not isinstance(value, str) or not value:
102
+ raise RunnerContractError(f"{label}[{index}] must be a non-empty string")
103
+ if "\x00" in value:
104
+ raise RunnerContractError(f"{label}[{index}] contains a NUL character")
105
+ result.append(value)
106
+ return tuple(result)
107
+
108
+
109
+ @dataclass(frozen=True, repr=False)
110
+ class RunnerArtifact:
111
+ """One ordered logical input artifact.
112
+
113
+ ``path`` is an already validated hook-owned path for adapters with native
114
+ attachment support. It is intentionally not used to render prompt
115
+ packets, so adapters without attachments receive the same name and body.
116
+ """
117
+
118
+ name: str
119
+ content: str = field(repr=False)
120
+ path: pathlib.Path | None = None
121
+
122
+ def __post_init__(self) -> None:
123
+ _validate_text(self.name, "artifact name")
124
+ _validate_text(
125
+ self.content,
126
+ "artifact content",
127
+ allow_empty=True,
128
+ allow_line_breaks=True,
129
+ allow_controls=True,
130
+ )
131
+ if self.path is not None and not isinstance(self.path, pathlib.Path):
132
+ object.__setattr__(self, "path", pathlib.Path(self.path))
133
+ if self.path is not None and "\x00" in str(self.path):
134
+ raise RunnerContractError("artifact path contains a NUL character")
135
+
136
+ def __repr__(self) -> str:
137
+ path = f", path={self.path!r}" if self.path is not None else ""
138
+ return f"RunnerArtifact(name={self.name!r}, content=<redacted>{path})"
139
+
140
+
141
+ @dataclass(frozen=True, repr=False)
142
+ class PromptPacket:
143
+ """Deterministic prompt representation for adapters without attachments."""
144
+
145
+ instruction: str = field(repr=False)
146
+ artifacts: tuple[RunnerArtifact, ...] = ()
147
+
148
+ def __post_init__(self) -> None:
149
+ _validate_text(
150
+ self.instruction,
151
+ "instruction",
152
+ allow_empty=True,
153
+ allow_line_breaks=True,
154
+ allow_controls=True,
155
+ )
156
+ if not isinstance(self.artifacts, (tuple, list)):
157
+ raise RunnerContractError("prompt packet artifacts must be ordered")
158
+ if not all(isinstance(item, RunnerArtifact) for item in self.artifacts):
159
+ raise RunnerContractError("prompt packet artifacts must contain RunnerArtifact values")
160
+ object.__setattr__(self, "artifacts", tuple(self.artifacts))
161
+
162
+ def render(self) -> str:
163
+ """Render names and bodies in order without exposing attachment paths."""
164
+
165
+ sections = [self.instruction]
166
+ for artifact in self.artifacts:
167
+ sections.append(
168
+ f"\n\n--- ai-push-hooks artifact: {artifact.name} ---\n"
169
+ f"{artifact.content}\n"
170
+ f"--- end ai-push-hooks artifact: {artifact.name} ---"
171
+ )
172
+ return "".join(sections)
173
+
174
+ @property
175
+ def text(self) -> str:
176
+ return self.render()
177
+
178
+
179
+ @dataclass(frozen=True, repr=False)
180
+ class RunnerRequest:
181
+ """The complete, resolved input to one runner invocation."""
182
+
183
+ profile_id: str
184
+ runner_type: str
185
+ stage: str
186
+ purpose: str
187
+ mode: RunnerMode
188
+ instruction: str = field(repr=False)
189
+ artifacts: tuple[RunnerArtifact, ...] = ()
190
+ cwd: pathlib.Path = pathlib.Path(".")
191
+ timeout_seconds: float = 0
192
+ model: str | None = None
193
+ variant: str | None = None
194
+ project_access: ProjectAccess = "artifacts"
195
+ allow_paths: tuple[str, ...] = ()
196
+ command: tuple[str, ...] = ()
197
+ prompt_transport: PromptTransport = "stdin"
198
+ # These fields exist for the current OpenCode session/lifecycle integration
199
+ # only. Other adapters may ignore them and must remain session-optional.
200
+ session_id: str | None = field(default=None, repr=False)
201
+ resume_session: bool = False
202
+ integration_context: object | None = field(default=None, repr=False)
203
+
204
+ def __post_init__(self) -> None:
205
+ _validate_text(self.profile_id, "profile_id")
206
+ _validate_text(self.runner_type, "runner_type")
207
+ _validate_text(self.stage, "stage")
208
+ _validate_text(self.purpose, "purpose")
209
+ if self.mode not in {"ask", "apply"}:
210
+ raise RunnerContractError("mode must be 'ask' or 'apply'")
211
+ _validate_text(self.instruction, "instruction", allow_empty=True, allow_line_breaks=True)
212
+ if not isinstance(self.artifacts, (tuple, list)):
213
+ raise RunnerContractError("artifacts must be an ordered sequence")
214
+ normalized_artifacts: list[RunnerArtifact] = []
215
+ for artifact in self.artifacts:
216
+ if not isinstance(artifact, RunnerArtifact):
217
+ raise RunnerContractError("artifacts must contain RunnerArtifact values")
218
+ normalized_artifacts.append(artifact)
219
+ object.__setattr__(self, "artifacts", tuple(normalized_artifacts))
220
+ if not isinstance(self.cwd, pathlib.Path):
221
+ object.__setattr__(self, "cwd", pathlib.Path(self.cwd))
222
+ if (
223
+ isinstance(self.timeout_seconds, bool)
224
+ or not isinstance(self.timeout_seconds, (int, float))
225
+ or not math.isfinite(self.timeout_seconds)
226
+ ):
227
+ raise RunnerContractError("timeout_seconds must be finite")
228
+ if self.timeout_seconds <= 0:
229
+ raise RunnerContractError("timeout_seconds must be greater than zero")
230
+ for value, label in ((self.model, "model"), (self.variant, "variant")):
231
+ if value is not None:
232
+ _validate_text(value, label, allow_empty=True)
233
+ if self.project_access not in {"artifacts", "project"}:
234
+ raise RunnerContractError("project_access must be 'artifacts' or 'project'")
235
+ if not isinstance(self.allow_paths, (tuple, list)):
236
+ raise RunnerContractError("allow_paths must be an ordered sequence")
237
+ object.__setattr__(self, "allow_paths", tuple(self.allow_paths))
238
+ for index, path in enumerate(self.allow_paths):
239
+ _validate_text(path, f"allow_paths[{index}]")
240
+ if not isinstance(self.command, (tuple, list)):
241
+ raise RunnerContractError("command must be an argv sequence")
242
+ if self.command:
243
+ object.__setattr__(self, "command", _validate_argv(self.command, "command"))
244
+ else:
245
+ object.__setattr__(self, "command", ())
246
+ if self.prompt_transport not in {"stdin", "argv"}:
247
+ raise RunnerContractError("prompt_transport must be 'stdin' or 'argv'")
248
+ if self.session_id is not None:
249
+ _validate_text(self.session_id, "session_id")
250
+
251
+ def prompt_packet(self) -> PromptPacket:
252
+ """Build the ordered logical packet shared by non-attachment adapters."""
253
+
254
+ return PromptPacket(self.instruction, self.artifacts)
255
+
256
+ def __repr__(self) -> str:
257
+ return (
258
+ "RunnerRequest("
259
+ f"profile_id={self.profile_id!r}, runner_type={self.runner_type!r}, "
260
+ f"stage={self.stage!r}, purpose={self.purpose!r}, mode={self.mode!r}, "
261
+ f"artifact_count={len(self.artifacts)}, cwd={str(self.cwd)!r}, "
262
+ f"timeout_seconds={self.timeout_seconds!r}, model={self.model!r}, "
263
+ f"variant={self.variant!r}, project_access={self.project_access!r}, "
264
+ f"allow_path_count={len(self.allow_paths)}, command_arg_count={len(self.command)})"
265
+ )
266
+
267
+
268
+ @dataclass(frozen=True, repr=False)
269
+ class SessionMetadata:
270
+ """Truthful lifecycle state reported by an adapter."""
271
+
272
+ session_id: str | None = None
273
+ state: SessionState = "ephemeral"
274
+ resumable: bool = False
275
+ transcript: str | None = field(default=None, repr=False)
276
+
277
+ def __post_init__(self) -> None:
278
+ if self.session_id is not None:
279
+ _validate_text(self.session_id, "session_id")
280
+ if self.state not in {"persisted", "ephemeral", "deleted"}:
281
+ raise RunnerContractError("session state is invalid")
282
+ if self.resumable and self.state != "persisted":
283
+ raise RunnerContractError("only persisted sessions can be resumable")
284
+ if self.transcript is not None:
285
+ _validate_text(
286
+ self.transcript,
287
+ "transcript",
288
+ allow_empty=True,
289
+ allow_line_breaks=True,
290
+ allow_controls=True,
291
+ )
292
+
293
+ def __repr__(self) -> str:
294
+ return (
295
+ "SessionMetadata("
296
+ f"session_id={self.session_id!r}, state={self.state!r}, "
297
+ f"resumable={self.resumable}, transcript=<redacted>)"
298
+ )
299
+
300
+
301
+ @dataclass(frozen=True, repr=False)
302
+ class RunnerResult:
303
+ """Normalized output from an adapter."""
304
+
305
+ final_text: str
306
+ returncode: int
307
+ stdout: str = field(repr=False)
308
+ stderr: str = field(repr=False)
309
+ session: SessionMetadata | None = None
310
+ transcript: str | None = field(default=None, repr=False)
311
+
312
+ def __post_init__(self) -> None:
313
+ _validate_text(self.final_text, "final_text", allow_empty=True, allow_controls=True)
314
+ if isinstance(self.returncode, bool) or not isinstance(self.returncode, int):
315
+ raise RunnerContractError("returncode must be an integer")
316
+ _validate_text(self.stdout, "stdout", allow_empty=True, allow_controls=True)
317
+ _validate_text(self.stderr, "stderr", allow_empty=True, allow_controls=True)
318
+ if self.session is not None and not isinstance(self.session, SessionMetadata):
319
+ raise RunnerContractError("session must be SessionMetadata or None")
320
+ if self.transcript is not None:
321
+ _validate_text(self.transcript, "transcript", allow_empty=True, allow_line_breaks=True)
322
+
323
+ def __repr__(self) -> str:
324
+ return (
325
+ "RunnerResult("
326
+ f"returncode={self.returncode!r}, session={self.session!r}, "
327
+ "final_text=<redacted>, stdout=<redacted>, stderr=<redacted>)"
328
+ )
329
+
330
+
331
+ @dataclass(frozen=True)
332
+ class RunnerCapabilities:
333
+ """Optional adapter features; invocation never depends on them."""
334
+
335
+ supports_resume: bool = False
336
+ supports_finalize: bool = False
337
+ supports_transcript: bool = False
338
+
339
+
340
+ class Runner(Protocol):
341
+ """Minimal adapter API shared by all runner types."""
342
+
343
+ capabilities: RunnerCapabilities
344
+
345
+ def run(self, request: RunnerRequest) -> RunnerResult:
346
+ ...
347
+
348
+
349
+ class RunnerLifecycle(Protocol):
350
+ """Optional lifecycle API, implemented only by adapters that need it."""
351
+
352
+ def finalize(self, request: RunnerRequest, result: RunnerResult) -> RunnerResult:
353
+ ...
354
+
355
+
356
+ def finalize_runner(runner: Runner, request: RunnerRequest, result: RunnerResult) -> RunnerResult:
357
+ """Finalize a result when the adapter explicitly advertises that capability."""
358
+
359
+ capabilities = getattr(runner, "capabilities", RunnerCapabilities())
360
+ finalizer = getattr(runner, "finalize", None)
361
+ if not capabilities.supports_finalize or not callable(finalizer):
362
+ return result
363
+ finalized = finalizer(request, result)
364
+ if not isinstance(finalized, RunnerResult):
365
+ raise RunnerProtocolError("runner finalizer returned an invalid result")
366
+ return finalized
367
+
368
+
369
+ _ANSI_ESCAPE_PATTERN = re.compile(
370
+ r"(?:\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[@-_])"
371
+ )
372
+ _UNSAFE_TERMINAL_CONTROL_PATTERN = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
373
+ _DIAGNOSTIC_FRAGMENT_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:/+\-]{3,}")
374
+ _SECRET_PATTERNS = (
375
+ re.compile(r"(?i)(bearer\s+)[^\s,;]+"),
376
+ re.compile(
377
+ r"(?i)([\"']?(?:[a-z0-9]+[_-])*(?:api[_-]?key|access[_-]?token|auth[_-]?token|authorization|credential|password|secret|token)[a-z0-9_-]*[\"']?\s*[:=]\s*[\"']?)[^\"'\s,;}]+"
378
+ ),
379
+ re.compile(
380
+ r"(?i)(?<![a-z0-9])[a-z0-9][a-z0-9_.:/+\-]*(?:secret|api[_-]?key|access[_-]?token|auth[_-]?token|credential|password)[a-z0-9_.:/+\-]*(?![a-z0-9])"
381
+ ),
382
+ )
383
+
384
+
385
+ def strip_terminal_controls(value: str) -> str:
386
+ """Remove ANSI/terminal controls while retaining ordinary text and newlines."""
387
+
388
+ without_ansi = _ANSI_ESCAPE_PATTERN.sub("", value)
389
+ return _UNSAFE_TERMINAL_CONTROL_PATTERN.sub("", without_ansi)
390
+
391
+
392
+ def redact_diagnostic(value: str, *, secrets: Sequence[str] = ()) -> str:
393
+ """Redact supplied secrets and common credential-shaped values."""
394
+
395
+ redacted = strip_terminal_controls(value)
396
+ for pattern in _SECRET_PATTERNS:
397
+ redacted = pattern.sub(
398
+ lambda match: (
399
+ f"{match.group(1)}[REDACTED]"
400
+ if match.lastindex
401
+ else "[REDACTED]"
402
+ ),
403
+ redacted,
404
+ )
405
+ redaction_values: list[str] = []
406
+ seen: set[str] = set()
407
+ fragment_count = 0
408
+ for secret in secrets:
409
+ if not isinstance(secret, str) or not secret or secret in seen:
410
+ continue
411
+ seen.add(secret)
412
+ redaction_values.append(secret)
413
+ # A bounded fragment set catches a child echoing one prompt token,
414
+ # while skipping a whole large diff avoids the old O(words * stream)
415
+ # behavior. The request-sensitive helper additionally suppresses
416
+ # excerpts for genuinely large request bodies.
417
+ if len(secret) <= MAX_DIAGNOSTIC_REQUEST_CHARS:
418
+ for fragment in _DIAGNOSTIC_FRAGMENT_PATTERN.findall(secret):
419
+ if fragment not in seen:
420
+ seen.add(fragment)
421
+ redaction_values.append(fragment)
422
+ fragment_count += 1
423
+ if fragment_count >= 512:
424
+ break
425
+ if fragment_count >= 512:
426
+ continue
427
+ for secret in sorted(redaction_values, key=len, reverse=True):
428
+ redacted = redacted.replace(secret, "[REDACTED]")
429
+ return redacted
430
+
431
+
432
+ def bounded_diagnostic(
433
+ value: str,
434
+ *,
435
+ max_chars: int = MAX_DIAGNOSTIC_CHARS,
436
+ secrets: Sequence[str] = (),
437
+ ) -> str:
438
+ """Return a bounded, redacted diagnostic excerpt; never an environment dump."""
439
+
440
+ if max_chars <= 0:
441
+ return ""
442
+ raw = str(value)
443
+ # Redact only the bounded preview. Anything after this point cannot be
444
+ # present in the returned diagnostic, and therefore does not need a scan.
445
+ # Credential-shaped values which reach the preview boundary are still
446
+ # handled by the regex redactor before the preview is returned.
447
+ preview = raw[:max_chars]
448
+ safe = redact_diagnostic(preview, secrets=secrets)
449
+ if len(raw) <= max_chars and len(safe) <= max_chars:
450
+ return safe
451
+ if max_chars <= len(DIAGNOSTIC_TRUNCATION_MARKER):
452
+ return DIAGNOSTIC_TRUNCATION_MARKER[:max_chars]
453
+ return safe[: max_chars - len(DIAGNOSTIC_TRUNCATION_MARKER)] + DIAGNOSTIC_TRUNCATION_MARKER
454
+
455
+
456
+ def bounded_redacted_diagnostics(
457
+ stdout: str = "",
458
+ stderr: str = "",
459
+ *,
460
+ max_chars: int = MAX_DIAGNOSTIC_CHARS,
461
+ secrets: Sequence[str] = (),
462
+ ) -> str:
463
+ """Format bounded child streams without including prompt or environment data."""
464
+
465
+ parts: list[str] = []
466
+ for label, value in (("stdout", stdout), ("stderr", stderr)):
467
+ if value:
468
+ parts.append(f"{label}: {bounded_diagnostic(value, max_chars=max_chars, secrets=secrets)}")
469
+ combined = "\n".join(parts)
470
+ return bounded_diagnostic(combined, max_chars=max_chars, secrets=secrets)
471
+
472
+
473
+ def _credential_environment_values(env: Mapping[str, str] | None) -> tuple[str, ...]:
474
+ if env is None:
475
+ return ()
476
+ credential_markers = ("API_KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH", "CREDENTIAL")
477
+ return tuple(
478
+ value
479
+ for name, value in env.items()
480
+ if isinstance(name, str)
481
+ and isinstance(value, str)
482
+ and value
483
+ and any(marker in name.upper() for marker in credential_markers)
484
+ )
485
+
486
+
487
+ def _request_sensitive_values(
488
+ request: RunnerRequest,
489
+ *,
490
+ env: Mapping[str, str] | None = None,
491
+ extra: Sequence[str] = (),
492
+ ) -> tuple[str, ...]:
493
+ """Return a small, deduplicated set of values safe to use for redaction.
494
+
495
+ In particular, this must not manufacture one secret per whitespace word
496
+ in a request. A large diff would otherwise make every diagnostic scan the
497
+ full child stream once per word. JSON-escaped forms cover a child that
498
+ embeds the packet in a JSON error object without retaining the packet as a
499
+ second unbounded secret.
500
+ """
501
+
502
+ values = (
503
+ request.instruction,
504
+ *(artifact.content for artifact in request.artifacts),
505
+ request.model or "",
506
+ request.variant or "",
507
+ *_credential_environment_values(env),
508
+ *extra,
509
+ )
510
+ result: list[str] = []
511
+ seen: set[str] = set()
512
+ for value in values:
513
+ if not isinstance(value, str) or not value or value in seen:
514
+ continue
515
+ seen.add(value)
516
+ result.append(value)
517
+ # The encoded form is bounded independently below. It is useful for
518
+ # escaped newlines/quotes in JSON diagnostics, but not worth retaining
519
+ # for a request-sized value.
520
+ if len(value) <= MAX_DIAGNOSTIC_REQUEST_CHARS:
521
+ encoded = json.dumps(value, ensure_ascii=True)
522
+ if encoded not in seen:
523
+ seen.add(encoded)
524
+ result.append(encoded)
525
+
526
+ return tuple(result)
527
+
528
+
529
+ def request_sensitive_diagnostics(
530
+ request: RunnerRequest,
531
+ stdout: str = "",
532
+ stderr: str = "",
533
+ *,
534
+ max_chars: int = MAX_DIAGNOSTIC_CHARS,
535
+ env: Mapping[str, str] | None = None,
536
+ extra: Sequence[str] = (),
537
+ ) -> str:
538
+ """Build a bounded diagnostic while keeping request/environment data out.
539
+
540
+ If a request contains a body larger than the diagnostic budget, suppress
541
+ child excerpts entirely. It is not possible to prove that a short model
542
+ excerpt is unrelated to an arbitrary large prompt without an unbounded
543
+ substring search; a fixed explanation is safer and cheaper.
544
+ """
545
+
546
+ request_values = (
547
+ request.instruction,
548
+ *(artifact.content for artifact in request.artifacts),
549
+ )
550
+ if any(len(value) > MAX_DIAGNOSTIC_REQUEST_CHARS for value in request_values):
551
+ return "diagnostic output suppressed for a large request"
552
+ return bounded_redacted_diagnostics(
553
+ stdout,
554
+ stderr,
555
+ max_chars=max_chars,
556
+ secrets=_request_sensitive_values(request, env=env, extra=extra),
557
+ )
558
+
559
+
560
+ def require_final_text(final_text: str, *, mode: RunnerMode) -> str:
561
+ """Enforce the runner-neutral output rule for analysis versus apply."""
562
+
563
+ if mode == "ask" and not final_text.strip():
564
+ raise RunnerMissingOutputError("runner produced no final response")
565
+ return final_text
566
+
567
+
568
+ def build_prompt_packet(request: RunnerRequest) -> PromptPacket:
569
+ """Return a packet preserving the exact instruction and artifact order."""
570
+
571
+ return request.prompt_packet()
572
+
573
+
574
+ def require_zero_exit(
575
+ request: RunnerRequest,
576
+ result: RunnerResult,
577
+ *,
578
+ diagnostic_limit: int = MAX_DIAGNOSTIC_CHARS,
579
+ secrets: Sequence[str] = (),
580
+ env: Mapping[str, str] | None = None,
581
+ ) -> RunnerResult:
582
+ """Raise a bounded non-zero diagnostic while preserving normalized results."""
583
+
584
+ if result.returncode != 0:
585
+ details = request_sensitive_diagnostics(
586
+ request,
587
+ result.stdout,
588
+ result.stderr,
589
+ max_chars=diagnostic_limit,
590
+ env=env,
591
+ extra=secrets,
592
+ ) or f"exit code {result.returncode}"
593
+ raise RunnerNonzeroExitError(
594
+ f"runner {request.profile_id!r} ({request.runner_type}) failed at {request.stage!r}",
595
+ details=details,
596
+ )
597
+ return result