tenseleyflow/loader / 85c1cee

Browse files

Block reads of missing planned outputs

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
85c1cee81a40fec440b3b5c6b6bf1a9298b2a9e6
Parents
da96452
Tree
d07364e

2 changed files

StatusFile+-
M src/loader/runtime/hooks.py 136 0
M tests/test_permissions.py 78 0
src/loader/runtime/hooks.pymodified
@@ -20,6 +20,7 @@ from .dod import (
2020
     planned_artifact_target_satisfied,
2121
 )
2222
 from .memory import MemoryStore
23
+from .path_display import display_runtime_path
2324
 from .permissions import PermissionOverride, PermissionPolicy
2425
 from .repair_focus import (
2526
     extract_active_repair_context,
@@ -33,6 +34,7 @@ from .safeguard_services import (
3334
     PreActionValidator,
3435
     extract_shell_text_rewrite_target,
3536
 )
37
+from .workflow import infer_output_outline_label
3638
 
3739
 
3840
 class HookEvent(StrEnum):
@@ -1096,6 +1098,135 @@ class LateReferenceDriftHook(BaseToolHook):
10961098
         self._completed_scope_observation_count = 0
10971099
 
10981100
 
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
+
10991230
 class HookManager:
11001231
     """Runs tool hooks across Loader's three lifecycle events."""
11011232
 
@@ -1311,6 +1442,11 @@ def build_default_tool_hooks(
13111442
                 project_root=workspace_root,
13121443
                 session=session,
13131444
             ),
1445
+            MissingPlannedOutputReadHook(
1446
+                dod_store=DefinitionOfDoneStore(workspace_root),
1447
+                project_root=workspace_root,
1448
+                session=session,
1449
+            ),
13141450
             DuplicateActionHook(action_tracker),
13151451
             ActionValidationHook(validator),
13161452
             RollbackTrackingHook(registry, rollback_plan),
tests/test_permissions.pymodified
@@ -19,6 +19,7 @@ from loader.runtime.hooks import (
1919
     HookManager,
2020
     HookResult,
2121
     LateReferenceDriftHook,
22
+    MissingPlannedOutputReadHook,
2223
     RelativePathContextHook,
2324
     SearchPathAliasHook,
2425
 )
@@ -1855,3 +1856,80 @@ async def test_late_reference_drift_hook_does_not_block_when_html_outputs_still_
18551856
     )
18561857
 
18571858
     assert result.decision == HookDecision.CONTINUE
1859
+
1860
+
1861
+@pytest.mark.asyncio
1862
+async def test_missing_planned_output_read_hook_blocks_reads_of_declared_missing_output(
1863
+    temp_dir: Path,
1864
+) -> None:
1865
+    registry = create_default_registry(temp_dir)
1866
+    policy = build_permission_policy(
1867
+        active_mode=PermissionMode.WORKSPACE_WRITE,
1868
+        workspace_root=temp_dir,
1869
+        tool_requirements=registry.get_tool_requirements(),
1870
+    )
1871
+    dod_store = DefinitionOfDoneStore(temp_dir)
1872
+    dod = create_definition_of_done("Create a multi-file guide from a reference")
1873
+    dod.status = "in_progress"
1874
+    plan_path = temp_dir / "implementation.md"
1875
+    guide_root = temp_dir / "guide"
1876
+    chapters = guide_root / "chapters"
1877
+    plan_path.write_text(
1878
+        "\n".join(
1879
+            [
1880
+                "# Implementation Plan",
1881
+                "",
1882
+                "## File Changes",
1883
+                f"- `{guide_root / 'index.html'}`",
1884
+                f"- `{chapters}/`",
1885
+                "",
1886
+            ]
1887
+        )
1888
+    )
1889
+    dod.implementation_plan = str(plan_path)
1890
+    chapters.mkdir(parents=True, exist_ok=True)
1891
+    (guide_root / "index.html").write_text(
1892
+        "\n".join(
1893
+            [
1894
+                "<html>",
1895
+                '<a href="chapters/01-introduction.html">Chapter 1: Introduction</a>',
1896
+                '<a href="chapters/02-installation.html">Chapter 2: Installation</a>',
1897
+                '<a href="chapters/03-configuration-basics.html">Chapter 3: Configuration Basics</a>',
1898
+                "</html>",
1899
+            ]
1900
+        )
1901
+        + "\n"
1902
+    )
1903
+    (chapters / "01-introduction.html").write_text("<h1>Introduction</h1>\n")
1904
+    (chapters / "02-installation.html").write_text("<h1>Installation</h1>\n")
1905
+    dod_path = dod_store.save(dod)
1906
+    session = FakeSession(active_dod_path=str(dod_path), messages=[])
1907
+    hook = MissingPlannedOutputReadHook(
1908
+        dod_store=dod_store,
1909
+        project_root=temp_dir,
1910
+        session=session,
1911
+    )
1912
+    missing_target = chapters / "03-configuration-basics.html"
1913
+
1914
+    result = await hook.pre_tool_use(
1915
+        HookContext(
1916
+            tool_call=ToolCall(
1917
+                id="read-missing-output",
1918
+                name="read",
1919
+                arguments={"file_path": str(missing_target)},
1920
+            ),
1921
+            tool=registry.get("read"),
1922
+            registry=registry,
1923
+            permission_policy=policy,
1924
+            source="native",
1925
+        )
1926
+    )
1927
+
1928
+    assert result.decision == HookDecision.DENY
1929
+    assert result.terminal_state == "blocked"
1930
+    assert result.message is not None
1931
+    assert "missing planned output artifact" in result.message
1932
+    assert 'write(file_path="' in result.message
1933
+    assert "03-configuration-basics.html" in result.message
1934
+    assert "Chapter 3: Configuration Basics" in result.message
1935
+    assert "02-installation.html" in result.message