File size: 36,837 Bytes
ddb2889
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
from __future__ import annotations

import argparse
import ast
import json
import socket
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

from life_game.code_mutation import (  # noqa: E402
    CodeMutationSpecError,
    apply_code_mutation_spec,
    apply_method_replacement_spec,
    apply_unified_diff_patch,
    code_mutation_diff_stats,
    code_mutation_spec_to_dict,
    extract_unified_diff_patch,
    method_replacement_spec_to_dict,
    parse_code_mutation_spec,
    parse_method_replacement_spec,
    parse_unified_diff_patch_spec,
    render_method_replacement_diff,
    render_code_mutation_diff,
)
from life_game.code_extensions import format_extension_briefs, select_extension_brief  # noqa: E402
from life_game.game import GAME_REGISTRY  # noqa: E402


def main() -> int:
    parser = argparse.ArgumentParser(description="Ask the LLM for a constrained game-code mutation proposal.")
    parser.add_argument("--mode", choices=sorted(GAME_REGISTRY), help="Game mode whose code should be modified.")
    parser.add_argument("--intent", help="Developer intent for the code change.")
    parser.add_argument("--base-url", default="http://127.0.0.1:8080/v1")
    parser.add_argument("--model", default="signal-garden-qwen35-code-mutation")
    parser.add_argument("--timeout-seconds", type=float, default=120.0)
    parser.add_argument("--max-tokens", type=int, default=2048)
    parser.add_argument("--common-chars", type=int, default=1200)
    parser.add_argument("--output", default="annotations/code_mutation_proposals.jsonl")
    parser.add_argument("--from-record", help="Replay a JSONL proposal record instead of querying the LLM.")
    parser.add_argument("--record-index", type=int, default=-1)
    parser.add_argument("--list-extensions", action="store_true", help="Print detailed extension briefs for --mode.")
    parser.add_argument("--extension-id", help="Use one detailed extension brief as the mutation target.")
    parser.add_argument("--ambitious", action="store_true", help="Bias the prompt toward 30+ changed-line patches.")
    parser.add_argument("--min-changed-lines", type=int, default=0, help="Reject proposals below this diff size.")
    parser.add_argument("--repair-attempts", type=int, default=0, help="Ask the LLM to repair invalid patch output.")
    parser.add_argument(
        "--proposal-format",
        choices=("auto", "line-ranges", "method-replacements", "json-patch", "unified-diff"),
        default="auto",
        help="Use JSON line ranges for small edits or whole-method replacement JSON for larger patches.",
    )
    parser.add_argument("--print-diff", action=argparse.BooleanOptionalAction, default=True)
    parser.add_argument("--apply", action="store_true", help="Write the proposed exact edits into this worktree.")
    args = parser.parse_args()

    if args.list_extensions:
        if not args.mode:
            print("Choose --mode to list detailed extension briefs for a game.", file=sys.stderr)
            return 1
        try:
            print(format_extension_briefs(args.mode, args.extension_id))
        except KeyError as exc:
            print(f"code mutation failed: {exc}", file=sys.stderr)
            return 1
        return 0

    min_changed_lines = max(0, int(args.min_changed_lines))
    if args.ambitious:
        args.max_tokens = max(args.max_tokens, 2560)
        args.common_chars = max(args.common_chars, 1200)
    proposal_format = args.proposal_format
    if proposal_format == "auto":
        proposal_format = "method-replacements" if args.ambitious or args.extension_id or min_changed_lines >= 30 else "line-ranges"
    repair_attempts = max(0, int(args.repair_attempts))
    if args.ambitious and proposal_format in {"json-patch", "method-replacements"}:
        repair_attempts = max(repair_attempts, 1)

    raw = ""
    prompt_messages: Any = []
    mode = args.mode or ""
    intent = args.intent or ""
    model = args.model
    base_url = args.base_url
    patch_spec: dict[str, Any] | None = None
    method_spec = None
    try:
        if args.from_record:
            source_record = _load_jsonl_record(Path(args.from_record), args.record_index)
            raw = str(source_record.get("raw_response") or "")
            prompt_messages = source_record.get("prompt_messages") or []
            proposal_format = str(source_record.get("proposal_format") or source_record.get("format") or proposal_format)
            mode = str(source_record.get("mode") or "")
            intent = str(source_record.get("intent") or "")
            model = str(source_record.get("model") or args.model)
            base_url = str(source_record.get("base_url") or args.base_url)
            if proposal_format == "method-replacements":
                method_spec_payload = source_record.get("method_spec")
                if method_spec_payload:
                    method_spec = parse_method_replacement_spec(json.dumps(method_spec_payload))
                else:
                    method_spec = parse_method_replacement_spec(raw)
                _validate_required_method_replacements(method_spec, mode, args.extension_id)
                preview_diff = render_method_replacement_diff(method_spec, ROOT)
                targets = apply_method_replacement_spec(method_spec, ROOT, dry_run=True)
            elif proposal_format in {"json-patch", "unified-diff"}:
                patch_spec = source_record.get("patch_spec") if isinstance(source_record.get("patch_spec"), dict) else None
                preview_diff = str(source_record.get("patch") or source_record.get("diff") or raw)
                targets = apply_unified_diff_patch(preview_diff, ROOT, dry_run=True)
            else:
                spec_payload = source_record.get("parsed_spec")
                spec = parse_code_mutation_spec(json.dumps(spec_payload))
                preview_diff = render_code_mutation_diff(spec, ROOT)
                targets = apply_code_mutation_spec(spec, ROOT, dry_run=True)
        else:
            if not args.mode or not args.intent:
                raise RuntimeError("--mode and --intent are required unless --from-record is used.")
            mode = args.mode
            intent = args.intent
            model = args.model
            base_url = args.base_url
            prompt_messages = _prompt_messages(
                mode,
                intent,
                args.common_chars,
                args.extension_id,
                args.ambitious,
                min_changed_lines,
                proposal_format,
            )
            last_error = ""
            for attempt in range(repair_attempts + 1):
                if attempt > 0:
                    prompt_messages = _repair_prompt_messages(proposal_format, raw, last_error)
                raw = _post_chat_completion(
                    args.base_url,
                    args.model,
                    prompt_messages,
                    args.timeout_seconds,
                    args.max_tokens,
                    json_response=proposal_format in {"line-ranges", "method-replacements", "json-patch"},
                )
                try:
                    if proposal_format == "method-replacements":
                        method_spec = parse_method_replacement_spec(raw)
                        _validate_required_method_replacements(method_spec, mode, args.extension_id)
                        preview_diff = render_method_replacement_diff(method_spec, ROOT)
                        targets = apply_method_replacement_spec(method_spec, ROOT, dry_run=True)
                    elif proposal_format == "json-patch":
                        patch_spec = parse_unified_diff_patch_spec(raw)
                        preview_diff = str(patch_spec["patch"])
                        targets = apply_unified_diff_patch(preview_diff, ROOT, dry_run=True)
                    elif proposal_format == "unified-diff":
                        preview_diff = extract_unified_diff_patch(raw)
                        targets = apply_unified_diff_patch(preview_diff, ROOT, dry_run=True)
                    else:
                        spec = parse_code_mutation_spec(raw)
                        preview_diff = render_code_mutation_diff(spec, ROOT)
                        targets = apply_code_mutation_spec(spec, ROOT, dry_run=True)
                except (CodeMutationSpecError, RuntimeError) as exc:
                    last_error = str(exc)
                    if attempt >= repair_attempts:
                        raise
                    continue
                break
        diff_stats = code_mutation_diff_stats(preview_diff)
        if diff_stats.changed_lines < min_changed_lines:
            raise RuntimeError(
                f"proposal changed {diff_stats.changed_lines} lines, below requested minimum {min_changed_lines}"
            )
        if args.apply:
            if proposal_format == "method-replacements":
                targets = apply_method_replacement_spec(method_spec, ROOT, dry_run=False)
            elif proposal_format in {"json-patch", "unified-diff"}:
                targets = apply_unified_diff_patch(preview_diff, ROOT, dry_run=False)
            else:
                targets = apply_code_mutation_spec(spec, ROOT, dry_run=False)
    except (CodeMutationSpecError, RuntimeError, KeyError) as exc:
        if raw or prompt_messages:
            _record_failure(args.output, mode, intent, model, base_url, proposal_format, prompt_messages, raw, str(exc))
        print(f"code mutation failed: {exc}", file=sys.stderr)
        return 1

    record = {
        "schema": "signal-garden-code-mutation-proposal/v1",
        "mode": mode,
        "intent": intent,
        "model": model,
        "base_url": base_url,
        "proposal_format": proposal_format,
        "prompt_messages": prompt_messages,
        "raw_response": raw,
        "parsed_spec": code_mutation_spec_to_dict(spec) if proposal_format == "line-ranges" else None,
        "method_spec": method_replacement_spec_to_dict(method_spec) if proposal_format == "method-replacements" else None,
        "patch_spec": patch_spec if proposal_format == "json-patch" else None,
        "patch": preview_diff if proposal_format in {"json-patch", "unified-diff"} else None,
        "diff": preview_diff,
        "diff_stats": {
            "added_lines": diff_stats.added_lines,
            "removed_lines": diff_stats.removed_lines,
            "changed_lines": diff_stats.changed_lines,
        },
        "applied": bool(args.apply),
        "targets": [str(path.relative_to(ROOT)) for path in targets],
    }
    output_path = ROOT / args.output
    output_path.parent.mkdir(parents=True, exist_ok=True)
    with output_path.open("a", encoding="utf-8") as handle:
        handle.write(json.dumps(record, sort_keys=True) + "\n")

    action = "Applied" if args.apply else "Validated"
    if proposal_format == "line-ranges":
        summary = spec.summary
    elif proposal_format == "method-replacements":
        summary = method_spec.summary
    else:
        summary = "unified diff patch"
    print(f"{action} proposal: {summary}")
    print(
        f"Diff size: +{diff_stats.added_lines}/-{diff_stats.removed_lines} "
        f"({diff_stats.changed_lines} changed lines)"
    )
    for target in targets:
        print(f"- {target.relative_to(ROOT)}")
    if args.print_diff and preview_diff:
        print()
        print(preview_diff.rstrip())
    print(f"Recorded proposal in {_display_path(output_path)}")
    if not args.apply:
        print("Rerun with --apply in this worktree to write the exact edits.")
    return 0


def _prompt_messages(
    mode: str,
    intent: str,
    common_chars: int,
    extension_id: str | None,
    ambitious: bool,
    min_changed_lines: int,
    proposal_format: str,
) -> list[dict[str, str]]:
    game = GAME_REGISTRY[mode]
    game_path = _module_path(game.__class__.__module__)
    game_source = game_path.read_text(encoding="utf-8")
    common_source = (ROOT / "life_game/games/common.py").read_text(encoding="utf-8")
    regions = _python_regions(game_source)
    selected_extension = select_extension_brief(mode, extension_id) if extension_id else None
    extension_briefs = _prompt_extension_briefs(mode, selected_extension)
    source_context = _source_context(game_path.relative_to(ROOT), game_source, selected_extension, proposal_format)
    helper_context = _helper_context(game_source, common_source, common_chars)
    method_scope_text = _method_scope_text(selected_extension, proposal_format)
    signatures_text = _method_signature_text(game_source, selected_extension.required_methods if selected_extension else ())
    required_method_text = (
        f"- Required replacement methods for this extension: {', '.join(selected_extension.required_methods)}.\n"
        if selected_extension and selected_extension.required_methods
        else ""
    )
    semantic_requirements = _semantic_requirement_text(mode, extension_id)
    target_lines = max(
        min_changed_lines,
        selected_extension.target_change_lines if selected_extension and ambitious else 30 if ambitious else 0,
    )
    ambition_rules = (
        f"- This is a material extension request: target at least {target_lines} changed diff lines.\n"
        "- Prefer 2-4 coordinated complete method replacements over a one-line tweak.\n"
        "- Do not answer with a tiny constant tweak; proposals below the target changed-line count are rejected.\n"
        "- If you cannot safely reach the target size, make the biggest coherent patch rather than padding with noise."
        if target_lines
        else "- Keep the patch compact unless the requested mechanic needs a larger change."
    )

    response_contract = _response_contract(proposal_format)
    user = f"""
Mode: {mode}
Objective: {game.objective}
Developer intent: {intent}

You may modify game code in this experimental git worktree.

{response_contract}

Rules:
- For material changes, replace complete methods from the replaceable regions list.
{required_method_text.rstrip()}
{semantic_requirements.rstrip()}
{method_scope_text.rstrip()}
- Only modify life_game/games/*.py, tests/test_*.py, or USAGE_hackathon-clean.md.
- Prefer changing only {game_path.relative_to(ROOT)}; include a test edit only when it is essential.
- Keep the game deterministic enough for tests and compatible with GameMode/GameModel.
- Do not add new third-party dependencies.
{ambition_rules}

Extension brief:
{extension_briefs}

{source_context}

Replaceable regions in {game_path.relative_to(ROOT)}:
{regions}

{signatures_text}

Imported helper excerpts:
```python
{helper_context}
```
"""
    return [
        {
            "role": "system",
            "content": _system_prompt(proposal_format),
        },
        {"role": "user", "content": user.strip()},
    ]


def _module_path(module_name: str) -> Path:
    return ROOT / Path(*module_name.split(".")).with_suffix(".py")


def _numbered_source(source: str) -> str:
    return "\n".join(f"{line_number:04d}: {line}" for line_number, line in enumerate(source.splitlines(), start=1))


def _source_context(
    path: Path,
    source: str,
    selected_extension: Any,
    proposal_format: str,
) -> str:
    required_methods = tuple(selected_extension.required_methods) if selected_extension else ()
    if proposal_format == "method-replacements" and required_methods:
        excerpt = _numbered_method_excerpt(source, required_methods)
        return f"Relevant numbered excerpts from {path}:\n```python\n{excerpt}\n```"
    return f"Numbered {path}:\n```python\n{_numbered_source(source)}\n```"


def _numbered_method_excerpt(source: str, method_names: tuple[str, ...]) -> str:
    lines = source.splitlines()
    try:
        tree = ast.parse(source)
    except SyntaxError:
        return _numbered_source(source)

    ranges: list[tuple[int, int]] = []
    first_method = len(lines) + 1
    for node in tree.body:
        if not isinstance(node, ast.ClassDef):
            continue
        method_nodes = [item for item in node.body if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))]
        if not method_nodes:
            continue
        first_method = min(first_method, min(item.lineno for item in method_nodes))
        for item in method_nodes:
            if item.name in method_names and item.end_lineno is not None:
                ranges.append((item.lineno, item.end_lineno))

    header_end = max(1, first_method - 1)
    merged = _merge_ranges([(1, header_end), *ranges])
    chunks: list[str] = []
    previous_end = 0
    for start, end in merged:
        if chunks and start > previous_end + 1:
            chunks.append(".... omitted unchanged methods ....")
        for line_number in range(start, end + 1):
            if 1 <= line_number <= len(lines):
                chunks.append(f"{line_number:04d}: {lines[line_number - 1]}")
        previous_end = end
    return "\n".join(chunks)


def _merge_ranges(ranges: list[tuple[int, int]]) -> list[tuple[int, int]]:
    cleaned = sorted((start, end) for start, end in ranges if start <= end)
    if not cleaned:
        return []
    merged = [cleaned[0]]
    for start, end in cleaned[1:]:
        previous_start, previous_end = merged[-1]
        if start <= previous_end + 1:
            merged[-1] = (previous_start, max(previous_end, end))
        else:
            merged.append((start, end))
    return merged


def _prompt_extension_briefs(mode: str, selected_extension: Any) -> str:
    if selected_extension is None:
        return format_extension_briefs(mode, None)
    lines = [
        f"- {selected_extension.extension_id}: {selected_extension.title}",
        f"  Goal: {selected_extension.player_facing_goal}",
        "  Implement:",
        *(f"    - {note}" for note in selected_extension.implementation_notes),
        "  Guardrails:",
        *(f"    - {risk}" for risk in selected_extension.risk_notes),
        f"  Required methods: {', '.join(selected_extension.required_methods or ('none',))}",
    ]
    return "\n".join(lines)


def _method_scope_text(selected_extension: Any, proposal_format: str) -> str:
    if proposal_format != "method-replacements" or selected_extension is None or not selected_extension.required_methods:
        return ""
    methods = ", ".join(selected_extension.required_methods)
    return (
        f"- Replace only these required methods unless the brief explicitly requires another method: {methods}.\n"
        "- Do not replace private helper methods such as names starting with `_`; call them with their existing signature.\n"
    )


def _method_signature_text(source: str, method_names: tuple[str, ...]) -> str:
    if not method_names:
        return ""
    signatures = _method_signatures(source, method_names)
    if not signatures:
        return ""
    return "Required method signatures:\n" + "\n".join(f"- {signature}" for signature in signatures)


def _method_signatures(source: str, method_names: tuple[str, ...]) -> list[str]:
    lines = source.splitlines()
    try:
        tree = ast.parse(source)
    except SyntaxError:
        return []
    wanted = set(method_names)
    signatures: list[str] = []
    for node in ast.walk(tree):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in wanted:
            line = lines[node.lineno - 1].strip()
            current = node.lineno
            while not line.rstrip().endswith(":") and len(line) < 240 and current < len(lines):
                line += " " + lines[current].strip()
                current += 1
            signatures.append(line)
    return signatures


def _helper_context(game_source: str, common_source: str, common_chars: int) -> str:
    names = _common_import_names(game_source)
    excerpt = _common_symbol_excerpt(common_source, names, max(0, int(common_chars)))
    if excerpt:
        return excerpt
    return common_source[: max(0, int(common_chars))]


def _common_import_names(source: str) -> tuple[str, ...]:
    try:
        tree = ast.parse(source)
    except SyntaxError:
        return ()
    names: list[str] = []
    for node in tree.body:
        if not isinstance(node, ast.ImportFrom) or node.module != "common":
            continue
        for alias in node.names:
            if alias.name not in names:
                names.append(alias.name)
    return tuple(names)


def _common_symbol_excerpt(source: str, names: tuple[str, ...], limit: int) -> str:
    if limit <= 0 or not names:
        return ""
    lines = source.splitlines()
    try:
        tree = ast.parse(source)
    except SyntaxError:
        return source[:limit]
    wanted = set(names)
    chunks: list[str] = []
    for node in tree.body:
        node_names: tuple[str, ...] = ()
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            node_names = (node.name,)
        elif isinstance(node, ast.Assign):
            node_names = tuple(target.id for target in node.targets if isinstance(target, ast.Name))
        elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
            node_names = (node.target.id,)
        if not wanted.intersection(node_names) or node.end_lineno is None:
            continue
        chunk = "\n".join(lines[line_number - 1] for line_number in range(node.lineno, node.end_lineno + 1))
        candidate = "\n\n".join((*chunks, chunk))
        if len(candidate) <= limit:
            chunks.append(chunk)
            continue
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            signature = _function_signature_excerpt(lines, node)
            candidate = "\n\n".join((*chunks, signature))
            if len(candidate) <= limit:
                chunks.append(signature)
        if len("\n\n".join(chunks)) >= limit:
            break
    return "\n\n".join(chunks).strip()


def _function_signature_excerpt(lines: list[str], node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
    signature = lines[node.lineno - 1]
    current = node.lineno
    while not signature.rstrip().endswith(":") and current < len(lines):
        signature += "\n" + lines[current]
        current += 1
    return signature + "\n    ..."


def _response_contract(proposal_format: str) -> str:
    if proposal_format == "unified-diff":
        return """Return only a unified diff patch. Do not wrap it in JSON. Do not include prose.

The patch must:
- use `diff --git a/path b/path` file headers,
- include enough context for `git apply --check`,
- preserve Python indentation,
- include only allowed paths."""
    if proposal_format == "method-replacements":
        return """Return JSON only with this schema:
{
  "summary": "short imperative summary",
  "rationale": "why the code change helps the game",
  "replacements": [
    {
      "path": "life_game/games/example.py",
      "class_name": "ExampleGame",
      "method_name": "advance",
      "content": "    def advance(self, model, game, rule):\n        ..."
    }
  ],
  "tests": ["uv run pytest tests/test_game.py"]
}

For method replacements:
- content is the complete replacement method, including the `def method_name(...)` line.
- Preserve the exact existing signature for every replaced method.
- Unindented method content is accepted and will be indented into the class.
- Replace required methods from the prompt; avoid private helpers unless explicitly required.
- Use existing imports/helpers only; do not invent `self.some_new_method(...)` or new imports.
- Store new state in `game.data` and existing GameModel fields such as bullets, enemies, towers, boss, claimed, or pickups."""
    if proposal_format == "json-patch":
        return """Return JSON only with this schema:
{
  "summary": "short imperative summary",
  "rationale": "why the code change helps the game",
  "patch": "unified diff patch text with escaped newline characters",
  "tests": ["uv run pytest tests/test_game.py"]
}

For the patch string:
- use `diff --git a/path b/path` file headers,
- include enough context for `git apply --check`,
- preserve Python indentation,
- include only allowed paths,
- make the patch material when requested, usually by replacing complete methods."""
    return """Return JSON only with this schema:
{
  "summary": "short imperative summary",
  "rationale": "why the code change helps the game",
  "edits": [
    {
      "path": "life_game/games/example.py",
      "purpose": "why this exact edit changes behavior",
      "start_line": 10,
      "end_line": 12,
      "new_lines": ["replacement line 1", "replacement line 2"]
    }
  ],
  "tests": ["uv run pytest tests/test_game.py"]
}

For line-range JSON:
- start_line and end_line are 1-based inclusive line numbers from the numbered source.
- Use JSON string arrays for new_lines.
- Preserve indentation inside each line string.
- The apply tool joins new_lines with newline characters and a final newline."""


def _system_prompt(proposal_format: str) -> str:
    addendum = (
        " Build material game mechanics as deterministic local changes: store new state in game.data or existing "
        "GameModel masks, update player-visible messages/progress, prefer complete new()/command()/advance()/progress() "
        "method replacements, and avoid app UI, renderer, replay, dependency, or multi-file architecture work unless "
        "the prompt explicitly requests it."
    )
    if proposal_format == "unified-diff":
        return "You are an expert Python game engineer. Return only a valid unified diff patch." + addendum
    if proposal_format == "method-replacements":
        return "You are an expert Python game engineer. Return only JSON with complete Python method replacements." + addendum
    if proposal_format == "json-patch":
        return "You are an expert Python game engineer. Return only JSON with a valid unified diff patch string." + addendum
    return "You are an expert Python game engineer. Return only validated JSON for exact code edits." + addendum


def _semantic_requirement_text(mode: str, extension_id: str | None) -> str:
    if mode == "Firewall Defender" and extension_id == "spread_charge":
        return (
            "- Semantic requirements: new() must initialize charge/max_charge/charge_cooldown; "
            "command() must create a real multi-bullet spread shot when charged by directly assigning "
            "a new bullets tuple on the returned GameModel, not by calling an invented helper; "
            "advance() must increment charge over time and maintain cooldown state.\n"
        )
    if mode == "Drone Swarm" and extension_id in {"anchor_modes", "overclock_pulse"}:
        return (
            "- Semantic requirements: command() must intentionally switch formation state; "
            "advance() must preserve or update towers according to that explicit state.\n"
        )
    if mode == "Gatekeeper" and extension_id == "typed_gates":
        return (
            "- Semantic requirements: new() must initialize typed gate state; command() must create gates with "
            "a gate_type such as blocker, zapper, or slow; advance() must branch on gate_type and implement "
            "distinct blocker, zapper, and slow behavior.\n"
        )
    if mode == "Data Vampire" and extension_id == "safe_feeding_zones":
        return (
            "- Semantic requirements: new() must initialize safe_zone or poison node state; command() or advance() "
            "must create or spend safe feeding zone state; advance() must prevent enemies from occupying active safe "
            "zones and must not use model.grid as food.\n"
        )
    if mode == "Gravity Well" and extension_id == "polarity_charge_heavies":
        return (
            "- Semantic requirements: new() must initialize polarity_cooldown, charge_meter, heavy_enemy, or "
            "orbit_shield state; command() must gate polarity toggles through cooldown or charge; advance() must "
            "make heavy enemies or orbit shield behavior mechanically different from normal enemies/core damage.\n"
        )
    return ""


def _repair_prompt_messages(proposal_format: str, raw_response: str, error: str) -> list[dict[str, str]]:
    if proposal_format == "json-patch":
        contract = _response_contract("json-patch")
    elif proposal_format == "unified-diff":
        contract = _response_contract("unified-diff")
    elif proposal_format == "method-replacements":
        contract = _response_contract("method-replacements")
    else:
        contract = _response_contract("line-ranges")
    return [
        {
            "role": "system",
            "content": _system_prompt(proposal_format),
        },
        {
            "role": "user",
            "content": f"""
Your previous code mutation output failed validation.

Validation error:
{error}

Previous output:
{raw_response[:12000]}

Return a corrected response that satisfies this contract:
{contract}

Important correction rules:
- Return the full corrected proposal, not only the method or hunk related to the validation error.
- Preserve every required replacement method from the original task; if the error names missing methods, include them.
- The patch must be accepted by `git apply --check`.
- In unified diff hunks, added Python code lines must start with exactly one `+` diff marker.
- The Python code after that marker must not contain an accidental extra leading `+`.
- Do not call invented helper methods. Method-replacement responses must be complete using existing helpers and GameModel fields.
- Preserve the exact existing method signature for every replaced method.
- Preserve indentation and enough context lines.
- Do not include prose outside the requested response format.
""".strip(),
        },
    ]


def _validate_required_method_replacements(method_spec: Any, mode: str, extension_id: str | None) -> None:
    if not extension_id:
        return
    brief = select_extension_brief(mode, extension_id)
    if brief is None or not brief.required_methods:
        return
    replaced = {replacement.method_name for replacement in method_spec.replacements}
    missing = [method for method in brief.required_methods if method not in replaced]
    if missing:
        raise RuntimeError(f"method replacement proposal is missing required method(s): {', '.join(missing)}")
    _validate_extension_method_content(method_spec, mode, extension_id)


def _validate_extension_method_content(method_spec: Any, mode: str, extension_id: str) -> None:
    replacements = {replacement.method_name: replacement.content for replacement in method_spec.replacements}
    if mode == "Firewall Defender" and extension_id == "spread_charge":
        _require_content("new", replacements.get("new", ""), ("charge", "max_charge", "charge_cooldown"))
        _require_content("command", replacements.get("command", ""), ("charge", "max_charge", "bullets"))
        _require_content("advance", replacements.get("advance", ""), ("charge", "max_charge", "charge_cooldown", "min("))
    if mode == "Drone Swarm" and extension_id in {"anchor_modes", "overclock_pulse"}:
        _require_content("command", replacements.get("command", ""), ("drone", "rotation"))
        _require_content("advance", replacements.get("advance", ""), ("towers", "events"))
    if mode == "Gatekeeper" and extension_id == "typed_gates":
        _require_content("new", replacements.get("new", ""), ("gate_type",))
        _require_content("command", replacements.get("command", ""), ("gate_type", "blocker", "zapper", "slow"))
        _require_content("advance", replacements.get("advance", ""), ("gate_type", "blocker", "zapper", "slow"))
    if mode == "Data Vampire" and extension_id == "safe_feeding_zones":
        _require_content("new", replacements.get("new", ""), ("safe", "zone"))
        _require_content("advance", replacements.get("advance", ""), ("safe", "zone", "poison"))
    if mode == "Gravity Well" and extension_id == "polarity_charge_heavies":
        _require_content("new", replacements.get("new", ""), ("polarity_cooldown", "charge_meter"))
        _require_content("command", replacements.get("command", ""), ("polarity_cooldown", "charge_meter"))
        _require_content("advance", replacements.get("advance", ""), ("heavy", "orbit_shield"))


def _require_content(method_name: str, content: str, required_terms: tuple[str, ...]) -> None:
    missing = [term for term in required_terms if term not in content]
    if missing:
        raise RuntimeError(f"{method_name} replacement is missing required content: {', '.join(missing)}")


def _python_regions(source: str) -> str:
    try:
        tree = ast.parse(source)
    except SyntaxError:
        return "- unavailable: source did not parse"

    regions: list[str] = []
    for node in tree.body:
        if isinstance(node, ast.ClassDef):
            for item in node.body:
                if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
                    regions.append(
                        f"- {node.name}.{item.name}: start_line {item.lineno}, end_line {item.end_lineno}"
                    )
        elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            regions.append(f"- {node.name}: start_line {node.lineno}, end_line {node.end_lineno}")
    return "\n".join(regions) if regions else "- no functions detected"


def _post_chat_completion(
    base_url: str,
    model: str,
    messages: list[dict[str, str]],
    timeout_seconds: float,
    max_tokens: int,
    json_response: bool,
) -> str:
    payload: dict[str, Any] = {
        "model": model,
        "messages": messages,
        "temperature": 0.2,
        "top_p": 0.9,
        "max_tokens": max(256, int(max_tokens)),
    }
    if json_response:
        payload["response_format"] = {"type": "json_object"}
    endpoint = f"{base_url.rstrip('/')}/chat/completions"
    request = urllib.request.Request(
        endpoint,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(request, timeout=max(1.0, float(timeout_seconds))) as response:
            response_body = response.read().decode("utf-8")
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"HTTP {exc.code} from {endpoint}: {detail[:240]}") from exc
    except (socket.timeout, TimeoutError) as exc:
        raise RuntimeError(f"timeout after {timeout_seconds:g}s contacting {endpoint}") from exc
    except urllib.error.URLError as exc:
        raise RuntimeError(f"could not connect to {endpoint}: {exc.reason}") from exc

    try:
        parsed = json.loads(response_body)
        return str(parsed["choices"][0]["message"].get("content") or "")
    except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc:
        raise RuntimeError("LLM server returned an invalid chat completion response.") from exc


def _display_path(path: Path) -> str:
    try:
        return str(path.relative_to(ROOT))
    except ValueError:
        return str(path)


def _load_jsonl_record(path: Path, record_index: int) -> dict[str, Any]:
    records: list[dict[str, Any]] = []
    with path.open("r", encoding="utf-8") as handle:
        for line in handle:
            stripped = line.strip()
            if not stripped:
                continue
            parsed = json.loads(stripped)
            if not isinstance(parsed, dict):
                raise RuntimeError(f"Record in {path} is not a JSON object.")
            records.append(parsed)
    if not records:
        raise RuntimeError(f"No records found in {path}.")
    try:
        return records[record_index]
    except IndexError as exc:
        raise RuntimeError(f"Record index {record_index} is outside {path}.") from exc


def _record_failure(
    output: str,
    mode: str,
    intent: str,
    model: str,
    base_url: str,
    proposal_format: str,
    prompt_messages: Any,
    raw: str,
    error: str,
) -> None:
    output_path = ROOT / output
    output_path.parent.mkdir(parents=True, exist_ok=True)
    record = {
        "schema": "signal-garden-code-mutation-proposal/v1",
        "status": "failed",
        "mode": mode,
        "intent": intent,
        "model": model,
        "base_url": base_url,
        "proposal_format": proposal_format,
        "prompt_messages": prompt_messages,
        "raw_response": raw,
        "error": error,
    }
    with output_path.open("a", encoding="utf-8") as handle:
        handle.write(json.dumps(record, sort_keys=True) + "\n")


if __name__ == "__main__":
    raise SystemExit(main())