tenseleyflow/loader / 7b1c338

Browse files

Require edits during repair audits

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
7b1c3380b91870e0bc4f654b0ad887525fd570ea
Parents
b69a3fe
Tree
1f79d21

4 changed files

StatusFile+-
M src/loader/runtime/hooks.py 21 3
M src/loader/runtime/safeguard_services.py 47 0
M tests/test_permissions.py 87 0
M tests/test_safeguard_services.py 44 0
src/loader/runtime/hooks.pymodified
@@ -1014,14 +1014,32 @@ class LateReferenceDriftHook(BaseToolHook):
10141014
                     roots_preview = ", ".join(f"`{root}`" for root in completed_scope[:2])
10151015
                     if len(completed_scope) > 2:
10161016
                         roots_preview += ", ..."
1017
+                    repair = extract_active_repair_context(
1018
+                        getattr(self.session, "messages", [])
1019
+                    )
1020
+                    if repair is not None and repair.allowed_paths:
1021
+                        repair_preview = ", ".join(
1022
+                            f"`{path}`" for path in repair.allowed_paths[:3]
1023
+                        )
1024
+                        if len(repair.allowed_paths) > 3:
1025
+                            repair_preview += ", ..."
1026
+                        suggestion = (
1027
+                            "make one concrete edit, patch, or write to the active "
1028
+                            f"repair file(s) {repair_preview}. Do not finish with a "
1029
+                            "final response while these verification repair targets remain."
1030
+                        )
1031
+                    else:
1032
+                        suggestion = (
1033
+                            "finish with a final response so Loader can verify automatically, "
1034
+                            "or make one concrete edit for a specific mismatch inside "
1035
+                            f"{roots_preview} instead of more rereads."
1036
+                        )
10171037
                     return HookResult(
10181038
                         decision=HookDecision.DENY,
10191039
                         message=(
10201040
                             "[Blocked - post-build audit loop: all explicitly planned artifacts "
10211041
                             "already exist and the current output set has already been inspected "
1022
-                            "several times.] Suggestion: finish with a final response so Loader "
1023
-                            "can verify automatically, or make one concrete edit for a specific mismatch inside "
1024
-                            f"{roots_preview} instead of more rereads."
1042
+                            f"several times.] Suggestion: {suggestion}"
10251043
                         ),
10261044
                         terminal_state="blocked",
10271045
                     )
src/loader/runtime/safeguard_services.pymodified
@@ -793,6 +793,13 @@ class PreActionValidator:
793793
                     severity="block",
794794
                 )
795795
 
796
+        html_link_scope_result = self._validate_html_write_local_link_scope(
797
+            str(file_path),
798
+            str(content),
799
+        )
800
+        if not html_link_scope_result.valid:
801
+            return html_link_scope_result
802
+
796803
         html_declared_target_result = self._validate_html_declared_target_set(
797804
             str(file_path),
798805
             str(content),
@@ -1008,6 +1015,46 @@ class PreActionValidator:
10081015
             "\n".join(added_fragments),
10091016
         )
10101017
 
1018
+    def _validate_html_write_local_link_scope(
1019
+        self,
1020
+        file_path: str,
1021
+        content: str,
1022
+    ) -> ValidationResult:
1023
+        normalized = Path(file_path).expanduser()
1024
+        if normalized.suffix.lower() not in {".html", ".htm"}:
1025
+            return ValidationResult(valid=True)
1026
+
1027
+        root = (
1028
+            normalized.parent
1029
+            if normalized.name.lower() in {"index.html", "index.htm"}
1030
+            else self._resolve_html_artifact_root(normalized)
1031
+        )
1032
+        outside_missing: list[str] = []
1033
+        for href, resolved in self._collect_local_html_targets(normalized, content):
1034
+            if resolved.exists():
1035
+                continue
1036
+            if self._relative_html_target(root, resolved) is not None:
1037
+                continue
1038
+            if href not in outside_missing:
1039
+                outside_missing.append(href)
1040
+
1041
+        if not outside_missing:
1042
+            return ValidationResult(valid=True)
1043
+
1044
+        preview = ", ".join(outside_missing[:3])
1045
+        if len(outside_missing) > 3:
1046
+            preview += ", ..."
1047
+        return ValidationResult(
1048
+            valid=False,
1049
+            reason="HTML page links outside the current artifact root",
1050
+            suggestion=(
1051
+                "Keep local HTML href values inside the generated artifact root. "
1052
+                f"Missing out-of-scope href(s): {preview}. Remove the parent/outside "
1053
+                "link or replace it with an existing in-scope local target."
1054
+            ),
1055
+            severity="error",
1056
+        )
1057
+
10111058
     def _validate_html_local_asset_links(
10121059
         self,
10131060
         file_path: str,
tests/test_permissions.pymodified
@@ -1898,6 +1898,93 @@ async def test_late_reference_drift_hook_blocks_excessive_post_build_self_audits
18981898
     assert "post-build audit loop" in blocked.message
18991899
 
19001900
 
1901
+@pytest.mark.asyncio
1902
+async def test_late_reference_drift_hook_requires_edit_during_active_repair_audit_loop(
1903
+    temp_dir: Path,
1904
+) -> None:
1905
+    registry = create_default_registry(temp_dir)
1906
+    policy = build_permission_policy(
1907
+        active_mode=PermissionMode.WORKSPACE_WRITE,
1908
+        workspace_root=temp_dir,
1909
+        tool_requirements=registry.get_tool_requirements(),
1910
+    )
1911
+    dod_store = DefinitionOfDoneStore(temp_dir)
1912
+    dod = create_definition_of_done("Create a multi-file guide from a reference")
1913
+    dod.status = "in_progress"
1914
+    guide_root = temp_dir / "guide"
1915
+    chapters = guide_root / "chapters"
1916
+    chapters.mkdir(parents=True, exist_ok=True)
1917
+    index_path = guide_root / "index.html"
1918
+    intro_path = chapters / "01-introduction.html"
1919
+    config_path = chapters / "03-basic-configuration.html"
1920
+    index_path.write_text("<h1>Nginx Guide</h1>\n")
1921
+    intro_path.write_text("<h1>Introduction</h1>\n")
1922
+    config_path.write_text("<h1>Configuration</h1>\n")
1923
+    plan_path = temp_dir / "implementation.md"
1924
+    plan_path.write_text(
1925
+        "\n".join(
1926
+            [
1927
+                "# Implementation Plan",
1928
+                "",
1929
+                "## File Changes",
1930
+                f"- `{index_path}`",
1931
+                f"- `{chapters}/`",
1932
+                "",
1933
+            ]
1934
+        )
1935
+    )
1936
+    dod.implementation_plan = str(plan_path)
1937
+    dod_path = dod_store.save(dod)
1938
+    session = FakeSession(
1939
+        active_dod_path=str(dod_path),
1940
+        messages=[
1941
+            Message(
1942
+                role=Role.USER,
1943
+                content=(
1944
+                    "Repair focus:\n"
1945
+                    f"- Improve `{index_path}`: insufficient structured content.\n"
1946
+                    f"- Improve `{intro_path}`: insufficient structured content.\n"
1947
+                    f"- Improve `{config_path}`: thin content.\n"
1948
+                    f"- Immediate next step: edit `{index_path}` with a substantial expansion.\n"
1949
+                ),
1950
+            )
1951
+        ],
1952
+    )
1953
+    hook = LateReferenceDriftHook(
1954
+        dod_store=dod_store,
1955
+        project_root=temp_dir,
1956
+        session=session,
1957
+    )
1958
+
1959
+    def make_context(index: int) -> HookContext:
1960
+        return HookContext(
1961
+            tool_call=ToolCall(
1962
+                id=f"read-{index}",
1963
+                name="read",
1964
+                arguments={"file_path": str(index_path)},
1965
+            ),
1966
+            tool=registry.get("read"),
1967
+            registry=registry,
1968
+            permission_policy=policy,
1969
+            source="native",
1970
+        )
1971
+
1972
+    for index in range(1, 5):
1973
+        context = make_context(index)
1974
+        result = await hook.pre_tool_use(context)
1975
+        assert result.decision == HookDecision.CONTINUE
1976
+        await hook.post_tool_use(context)
1977
+
1978
+    blocked = await hook.pre_tool_use(make_context(5))
1979
+
1980
+    assert blocked.decision == HookDecision.DENY
1981
+    assert blocked.message is not None
1982
+    assert "post-build audit loop" in blocked.message
1983
+    assert "make one concrete edit, patch, or write" in blocked.message
1984
+    assert "Do not finish with a final response" in blocked.message
1985
+    assert str(index_path.resolve(strict=False)) in blocked.message
1986
+
1987
+
19011988
 @pytest.mark.asyncio
19021989
 async def test_late_reference_drift_hook_allows_post_build_self_audits_during_verification(
19031990
     temp_dir: Path,
tests/test_safeguard_services.pymodified
@@ -461,6 +461,50 @@ def test_pre_action_validator_allows_existing_local_html_asset_href(
461461
     assert result.valid is True
462462
 
463463
 
464
+def test_pre_action_validator_blocks_new_root_index_parent_html_link(
465
+    tmp_path: Path,
466
+) -> None:
467
+    validator = PreActionValidator()
468
+    guide = tmp_path / "guides" / "nginx"
469
+    guide.mkdir(parents=True)
470
+
471
+    result = validator.validate(
472
+        "write",
473
+        {
474
+            "file_path": str(guide / "index.html"),
475
+            "content": (
476
+                '<html><body><a href="chapters/01-introduction.html">Intro</a>'
477
+                '<p><a href="../index.html">Back to Main Index</a></p></body></html>'
478
+            ),
479
+        },
480
+    )
481
+
482
+    assert result.valid is False
483
+    assert result.reason == "HTML page links outside the current artifact root"
484
+    assert "../index.html" in result.suggestion
485
+
486
+
487
+def test_pre_action_validator_allows_new_root_index_to_seed_child_html_links(
488
+    tmp_path: Path,
489
+) -> None:
490
+    validator = PreActionValidator()
491
+    guide = tmp_path / "guides" / "nginx"
492
+    guide.mkdir(parents=True)
493
+
494
+    result = validator.validate(
495
+        "write",
496
+        {
497
+            "file_path": str(guide / "index.html"),
498
+            "content": (
499
+                '<html><body><a href="chapters/01-introduction.html">Intro</a>'
500
+                '<a href="chapters/02-installation.html">Install</a></body></html>'
501
+            ),
502
+        },
503
+    )
504
+
505
+    assert result.valid is True
506
+
507
+
464508
 def test_pre_action_validator_blocks_shell_text_rewrite_for_html_target() -> None:
465509
     validator = PreActionValidator()
466510