Block post-build reference drift
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
b210a18331eb22c802b9e9cbae3401d322d96b79- Parents
-
3666782 - Tree
7de00c7
b210a18
b210a18331eb22c802b9e9cbae3401d322d96b793666782
7de00c7| Status | File | + | - |
|---|---|---|---|
| M |
src/loader/runtime/hooks.py
|
44 | 2 |
| M |
tests/test_permissions.py
|
63 | 0 |
src/loader/runtime/hooks.pymodified@@ -14,7 +14,6 @@ from ..tools.base import Tool, ToolRegistry | |||
| 14 | from ..tools.base import ToolResult as RegistryToolResult | 14 | from ..tools.base import ToolResult as RegistryToolResult |
| 15 | from .dod import ( | 15 | from .dod import ( |
| 16 | DefinitionOfDoneStore, | 16 | DefinitionOfDoneStore, |
| 17 | - all_planned_artifacts_exist, | ||
| 18 | collect_missing_declared_html_output_files, | 17 | collect_missing_declared_html_output_files, |
| 19 | collect_planned_artifact_targets, | 18 | collect_planned_artifact_targets, |
| 20 | planned_artifact_target_satisfied, | 19 | planned_artifact_target_satisfied, |
@@ -667,6 +666,41 @@ def _next_missing_repair_target(repair: Any) -> str: | |||
| 667 | return "" | 666 | return "" |
| 668 | 667 | ||
| 669 | 668 | ||
| 669 | +def _planned_artifact_targets_satisfied(dod: Any, *, project_root: Path) -> bool: | ||
| 670 | + targets = collect_planned_artifact_targets( | ||
| 671 | + dod, | ||
| 672 | + project_root=project_root, | ||
| 673 | + ) | ||
| 674 | + if not targets: | ||
| 675 | + return False | ||
| 676 | + return all( | ||
| 677 | + planned_artifact_target_satisfied( | ||
| 678 | + dod, | ||
| 679 | + target=target, | ||
| 680 | + expect_directory=expect_directory, | ||
| 681 | + project_root=project_root, | ||
| 682 | + ) | ||
| 683 | + for target, expect_directory in targets | ||
| 684 | + ) | ||
| 685 | + | ||
| 686 | + | ||
| 687 | +def _planned_artifact_targets_declare_missing_html_outputs( | ||
| 688 | + dod: Any, | ||
| 689 | + *, | ||
| 690 | + project_root: Path, | ||
| 691 | +) -> bool: | ||
| 692 | + for target, _expect_directory in collect_planned_artifact_targets( | ||
| 693 | + dod, | ||
| 694 | + project_root=project_root, | ||
| 695 | + ): | ||
| 696 | + if collect_missing_declared_html_output_files( | ||
| 697 | + target=target, | ||
| 698 | + project_root=project_root, | ||
| 699 | + ): | ||
| 700 | + return True | ||
| 701 | + return False | ||
| 702 | + | ||
| 703 | + | ||
| 670 | class ActiveRepairScopeHook(BaseToolHook): | 704 | class ActiveRepairScopeHook(BaseToolHook): |
| 671 | """Keep fix-mode observations anchored to the active artifact set.""" | 705 | """Keep fix-mode observations anchored to the active artifact set.""" |
| 672 | 706 | ||
@@ -1148,7 +1182,15 @@ class LateReferenceDriftHook(BaseToolHook): | |||
| 1148 | ) | 1182 | ) |
| 1149 | if not planned_targets: | 1183 | if not planned_targets: |
| 1150 | return None | 1184 | return None |
| 1151 | - if not all_planned_artifacts_exist(dod, project_root=self.project_root): | 1185 | + if not _planned_artifact_targets_satisfied( |
| 1186 | + dod, | ||
| 1187 | + project_root=self.project_root, | ||
| 1188 | + ): | ||
| 1189 | + return None | ||
| 1190 | + if _planned_artifact_targets_declare_missing_html_outputs( | ||
| 1191 | + dod, | ||
| 1192 | + project_root=self.project_root, | ||
| 1193 | + ): | ||
| 1152 | return None | 1194 | return None |
| 1153 | 1195 | ||
| 1154 | planned_roots: list[str] = [] | 1196 | planned_roots: list[str] = [] |
tests/test_permissions.pymodified@@ -1706,6 +1706,69 @@ async def test_late_reference_drift_hook_blocks_reference_reads_after_artifacts_ | |||
| 1706 | assert str(temp_dir / "guide") in result.message | 1706 | assert str(temp_dir / "guide") in result.message |
| 1707 | 1707 | ||
| 1708 | 1708 | ||
| 1709 | +@pytest.mark.asyncio | ||
| 1710 | +async def test_late_reference_drift_hook_blocks_reference_reads_when_outputs_exist_but_need_quality( | ||
| 1711 | + temp_dir: Path, | ||
| 1712 | +) -> None: | ||
| 1713 | + registry = create_default_registry(temp_dir) | ||
| 1714 | + policy = build_permission_policy( | ||
| 1715 | + active_mode=PermissionMode.WORKSPACE_WRITE, | ||
| 1716 | + workspace_root=temp_dir, | ||
| 1717 | + tool_requirements=registry.get_tool_requirements(), | ||
| 1718 | + ) | ||
| 1719 | + dod_store = DefinitionOfDoneStore(temp_dir) | ||
| 1720 | + dod = create_definition_of_done("Create an equally thorough multi-page HTML guide.") | ||
| 1721 | + dod.status = "in_progress" | ||
| 1722 | + dod.pending_items.append("Improve generated guide depth and formatting") | ||
| 1723 | + plan_path = temp_dir / "implementation.md" | ||
| 1724 | + plan_path.write_text( | ||
| 1725 | + "\n".join( | ||
| 1726 | + [ | ||
| 1727 | + "# Implementation Plan", | ||
| 1728 | + "", | ||
| 1729 | + "## File Changes", | ||
| 1730 | + f"- `{temp_dir / 'guide' / 'index.html'}`", | ||
| 1731 | + f"- `{temp_dir / 'guide' / 'chapters'}/`", | ||
| 1732 | + f"- `{temp_dir / 'guide' / 'chapters' / '01-getting-started.html'}`", | ||
| 1733 | + "", | ||
| 1734 | + ] | ||
| 1735 | + ) | ||
| 1736 | + ) | ||
| 1737 | + dod.implementation_plan = str(plan_path) | ||
| 1738 | + guide_dir = temp_dir / "guide" / "chapters" | ||
| 1739 | + guide_dir.mkdir(parents=True, exist_ok=True) | ||
| 1740 | + (temp_dir / "guide" / "index.html").write_text( | ||
| 1741 | + '<h1>Guide</h1><a href="chapters/01-getting-started.html">One</a>\n' | ||
| 1742 | + ) | ||
| 1743 | + (guide_dir / "01-getting-started.html").write_text("<h1>One</h1><p>thin</p>\n") | ||
| 1744 | + dod_path = dod_store.save(dod) | ||
| 1745 | + session = FakeSession(active_dod_path=str(dod_path), messages=[]) | ||
| 1746 | + hook = LateReferenceDriftHook( | ||
| 1747 | + dod_store=dod_store, | ||
| 1748 | + project_root=temp_dir, | ||
| 1749 | + session=session, | ||
| 1750 | + ) | ||
| 1751 | + | ||
| 1752 | + result = await hook.pre_tool_use( | ||
| 1753 | + HookContext( | ||
| 1754 | + tool_call=ToolCall( | ||
| 1755 | + id="read-reference", | ||
| 1756 | + name="read", | ||
| 1757 | + arguments={"file_path": str(temp_dir / "reference" / "index.html")}, | ||
| 1758 | + ), | ||
| 1759 | + tool=registry.get("read"), | ||
| 1760 | + registry=registry, | ||
| 1761 | + permission_policy=policy, | ||
| 1762 | + source="native", | ||
| 1763 | + ) | ||
| 1764 | + ) | ||
| 1765 | + | ||
| 1766 | + assert result.decision == HookDecision.DENY | ||
| 1767 | + assert result.message is not None | ||
| 1768 | + assert "completed artifact set scope" in result.message | ||
| 1769 | + assert str(temp_dir / "guide") in result.message | ||
| 1770 | + | ||
| 1771 | + | ||
| 1709 | @pytest.mark.asyncio | 1772 | @pytest.mark.asyncio |
| 1710 | async def test_late_reference_drift_hook_blocks_verification_reference_reads_after_artifacts_exist( | 1773 | async def test_late_reference_drift_hook_blocks_verification_reference_reads_after_artifacts_exist( |
| 1711 | temp_dir: Path, | 1774 | temp_dir: Path, |