@@ -20,6 +20,7 @@ from .dod import ( |
| 20 | 20 | planned_artifact_target_satisfied, |
| 21 | 21 | ) |
| 22 | 22 | from .memory import MemoryStore |
| 23 | +from .path_display import display_runtime_path |
| 23 | 24 | from .permissions import PermissionOverride, PermissionPolicy |
| 24 | 25 | from .repair_focus import ( |
| 25 | 26 | extract_active_repair_context, |
@@ -33,6 +34,7 @@ from .safeguard_services import ( |
| 33 | 34 | PreActionValidator, |
| 34 | 35 | extract_shell_text_rewrite_target, |
| 35 | 36 | ) |
| 37 | +from .workflow import infer_output_outline_label |
| 36 | 38 | |
| 37 | 39 | |
| 38 | 40 | class HookEvent(StrEnum): |
@@ -1096,6 +1098,135 @@ class LateReferenceDriftHook(BaseToolHook): |
| 1096 | 1098 | self._completed_scope_observation_count = 0 |
| 1097 | 1099 | |
| 1098 | 1100 | |
| 1101 | +class MissingPlannedOutputReadHook(BaseToolHook): |
| 1102 | + """Block rereads of planned outputs that have not been created yet.""" |
| 1103 | + |
| 1104 | + def __init__( |
| 1105 | + self, |
| 1106 | + *, |
| 1107 | + dod_store: DefinitionOfDoneStore, |
| 1108 | + project_root: Path, |
| 1109 | + session: Any, |
| 1110 | + ) -> None: |
| 1111 | + self.dod_store = dod_store |
| 1112 | + self.project_root = project_root |
| 1113 | + self.session = session |
| 1114 | + |
| 1115 | + async def pre_tool_use(self, context: HookContext) -> HookResult: |
| 1116 | + if context.tool_call.name != "read": |
| 1117 | + return HookResult() |
| 1118 | + if context.source == "verification": |
| 1119 | + return HookResult() |
| 1120 | + |
| 1121 | + missing_output = self._missing_planned_output_path(context.tool_call) |
| 1122 | + if missing_output is None: |
| 1123 | + return HookResult() |
| 1124 | + |
| 1125 | + target_path, dod = missing_output |
| 1126 | + message_lines = [ |
| 1127 | + ( |
| 1128 | + "[Blocked - missing planned output artifact: " |
| 1129 | + f"`{target_path}` has not been created yet.]" |
| 1130 | + ), |
| 1131 | + ( |
| 1132 | + "Suggestion: create it now with one " |
| 1133 | + f"`write(file_path=\"{display_runtime_path(target_path)}\", content=\"...\")` " |
| 1134 | + "call instead of reading it first." |
| 1135 | + ), |
| 1136 | + ] |
| 1137 | + |
| 1138 | + outline_label = infer_output_outline_label( |
| 1139 | + dod, |
| 1140 | + target_path, |
| 1141 | + project_root=self.project_root, |
| 1142 | + ) |
| 1143 | + if outline_label: |
| 1144 | + message_lines.append( |
| 1145 | + f"Use the existing outline label `{outline_label}` so the new file matches the current artifact graph." |
| 1146 | + ) |
| 1147 | + |
| 1148 | + sibling_hint = self._existing_sibling_html_hint(target_path) |
| 1149 | + if sibling_hint: |
| 1150 | + message_lines.append(sibling_hint) |
| 1151 | + |
| 1152 | + return HookResult( |
| 1153 | + decision=HookDecision.DENY, |
| 1154 | + message=" ".join(message_lines), |
| 1155 | + terminal_state="blocked", |
| 1156 | + ) |
| 1157 | + |
| 1158 | + def _missing_planned_output_path( |
| 1159 | + self, |
| 1160 | + tool_call: ToolCall, |
| 1161 | + ) -> tuple[Path, Any] | None: |
| 1162 | + dod_path = getattr(self.session, "active_dod_path", None) |
| 1163 | + if not dod_path: |
| 1164 | + return None |
| 1165 | + path = Path(str(dod_path)) |
| 1166 | + if not path.exists(): |
| 1167 | + return None |
| 1168 | + dod = self.dod_store.load(path) |
| 1169 | + if dod.status in {"done", "fixing"}: |
| 1170 | + return None |
| 1171 | + |
| 1172 | + raw_path = str(tool_call.arguments.get("file_path") or "").strip() |
| 1173 | + if not raw_path: |
| 1174 | + return None |
| 1175 | + target_path = Path(raw_path).expanduser().resolve(strict=False) |
| 1176 | + if target_path.exists(): |
| 1177 | + return None |
| 1178 | + |
| 1179 | + planned_targets = collect_planned_artifact_targets( |
| 1180 | + dod, |
| 1181 | + project_root=self.project_root, |
| 1182 | + ) |
| 1183 | + if not planned_targets: |
| 1184 | + return None |
| 1185 | + |
| 1186 | + for planned_target, expect_directory in planned_targets: |
| 1187 | + if expect_directory: |
| 1188 | + continue |
| 1189 | + if planned_target != target_path: |
| 1190 | + continue |
| 1191 | + if planned_artifact_target_satisfied( |
| 1192 | + dod, |
| 1193 | + target=planned_target, |
| 1194 | + expect_directory=False, |
| 1195 | + project_root=self.project_root, |
| 1196 | + ): |
| 1197 | + return None |
| 1198 | + return target_path, dod |
| 1199 | + |
| 1200 | + for planned_target, _ in planned_targets: |
| 1201 | + if target_path not in collect_missing_declared_html_output_files( |
| 1202 | + target=planned_target, |
| 1203 | + project_root=self.project_root, |
| 1204 | + ): |
| 1205 | + continue |
| 1206 | + return target_path, dod |
| 1207 | + return None |
| 1208 | + |
| 1209 | + def _existing_sibling_html_hint(self, target_path: Path) -> str | None: |
| 1210 | + if target_path.suffix.lower() not in {".html", ".htm"}: |
| 1211 | + return None |
| 1212 | + parent = target_path.parent |
| 1213 | + if not parent.is_dir(): |
| 1214 | + return None |
| 1215 | + siblings = sorted( |
| 1216 | + child for child in parent.iterdir() if child.is_file() and child != target_path |
| 1217 | + ) |
| 1218 | + html_siblings = [ |
| 1219 | + child for child in siblings if child.suffix.lower() in {".html", ".htm"} |
| 1220 | + ] |
| 1221 | + if not html_siblings: |
| 1222 | + return None |
| 1223 | + reference = html_siblings[-1] |
| 1224 | + return ( |
| 1225 | + "Reuse the overall structure and navigation pattern from " |
| 1226 | + f"`{reference.name}` as the starting pattern for this file." |
| 1227 | + ) |
| 1228 | + |
| 1229 | + |
| 1099 | 1230 | class HookManager: |
| 1100 | 1231 | """Runs tool hooks across Loader's three lifecycle events.""" |
| 1101 | 1232 | |
@@ -1311,6 +1442,11 @@ def build_default_tool_hooks( |
| 1311 | 1442 | project_root=workspace_root, |
| 1312 | 1443 | session=session, |
| 1313 | 1444 | ), |
| 1445 | + MissingPlannedOutputReadHook( |
| 1446 | + dod_store=DefinitionOfDoneStore(workspace_root), |
| 1447 | + project_root=workspace_root, |
| 1448 | + session=session, |
| 1449 | + ), |
| 1314 | 1450 | DuplicateActionHook(action_tracker), |
| 1315 | 1451 | ActionValidationHook(validator), |
| 1316 | 1452 | RollbackTrackingHook(registry, rollback_plan), |