@@ -12,6 +12,7 @@ from ..llm.base import ToolCall |
| 12 | 12 | from .context import RuntimeContext |
| 13 | 13 | from .dod import ( |
| 14 | 14 | DefinitionOfDone, |
| 15 | + collect_missing_declared_html_output_files, |
| 15 | 16 | collect_planned_artifact_targets, |
| 16 | 17 | infer_next_output_file, |
| 17 | 18 | planned_artifact_target_satisfied, |
@@ -695,7 +696,7 @@ class ResponseRepairer: |
| 695 | 696 | def _should_compact_empty_retry_message(self, dod: DefinitionOfDone) -> bool: |
| 696 | 697 | completed_artifacts, missing_artifacts = self._planned_artifact_counts(dod) |
| 697 | 698 | if completed_artifacts >= 3: |
| 698 | | - return missing_artifacts > 0 |
| 699 | + return missing_artifacts > 0 or self._has_concrete_next_output_step(dod) |
| 699 | 700 | return self._has_concrete_next_output_step(dod) |
| 700 | 701 | |
| 701 | 702 | def _planned_artifact_counts(self, dod: DefinitionOfDone) -> tuple[int, int]: |
@@ -718,14 +719,15 @@ class ResponseRepairer: |
| 718 | 719 | return completed, missing |
| 719 | 720 | |
| 720 | 721 | def _has_concrete_next_output_step(self, dod: DefinitionOfDone) -> bool: |
| 722 | + planned_targets = collect_planned_artifact_targets( |
| 723 | + dod, |
| 724 | + project_root=self.context.project_root, |
| 725 | + max_paths=12, |
| 726 | + ) |
| 721 | 727 | next_missing_artifact = next( |
| 722 | 728 | ( |
| 723 | 729 | artifact |
| 724 | | - for artifact in collect_planned_artifact_targets( |
| 725 | | - dod, |
| 726 | | - project_root=self.context.project_root, |
| 727 | | - max_paths=12, |
| 728 | | - ) |
| 730 | + for artifact in planned_targets |
| 729 | 731 | if not planned_artifact_target_satisfied( |
| 730 | 732 | dod, |
| 731 | 733 | target=artifact[0], |
@@ -735,6 +737,10 @@ class ResponseRepairer: |
| 735 | 737 | ), |
| 736 | 738 | None, |
| 737 | 739 | ) |
| 740 | + if next_missing_artifact is None: |
| 741 | + next_missing_artifact = self._next_missing_declared_output_artifact( |
| 742 | + planned_targets |
| 743 | + ) |
| 738 | 744 | next_pending = self._preferred_resume_pending_item( |
| 739 | 745 | dod, |
| 740 | 746 | missing_artifact=next_missing_artifact, |
@@ -816,6 +822,13 @@ class ResponseRepairer: |
| 816 | 822 | project_root=self.context.project_root, |
| 817 | 823 | ) |
| 818 | 824 | ] |
| 825 | + if not missing_labels and preferred_missing_artifact is not None: |
| 826 | + missing_labels = [ |
| 827 | + self._format_artifact_label( |
| 828 | + preferred_missing_artifact[0], |
| 829 | + expect_directory=preferred_missing_artifact[1], |
| 830 | + ) |
| 831 | + ] |
| 819 | 832 | if not missing_labels: |
| 820 | 833 | return [] |
| 821 | 834 | |
@@ -1110,6 +1123,63 @@ class ResponseRepairer: |
| 1110 | 1123 | ) |
| 1111 | 1124 | return lines |
| 1112 | 1125 | |
| 1126 | + if next_missing_artifact is not None and not next_missing_artifact[1]: |
| 1127 | + concrete_target = next_missing_artifact[0] |
| 1128 | + planned_target_keys = { |
| 1129 | + str(target.expanduser().resolve(strict=False)) |
| 1130 | + for target, expect_directory in collect_planned_artifact_targets( |
| 1131 | + dod, |
| 1132 | + project_root=self.context.project_root, |
| 1133 | + max_paths=12, |
| 1134 | + ) |
| 1135 | + if not expect_directory |
| 1136 | + } |
| 1137 | + if str(concrete_target.expanduser().resolve(strict=False)) not in planned_target_keys: |
| 1138 | + outline_label = infer_output_outline_label( |
| 1139 | + dod, |
| 1140 | + concrete_target, |
| 1141 | + project_root=self.context.project_root, |
| 1142 | + todo_label=next_pending or "", |
| 1143 | + ) |
| 1144 | + lines = [ |
| 1145 | + f"Resume with this exact next step: create `{concrete_target.name}`.", |
| 1146 | + "It is the next missing declared output in the current artifact graph.", |
| 1147 | + "Prefer one `write(content=...)` call for " |
| 1148 | + f"`{display_runtime_path(concrete_target)}` before more research.", |
| 1149 | + self._mutation_tool_scaffold( |
| 1150 | + concrete_target, |
| 1151 | + tool_name="write", |
| 1152 | + ), |
| 1153 | + ] |
| 1154 | + if outline_label: |
| 1155 | + lines.append( |
| 1156 | + f"Use the existing outline label `{outline_label}` for that file so it matches the current guide structure." |
| 1157 | + ) |
| 1158 | + self._append_concrete_html_write_cues( |
| 1159 | + lines, |
| 1160 | + target=concrete_target, |
| 1161 | + outline_label=outline_label, |
| 1162 | + retry_number=retry_number, |
| 1163 | + has_confirmed_output_file_progress=has_confirmed_output_file_progress, |
| 1164 | + has_confirmed_substantive_output_file_progress=has_confirmed_substantive_output_file_progress, |
| 1165 | + ) |
| 1166 | + if has_confirmed_substantive_output_file_progress: |
| 1167 | + lines.append( |
| 1168 | + "Follow the same full-payload one-file-at-a-time write pattern that " |
| 1169 | + "already created the confirmed output files." |
| 1170 | + ) |
| 1171 | + if retry_number >= 2: |
| 1172 | + lines.append( |
| 1173 | + "Do not return another working note or empty response; emit the " |
| 1174 | + "concrete mutation tool call now." |
| 1175 | + ) |
| 1176 | + else: |
| 1177 | + lines.append( |
| 1178 | + "Do not restart discovery unless one specific missing fact blocks " |
| 1179 | + "that file write." |
| 1180 | + ) |
| 1181 | + return lines |
| 1182 | + |
| 1113 | 1183 | for target, expect_directory in collect_planned_artifact_targets( |
| 1114 | 1184 | dod, |
| 1115 | 1185 | project_root=self.context.project_root, |
@@ -1398,7 +1468,7 @@ class ResponseRepairer: |
| 1398 | 1468 | None, |
| 1399 | 1469 | ) |
| 1400 | 1470 | if first_missing is None: |
| 1401 | | - return None |
| 1471 | + return self._next_missing_declared_output_artifact(planned_targets) |
| 1402 | 1472 | |
| 1403 | 1473 | next_pending = self._preferred_resume_pending_item( |
| 1404 | 1474 | dod, |
@@ -1432,6 +1502,31 @@ class ResponseRepairer: |
| 1432 | 1502 | return normalized_target, False |
| 1433 | 1503 | return first_missing |
| 1434 | 1504 | |
| 1505 | + def _next_missing_declared_output_artifact( |
| 1506 | + self, |
| 1507 | + planned_targets: list[tuple[Path, bool]], |
| 1508 | + ) -> tuple[Path, bool] | None: |
| 1509 | + seen_scopes: set[str] = set() |
| 1510 | + for planned_target, expect_directory in planned_targets: |
| 1511 | + if expect_directory: |
| 1512 | + scope_target = planned_target |
| 1513 | + elif planned_target.suffix.lower() in {".html", ".htm"}: |
| 1514 | + scope_target = planned_target |
| 1515 | + else: |
| 1516 | + continue |
| 1517 | + |
| 1518 | + scope_key = str(scope_target.expanduser().resolve(strict=False)) |
| 1519 | + if scope_key in seen_scopes: |
| 1520 | + continue |
| 1521 | + seen_scopes.add(scope_key) |
| 1522 | + missing_outputs = collect_missing_declared_html_output_files( |
| 1523 | + target=scope_target, |
| 1524 | + project_root=self.context.project_root, |
| 1525 | + ) |
| 1526 | + if missing_outputs: |
| 1527 | + return missing_outputs[0], False |
| 1528 | + return None |
| 1529 | + |
| 1435 | 1530 | def _preferred_retry_target(self, dod: DefinitionOfDone | None) -> str: |
| 1436 | 1531 | if dod is None: |
| 1437 | 1532 | return "" |