sequential-hooks 0.1.0__py3-none-any.whl

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 (43) hide show
  1. plugins/agy/_sequential_hooks/__init__.py +1 -0
  2. plugins/agy/_sequential_hooks/_adapter.py +566 -0
  3. plugins/agy/_sequential_hooks/_doctor.py +413 -0
  4. plugins/claude/_sequential_hooks/__init__.py +1 -0
  5. plugins/claude/_sequential_hooks/_adapter.py +560 -0
  6. plugins/claude/_sequential_hooks/_doctor.py +455 -0
  7. plugins/codex/_sequential_hooks/__init__.py +1 -0
  8. plugins/codex/_sequential_hooks/_adapter.py +466 -0
  9. plugins/codex/_sequential_hooks/_doctor.py +563 -0
  10. sequential_hooks/__init__.py +3 -0
  11. sequential_hooks/__main__.py +5 -0
  12. sequential_hooks/_arguments.py +222 -0
  13. sequential_hooks/_cleanup.py +88 -0
  14. sequential_hooks/_cli.py +440 -0
  15. sequential_hooks/_containment/__init__.py +410 -0
  16. sequential_hooks/_containment/_posix.py +236 -0
  17. sequential_hooks/_containment/_uncontained.py +239 -0
  18. sequential_hooks/_containment/_windows.py +853 -0
  19. sequential_hooks/_containment/_windows_api.py +570 -0
  20. sequential_hooks/_containment/_windows_launcher.py +189 -0
  21. sequential_hooks/_diagnostics.py +265 -0
  22. sequential_hooks/_doctor/__init__.py +342 -0
  23. sequential_hooks/_doctor/_command.py +469 -0
  24. sequential_hooks/_doctor/_common.py +448 -0
  25. sequential_hooks/_doctor/_types.py +94 -0
  26. sequential_hooks/_downstream.py +181 -0
  27. sequential_hooks/_executable.py +187 -0
  28. sequential_hooks/_executor.py +825 -0
  29. sequential_hooks/_registry.py +103 -0
  30. sequential_hooks/_runner.py +48 -0
  31. sequential_hooks/_types.py +125 -0
  32. sequential_hooks/hosts/__init__.py +130 -0
  33. sequential_hooks/hosts/_contract.py +434 -0
  34. sequential_hooks/hosts/_inspection.py +78 -0
  35. sequential_hooks/hosts/_json.py +150 -0
  36. sequential_hooks/hosts/_records.py +273 -0
  37. sequential_hooks/hosts/_skeleton.py +1326 -0
  38. sequential_hooks/py.typed +0 -0
  39. sequential_hooks-0.1.0.dist-info/METADATA +90 -0
  40. sequential_hooks-0.1.0.dist-info/RECORD +43 -0
  41. sequential_hooks-0.1.0.dist-info/WHEEL +4 -0
  42. sequential_hooks-0.1.0.dist-info/entry_points.txt +7 -0
  43. sequential_hooks-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,560 @@
1
+ """Validate and compose Claude Code hook events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from importlib import import_module
7
+ from typing import TYPE_CHECKING, cast
8
+
9
+ from sequential_hooks.hosts import (
10
+ API_VERSION,
11
+ AdapterOutputError,
12
+ Composer,
13
+ Contribution,
14
+ HostInputError,
15
+ NativeResponse,
16
+ Outcome,
17
+ OutputFailureKind,
18
+ encode_response,
19
+ labeled,
20
+ optional_string,
21
+ reject_unsupported,
22
+ unsupported_fields,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Mapping
27
+
28
+ from plugins.claude._sequential_hooks._doctor import ClaudeDoctorLoader
29
+ from sequential_hooks.hosts import (
30
+ Diagnostics,
31
+ DoctorLoader,
32
+ FinalResponse,
33
+ JsonObject,
34
+ JsonValue,
35
+ Step,
36
+ )
37
+
38
+ _CLAUDE_FIELD_LIMIT_CHARACTERS = 10_000
39
+ _CLAUDE_PRE_RANK = {
40
+ 'allow': 1,
41
+ 'ask': 2,
42
+ 'defer': 3,
43
+ 'deny': 4,
44
+ }
45
+ _COMMON_FIELDS = frozenset(
46
+ {
47
+ 'continue',
48
+ 'decision',
49
+ 'hookSpecificOutput',
50
+ 'reason',
51
+ 'stopReason',
52
+ 'suppressOutput',
53
+ 'systemMessage',
54
+ 'terminalSequence',
55
+ }
56
+ )
57
+ _EVENTS = ('PreToolUse', 'PostToolUse')
58
+ _POST_HOOK_FIELDS = frozenset(
59
+ {'additionalContext', 'hookEventName', 'updatedMCPToolOutput', 'updatedToolOutput'}
60
+ )
61
+ _PRE_HOOK_FIELDS = frozenset(
62
+ {
63
+ 'additionalContext',
64
+ 'hookEventName',
65
+ 'permissionDecision',
66
+ 'permissionDecisionReason',
67
+ 'updatedInput',
68
+ }
69
+ )
70
+
71
+
72
+ class ClaudeAdapter(Composer):
73
+ """Compose child results for one validated Claude Code event.
74
+
75
+ Child results are consumed in step order. Accepted mutations update the
76
+ event-owned field supplied to later children, and finalization emits the
77
+ composed native response.
78
+ """
79
+
80
+ field_limit = _CLAUDE_FIELD_LIMIT_CHARACTERS
81
+
82
+ def __init__(
83
+ self,
84
+ raw_input: bytes,
85
+ payload: JsonObject,
86
+ diagnostics: Diagnostics,
87
+ *,
88
+ allow_on_failure: bool,
89
+ ) -> None:
90
+ """Validate native input and initialize one Claude Code event adapter.
91
+
92
+ Args:
93
+ raw_input: Original strict JSON bytes received from Claude Code.
94
+ payload: Decoded strict JSON object matching `raw_input`.
95
+ diagnostics: Developer-tier diagnostic sink.
96
+ allow_on_failure: Whether approved pre-tool failures may continue.
97
+
98
+ Raises:
99
+ HostInputError: If required Claude Code input is missing or
100
+ invalid, or the payload nests too deeply to compose.
101
+ """
102
+ event = payload.get('hook_event_name')
103
+ if event not in _EVENTS:
104
+ raise HostInputError('Claude Code input has an unsupported hook event')
105
+ tool_name = payload.get('tool_name')
106
+ if not isinstance(tool_name, str) or not tool_name:
107
+ raise HostInputError('Claude Code input requires a non-empty tool_name')
108
+ if 'tool_input' not in payload:
109
+ raise HostInputError('Claude Code input requires tool_input')
110
+ pre_tool = event == 'PreToolUse'
111
+ if not pre_tool and 'tool_response' not in payload:
112
+ raise HostInputError('Claude Code PostToolUse input requires tool_response')
113
+ self.pre_tool = pre_tool
114
+ self.mutation_field = 'tool_input' if pre_tool else 'tool_response'
115
+ super().__init__(
116
+ event,
117
+ raw_input,
118
+ payload,
119
+ diagnostics,
120
+ allow_on_failure=allow_on_failure,
121
+ )
122
+ self._tool_name = tool_name
123
+
124
+ def _parse_post_hook(self, hook: JsonObject) -> Contribution:
125
+ """Validate one current structured PostToolUse result."""
126
+ unsupported_fields(hook, _POST_HOOK_FIELDS, 'unsupported PostToolUse field')
127
+ if hook.get('hookEventName') != 'PostToolUse':
128
+ reject_unsupported('hookSpecificOutput event mismatch')
129
+ has_updated = 'updatedToolOutput' in hook
130
+ has_mcp = 'updatedMCPToolOutput' in hook
131
+ if has_updated and has_mcp:
132
+ reject_unsupported('PostToolUse mutation forms are ambiguous')
133
+ if has_mcp and not self._tool_name.startswith('mcp__'):
134
+ reject_unsupported('updatedMCPToolOutput requires an MCP tool')
135
+ mutation: JsonValue = None
136
+ if has_updated:
137
+ mutation = deepcopy(hook['updatedToolOutput'])
138
+ elif has_mcp:
139
+ mutation = deepcopy(hook['updatedMCPToolOutput'])
140
+ mutation_present = has_updated or has_mcp
141
+ if mutation_present and not _mutation_strings_fit(mutation):
142
+ raise AdapterOutputError(
143
+ OutputFailureKind.UNDELIVERABLE_MUTATION,
144
+ 'PostToolUse mutation contains an over-limit string',
145
+ )
146
+ return Contribution(
147
+ additional_context=optional_string(hook, 'additionalContext'),
148
+ mutation=mutation,
149
+ mutation_present=mutation_present,
150
+ )
151
+
152
+ def _parse_specific(self, value: JsonObject) -> Contribution:
153
+ """Parse event-specific and deprecated decision fields."""
154
+ legacy_decision = value.get('decision')
155
+ if 'decision' in value and not isinstance(legacy_decision, str):
156
+ reject_unsupported('decision must be a string')
157
+ legacy_reason = optional_string(value, 'reason')
158
+ hook_value = value.get('hookSpecificOutput')
159
+ if 'hookSpecificOutput' in value and not isinstance(hook_value, dict):
160
+ reject_unsupported('hookSpecificOutput must be an object')
161
+ hook = cast('JsonObject', hook_value) if hook_value is not None else None
162
+ if self.pre_tool:
163
+ legacy = _parse_legacy_pre(legacy_decision, legacy_reason)
164
+ if hook is not None and legacy_decision is not None:
165
+ reject_unsupported('current and deprecated decision forms are ambiguous')
166
+ if hook is None:
167
+ return legacy
168
+ return _parse_current_pre(hook)
169
+ if legacy_decision is not None and legacy_decision != 'block':
170
+ reject_unsupported('PostToolUse decision must be block')
171
+ if legacy_reason is not None and legacy_decision != 'block':
172
+ reject_unsupported('PostToolUse reason requires decision block')
173
+ hook_contribution = Contribution() if hook is None else self._parse_post_hook(hook)
174
+ return Contribution(
175
+ additional_context=hook_contribution.additional_context,
176
+ decision=cast('str | None', legacy_decision),
177
+ decision_reason=legacy_reason,
178
+ mutation=hook_contribution.mutation,
179
+ mutation_present=hook_contribution.mutation_present,
180
+ )
181
+
182
+ def _post_response(self, response: JsonObject) -> None:
183
+ """Add PostToolUse decision, context, and mutation fields."""
184
+ state = self.state
185
+ if state.decision == 'block':
186
+ response['decision'] = 'block'
187
+ reasons = self.bounded_decision_reasons()
188
+ if reasons is not None:
189
+ response['reason'] = reasons
190
+ contexts = self.bounded_contexts()
191
+ if contexts is None and not state.updated_output_present:
192
+ return
193
+ hook: JsonObject = {'hookEventName': 'PostToolUse'}
194
+ if contexts is not None:
195
+ hook['additionalContext'] = contexts
196
+ if state.updated_output_present:
197
+ hook['updatedToolOutput'] = state.updated_output
198
+ response['hookSpecificOutput'] = hook
199
+
200
+ def _pre_response(self, response: JsonObject) -> None:
201
+ """Add PreToolUse output for the winning decision."""
202
+ state = self.state
203
+ if state.decision is None:
204
+ return
205
+ hook: JsonObject = {'hookEventName': 'PreToolUse', 'permissionDecision': state.decision}
206
+ reasons = self.bounded_decision_reasons()
207
+ if reasons is not None:
208
+ hook['permissionDecisionReason'] = reasons
209
+ if state.decision in ('allow', 'ask'):
210
+ contexts = self.bounded_contexts()
211
+ if contexts is not None:
212
+ hook['additionalContext'] = contexts
213
+ if state.updated_input_present:
214
+ hook['updatedInput'] = state.updated_input
215
+ response['hookSpecificOutput'] = hook
216
+
217
+ def apply_decision(self, step: Step, value: Contribution) -> None:
218
+ """Compose one native decision and its rank-owned reason.
219
+
220
+ PostToolUse accepts only `block`, which always wins. PreToolUse ranks
221
+ `allow`, `ask`, `defer`, `deny`; a higher rank replaces the reasons, an
222
+ equal rank appends its reason.
223
+
224
+ Args:
225
+ step: Child that supplied `value`.
226
+ value: Validated contribution whose `decision` may be set.
227
+ """
228
+ state = self.state
229
+ if value.decision is None:
230
+ return
231
+ if not self.pre_tool:
232
+ state.decision = value.decision
233
+ if value.decision_reason is not None:
234
+ state.decision_reasons.append(labeled(step, value.decision_reason))
235
+ return
236
+ current_rank = _CLAUDE_PRE_RANK[state.decision] if state.decision is not None else 0
237
+ new_rank = _CLAUDE_PRE_RANK[value.decision]
238
+ if new_rank > current_rank:
239
+ state.decision = value.decision
240
+ state.decision_reasons = []
241
+ if new_rank >= current_rank and state.decision == value.decision and value.decision_reason:
242
+ state.decision_reasons.append(labeled(step, value.decision_reason))
243
+
244
+ def build_response(self) -> JsonObject:
245
+ """Build the current structured Claude Code response.
246
+
247
+ Returns:
248
+ Universal fields, the event-specific `hookSpecificOutput`, and the
249
+ global stop, after the current shedding choices.
250
+ """
251
+ state = self.state
252
+ response: JsonObject = {}
253
+ messages = self.bounded_messages()
254
+ if messages is not None:
255
+ response['systemMessage'] = messages
256
+ if state.suppress_output is not None:
257
+ response['suppressOutput'] = state.suppress_output
258
+ if state.terminal_sequence is not None:
259
+ response['terminalSequence'] = state.terminal_sequence
260
+ if self.pre_tool:
261
+ self._pre_response(response)
262
+ else:
263
+ self._post_response(response)
264
+ if state.continue_false:
265
+ response['continue'] = False
266
+ stop_reason = self.bounded_stop_reason()
267
+ if stop_reason is not None:
268
+ response['stopReason'] = stop_reason
269
+ return response
270
+
271
+ def classify_unparsable(self, text: str, stripped: bytes, error: str) -> Outcome:
272
+ """Classify non-JSON stdout as native plain text unless it opens an object.
273
+
274
+ Args:
275
+ text: Decoded stdout.
276
+ stripped: Stdout without leading ASCII whitespace.
277
+ error: Parser detail for the malformed classification.
278
+
279
+ Returns:
280
+ Plain outcome, or a malformed failure for text starting with `{`.
281
+ """
282
+ if not stripped.startswith(b'{'):
283
+ return Outcome('plain', plain=text)
284
+ return Outcome('failure', failure=OutputFailureKind.MALFORMED.value, message=error)
285
+
286
+ def failure_texts(self, category: str, message: str) -> tuple[str, str, str]:
287
+ """Return Claude Code's full-detail diagnostic and report texts.
288
+
289
+ Args:
290
+ category: Failure category with underscores replaced by spaces.
291
+ message: Failure detail, possibly empty.
292
+
293
+ Returns:
294
+ Diagnostic text, report text, and the minimal report text.
295
+ """
296
+ detail = message or f'{category} output'
297
+ return (
298
+ f'{category}: {detail}',
299
+ f'{category}: {detail}',
300
+ (
301
+ f'{category}: sequential-hooks cannot represent hook output; '
302
+ 'see developer diagnostics'
303
+ ),
304
+ )
305
+
306
+ def parse_structured(self, value: JsonObject) -> Contribution:
307
+ """Parse one whole Claude Code protocol object.
308
+
309
+ Args:
310
+ value: Strict JSON object a child wrote to stdout.
311
+
312
+ Returns:
313
+ Validated contribution.
314
+
315
+ Raises:
316
+ AdapterOutputError: If the object is outside the documented
317
+ contract.
318
+ """
319
+ unsupported_fields(value, _COMMON_FIELDS, 'unsupported Claude Code output field')
320
+ universal = _parse_universal(value)
321
+ specific = self._parse_specific(value)
322
+ return Contribution(
323
+ additional_context=specific.additional_context,
324
+ continue_value=universal.continue_value,
325
+ decision=specific.decision,
326
+ decision_reason=specific.decision_reason,
327
+ mutation=specific.mutation,
328
+ mutation_present=specific.mutation_present,
329
+ stop_reason=universal.stop_reason,
330
+ suppress_output=universal.suppress_output,
331
+ system_message=universal.system_message,
332
+ terminal_sequence=universal.terminal_sequence,
333
+ )
334
+
335
+ def terminal_fallback(self, step: Step) -> bool:
336
+ """Drop an accepted mutation to deliver a native block.
337
+
338
+ A committed terminal block outranks a retained mutation; the tool
339
+ already ran or will not run, so dropping the rewrite does not run an
340
+ unrewritten operation.
341
+
342
+ Args:
343
+ step: Child whose blocking exit is being delivered.
344
+
345
+ Returns:
346
+ Whether the response fits without the mutation.
347
+ """
348
+ state = self.state
349
+ state.updated_input_present = False
350
+ state.updated_output_present = False
351
+ self.write_diagnostic(step, 'accepted mutation dropped to deliver the blocking response')
352
+ try:
353
+ self.fit_response()
354
+ except AdapterOutputError:
355
+ return False
356
+ return True
357
+
358
+ def undeliverable_owner(self, owner: Contribution) -> bool:
359
+ """Return whether an unfittable owner carries mandatory Claude Code output.
360
+
361
+ Args:
362
+ owner: Contribution whose publication cannot fit.
363
+
364
+ Returns:
365
+ `True` for a decision, a global stop, or a mutation, unless the
366
+ owner's terminal sequence alone is what does not fit.
367
+ """
368
+ mandatory = (
369
+ owner.continue_value is False or owner.decision is not None or owner.mutation_present
370
+ )
371
+ if (
372
+ mandatory
373
+ and self.state.terminal_sequence_owner is owner
374
+ and self.fits_without_terminal_sequence()
375
+ ):
376
+ return False
377
+ return mandatory
378
+
379
+
380
+ class ClaudeHost:
381
+ """Describe the Claude Code host for entry-point discovery.
382
+
383
+ Attributes:
384
+ api_version: Host contract version implemented here.
385
+ events: Native events the wrapper serves.
386
+ name: Entry-point name selected by `--agent`.
387
+ requires_event: `False`; the payload carries `hook_event_name`.
388
+ """
389
+
390
+ api_version = API_VERSION
391
+ events = _EVENTS
392
+ name = 'claude'
393
+ requires_event = False
394
+
395
+ def adapter(
396
+ self,
397
+ event: str,
398
+ raw_input: bytes,
399
+ payload: JsonObject,
400
+ diagnostics: Diagnostics,
401
+ *,
402
+ allow_on_failure: bool,
403
+ ) -> ClaudeAdapter:
404
+ """Validate native input and construct the adapter for one invocation.
405
+
406
+ Args:
407
+ event: Trusted event established from the payload.
408
+ raw_input: Original native strict JSON hook input.
409
+ payload: Parsed native strict JSON object.
410
+ diagnostics: Bounded developer diagnostic sink.
411
+ allow_on_failure: Whether approved pre-tool failures may continue.
412
+
413
+ Returns:
414
+ Adapter ready to drive the chain.
415
+
416
+ Raises:
417
+ HostInputError: If the payload is not valid input for `event`.
418
+ """
419
+ adapter = ClaudeAdapter(raw_input, payload, diagnostics, allow_on_failure=allow_on_failure)
420
+ if adapter.event != event:
421
+ raise HostInputError('Claude Code input event does not match the selected event')
422
+ return adapter
423
+
424
+ def configuration_response(
425
+ self,
426
+ event: str,
427
+ reason: str,
428
+ diagnostics: Diagnostics,
429
+ ) -> FinalResponse:
430
+ """Return the native deny or block for a configuration error.
431
+
432
+ Args:
433
+ event: Trusted event, one of `events`.
434
+ reason: Wrapper diagnostic, published after response resolution.
435
+ diagnostics: Bounded developer diagnostic sink.
436
+
437
+ Returns:
438
+ PreToolUse deny or PostToolUse block without adapter construction.
439
+
440
+ Raises:
441
+ ValueError: If `event` is not one of `events`.
442
+ """
443
+ del reason
444
+ if event not in self.events:
445
+ raise ValueError(f'Claude Code has no configuration response for {event!r}')
446
+ message = '[step 1: configuration]\nspawn: sequential-hooks could not safely run this hook'
447
+ diagnostics.write_global(
448
+ 'configuration: spawn: sequential-hooks could not safely run this hook'
449
+ )
450
+ value: JsonObject = {'systemMessage': message}
451
+ if event == 'PreToolUse':
452
+ value['hookSpecificOutput'] = {
453
+ 'hookEventName': 'PreToolUse',
454
+ 'permissionDecision': 'deny',
455
+ 'permissionDecisionReason': message,
456
+ }
457
+ else:
458
+ value['decision'] = 'block'
459
+ value['reason'] = message
460
+ return NativeResponse(encode_response(value))
461
+
462
+ def doctor_loader(self) -> DoctorLoader:
463
+ """Return the static Claude Code configuration loader.
464
+
465
+ Returns:
466
+ Loader over the documented Claude Code settings files.
467
+ """
468
+ module = import_module('plugins.claude._sequential_hooks._doctor')
469
+ loader_type: type[ClaudeDoctorLoader] = module.ClaudeDoctorLoader
470
+ return loader_type()
471
+
472
+ def payload_event(self, payload: Mapping[str, object]) -> str | None:
473
+ """Return the event named by `hook_event_name`, if supported.
474
+
475
+ Args:
476
+ payload: Parsed native strict JSON object.
477
+
478
+ Returns:
479
+ `PreToolUse` or `PostToolUse`, else `None`.
480
+ """
481
+ value = payload.get('hook_event_name')
482
+ return value if isinstance(value, str) and value in _EVENTS else None
483
+
484
+
485
+ def _mutation_strings_fit(value: JsonValue) -> bool:
486
+ """Return whether every nested mutation string fits the field cap exactly."""
487
+ if isinstance(value, str):
488
+ return len(value) <= _CLAUDE_FIELD_LIMIT_CHARACTERS
489
+ if isinstance(value, list):
490
+ return all(_mutation_strings_fit(item) for item in value)
491
+ if isinstance(value, dict):
492
+ return all(_mutation_strings_fit(item) for item in value.values())
493
+ return True
494
+
495
+
496
+ def _parse_current_pre(hook: JsonObject) -> Contribution:
497
+ """Validate one current structured PreToolUse result."""
498
+ unsupported_fields(hook, _PRE_HOOK_FIELDS, 'unsupported PreToolUse field')
499
+ if hook.get('hookEventName') != 'PreToolUse':
500
+ reject_unsupported('hookSpecificOutput event mismatch')
501
+ native_decision = hook.get('permissionDecision')
502
+ if not isinstance(native_decision, str) or native_decision not in _CLAUDE_PRE_RANK:
503
+ reject_unsupported('unsupported PreToolUse permissionDecision')
504
+ decision = native_decision
505
+ mutation: JsonValue = None
506
+ mutation_present = 'updatedInput' in hook
507
+ if mutation_present:
508
+ if decision not in ('allow', 'ask'):
509
+ reject_unsupported('updatedInput requires allow or ask')
510
+ mutation = deepcopy(hook['updatedInput'])
511
+ if not _mutation_strings_fit(mutation):
512
+ raise AdapterOutputError(
513
+ OutputFailureKind.UNDELIVERABLE_MUTATION,
514
+ 'PreToolUse mutation contains an over-limit string',
515
+ )
516
+ return Contribution(
517
+ additional_context=optional_string(hook, 'additionalContext'),
518
+ decision=decision,
519
+ decision_reason=optional_string(hook, 'permissionDecisionReason'),
520
+ mutation=mutation,
521
+ mutation_present=mutation_present,
522
+ )
523
+
524
+
525
+ def _parse_legacy_pre(legacy_decision: JsonValue, legacy_reason: str | None) -> Contribution:
526
+ """Validate and normalize one deprecated top-level PreToolUse result."""
527
+ decision: str | None = None
528
+ if legacy_decision is not None:
529
+ if legacy_decision not in ('approve', 'block'):
530
+ reject_unsupported('deprecated decision must be approve or block')
531
+ decision = 'allow' if legacy_decision == 'approve' else 'deny'
532
+ elif legacy_reason is not None:
533
+ reject_unsupported('reason requires a deprecated decision')
534
+ return Contribution(decision=decision, decision_reason=legacy_reason)
535
+
536
+
537
+ def _parse_universal(value: JsonObject) -> Contribution:
538
+ """Validate and retain universal Claude Code output fields."""
539
+ continue_value = value.get('continue')
540
+ if 'continue' in value and not isinstance(continue_value, bool):
541
+ reject_unsupported('continue must be a Boolean')
542
+ stop_reason = optional_string(value, 'stopReason')
543
+ if stop_reason is not None and continue_value is not False:
544
+ reject_unsupported('stopReason requires continue false')
545
+ suppress_output = value.get('suppressOutput')
546
+ if 'suppressOutput' in value and not isinstance(suppress_output, bool):
547
+ reject_unsupported('suppressOutput must be a Boolean')
548
+ terminal_sequence = optional_string(value, 'terminalSequence')
549
+ if terminal_sequence is not None and len(terminal_sequence) > _CLAUDE_FIELD_LIMIT_CHARACTERS:
550
+ raise AdapterOutputError(
551
+ OutputFailureKind.OVERSIZED,
552
+ 'terminalSequence exceeds the Claude Code field limit',
553
+ )
554
+ return Contribution(
555
+ continue_value=cast('bool | None', continue_value),
556
+ stop_reason=stop_reason,
557
+ suppress_output=cast('bool | None', suppress_output),
558
+ system_message=optional_string(value, 'systemMessage'),
559
+ terminal_sequence=terminal_sequence,
560
+ )