@@ -978,6 +978,75 @@ async def test_active_repair_scope_hook_blocks_repair_audit_loop_after_repeated_ |
| 978 | 978 | assert "repair audit loop" in blocked.message |
| 979 | 979 | |
| 980 | 980 | |
| 981 | +@pytest.mark.asyncio |
| 982 | +async def test_active_repair_scope_audit_loop_names_next_missing_repair_target( |
| 983 | + temp_dir: Path, |
| 984 | +) -> None: |
| 985 | + registry = create_default_registry(temp_dir) |
| 986 | + policy = build_permission_policy( |
| 987 | + active_mode=PermissionMode.WORKSPACE_WRITE, |
| 988 | + workspace_root=temp_dir, |
| 989 | + tool_requirements=registry.get_tool_requirements(), |
| 990 | + ) |
| 991 | + dod_store = DefinitionOfDoneStore(temp_dir) |
| 992 | + dod = create_definition_of_done("Repair the active artifact set") |
| 993 | + dod.status = "fixing" |
| 994 | + dod_path = dod_store.save(dod) |
| 995 | + guide_root = temp_dir / "guide" |
| 996 | + chapter_dir = guide_root / "chapters" |
| 997 | + chapter_dir.mkdir(parents=True, exist_ok=True) |
| 998 | + repair_target = chapter_dir / "04-reverse-proxy.html" |
| 999 | + next_missing = chapter_dir / "05-load-balancing.html" |
| 1000 | + repair_target.write_text("<h1>Reverse Proxy</h1>\n") |
| 1001 | + session = FakeSession( |
| 1002 | + active_dod_path=str(dod_path), |
| 1003 | + messages=[ |
| 1004 | + Message( |
| 1005 | + role=Role.ASSISTANT, |
| 1006 | + content=( |
| 1007 | + "Repair focus:\n" |
| 1008 | + f"- Fix the broken local reference `05-load-balancing.html` in `{repair_target}`.\n" |
| 1009 | + f"- Immediate next step: edit `{repair_target}`.\n" |
| 1010 | + f"- If the broken reference should remain, create `{next_missing}`; otherwise remove or replace `05-load-balancing.html`.\n" |
| 1011 | + "- Use the existing artifact files as the source of truth while repairing this file: " |
| 1012 | + f"`{repair_target}`, `{next_missing}`.\n" |
| 1013 | + ), |
| 1014 | + ) |
| 1015 | + ], |
| 1016 | + ) |
| 1017 | + hook = ActiveRepairScopeHook( |
| 1018 | + dod_store=dod_store, |
| 1019 | + project_root=temp_dir, |
| 1020 | + session=session, |
| 1021 | + ) |
| 1022 | + |
| 1023 | + def make_context(index: int) -> HookContext: |
| 1024 | + return HookContext( |
| 1025 | + tool_call=ToolCall( |
| 1026 | + id=f"read-{index}", |
| 1027 | + name="read", |
| 1028 | + arguments={"file_path": str(repair_target)}, |
| 1029 | + ), |
| 1030 | + tool=registry.get("read"), |
| 1031 | + registry=registry, |
| 1032 | + permission_policy=policy, |
| 1033 | + source="native", |
| 1034 | + ) |
| 1035 | + |
| 1036 | + for index in range(1, 5): |
| 1037 | + context = make_context(index) |
| 1038 | + result = await hook.pre_tool_use(context) |
| 1039 | + assert result.decision == HookDecision.CONTINUE |
| 1040 | + await hook.post_tool_use(context) |
| 1041 | + |
| 1042 | + blocked = await hook.pre_tool_use(make_context(5)) |
| 1043 | + |
| 1044 | + assert blocked.decision == HookDecision.DENY |
| 1045 | + assert blocked.message is not None |
| 1046 | + assert "repair audit loop" in blocked.message |
| 1047 | + assert str(next_missing) in blocked.message |
| 1048 | + |
| 1049 | + |
| 981 | 1050 | @pytest.mark.asyncio |
| 982 | 1051 | async def test_active_repair_scope_hook_allows_scoped_glob_within_active_artifact_roots( |
| 983 | 1052 | temp_dir: Path, |