@@ -897,6 +897,7 @@ def infer_pending_todo_output_target( |
| 897 | 897 | """Infer the concrete file path a pending todo is asking the model to mutate.""" |
| 898 | 898 | |
| 899 | 899 | root = project_root or Path.cwd() |
| 900 | + target_label = _normalize_pending_output_label(item) |
| 900 | 901 | candidates = todo_file_candidates(item) |
| 901 | 902 | planned_targets = collect_planned_artifact_targets( |
| 902 | 903 | dod, |
@@ -905,11 +906,11 @@ def infer_pending_todo_output_target( |
| 905 | 906 | ) |
| 906 | 907 | |
| 907 | 908 | if candidates: |
| 908 | | - planned_files = { |
| 909 | | - target.name.lower(): target |
| 909 | + planned_files = [ |
| 910 | + target |
| 910 | 911 | for target, expect_directory in planned_targets |
| 911 | 912 | if not expect_directory |
| 912 | | - } |
| 913 | + ] |
| 913 | 914 | planned_directories = [ |
| 914 | 915 | target |
| 915 | 916 | for target, expect_directory in planned_targets |
@@ -926,21 +927,35 @@ def infer_pending_todo_output_target( |
| 926 | 927 | if candidate.is_absolute() or candidate_str.startswith("~"): |
| 927 | 928 | return Path(candidate_str).expanduser() |
| 928 | 929 | |
| 929 | | - planned_match = planned_files.get(candidate.name.lower()) |
| 930 | | - if planned_match is not None: |
| 931 | | - return planned_match |
| 932 | | - |
| 933 | | - for touched in reversed(touched_paths): |
| 934 | | - if touched.name.lower() == candidate.name.lower(): |
| 935 | | - continue |
| 936 | | - if candidate.suffix and touched.suffix.lower() != candidate.suffix.lower(): |
| 937 | | - continue |
| 938 | | - return touched.parent / candidate.name |
| 930 | + planned_matches = [ |
| 931 | + target |
| 932 | + for target in planned_files |
| 933 | + if target.name.lower() == candidate.name.lower() |
| 934 | + ] |
| 935 | + if planned_matches: |
| 936 | + return _select_best_pending_output_path( |
| 937 | + planned_matches, |
| 938 | + todo_label=target_label, |
| 939 | + ) |
| 940 | + |
| 941 | + touched_matches = [ |
| 942 | + touched.parent / candidate.name |
| 943 | + for touched in reversed(touched_paths) |
| 944 | + if touched.name.lower() != candidate.name.lower() |
| 945 | + and ( |
| 946 | + not candidate.suffix |
| 947 | + or touched.suffix.lower() == candidate.suffix.lower() |
| 948 | + ) |
| 949 | + ] |
| 950 | + if touched_matches: |
| 951 | + return _select_best_pending_output_path( |
| 952 | + touched_matches, |
| 953 | + todo_label=target_label, |
| 954 | + ) |
| 939 | 955 | |
| 940 | 956 | for directory in planned_directories: |
| 941 | 957 | return directory / candidate.name |
| 942 | 958 | |
| 943 | | - target_label = _normalize_pending_output_label(item) |
| 944 | 959 | if not target_label: |
| 945 | 960 | return None |
| 946 | 961 | |
@@ -969,6 +984,23 @@ def infer_pending_todo_output_target( |
| 969 | 984 | return matches[0][2] |
| 970 | 985 | |
| 971 | 986 | |
| 987 | +def _select_best_pending_output_path( |
| 988 | + paths: list[Path], |
| 989 | + *, |
| 990 | + todo_label: str, |
| 991 | +) -> Path: |
| 992 | + ranked = sorted( |
| 993 | + paths, |
| 994 | + key=lambda path: ( |
| 995 | + _pending_output_path_match_score(todo_label, path), |
| 996 | + not path.expanduser().exists(), |
| 997 | + str(path), |
| 998 | + ), |
| 999 | + reverse=True, |
| 1000 | + ) |
| 1001 | + return ranked[0] |
| 1002 | + |
| 1003 | + |
| 972 | 1004 | def preserve_task_grounded_acceptance_criteria( |
| 973 | 1005 | task_statement: str, |
| 974 | 1006 | *, |
@@ -1053,6 +1085,13 @@ def _pending_output_link_match_score(todo_label: str, link_label: str) -> int: |
| 1053 | 1085 | return 0 |
| 1054 | 1086 | |
| 1055 | 1087 | |
| 1088 | +def _pending_output_path_match_score(todo_label: str, path: Path) -> int: |
| 1089 | + if not todo_label: |
| 1090 | + return 0 |
| 1091 | + path_label = _normalize_pending_output_label(str(path)) |
| 1092 | + return _pending_output_link_match_score(todo_label, path_label) |
| 1093 | + |
| 1094 | + |
| 1056 | 1095 | def _iter_local_html_links(content: str) -> list[tuple[str, str]]: |
| 1057 | 1096 | pattern = re.compile( |
| 1058 | 1097 | r"<a\b[^>]*href\s*=\s*[\"']([^\"']+)[\"'][^>]*>(.*?)</a>", |