tenseleyflow/loader / 1fafa49

Browse files

Block mid-build reference drift

Authored by espadonne
SHA
1fafa4901cc6338096f6e8247f5f0d908cf37fcc
Parents
938dadc
Tree
1d6abbe

2 changed files

StatusFile+-
M src/loader/runtime/hooks.py 22 1
M tests/test_permissions.py 56 0
src/loader/runtime/hooks.pymodified
@@ -819,6 +819,15 @@ class LateReferenceDriftHook(BaseToolHook):
819819
 
820820
     _MIN_COMPLETED_FILES = 3
821821
     _MAX_COMPLETED_SCOPE_OBSERVATIONS = 4
822
+    _REFERENCE_STUDY_HINTS = (
823
+        "examine",
824
+        "inspect",
825
+        "study",
826
+        "cadence",
827
+        "format",
828
+        "structure",
829
+        "reference",
830
+    )
822831
 
823832
     def __init__(self, *, dod_store: DefinitionOfDoneStore, project_root: Path, session: Any) -> None:
824833
         self.dod_store = dod_store
@@ -943,10 +952,22 @@ class LateReferenceDriftHook(BaseToolHook):
943952
 
944953
         if not missing_label:
945954
             return None
946
-        if completed_files < self._MIN_COMPLETED_FILES:
955
+        minimum_completed_files = self._MIN_COMPLETED_FILES
956
+        if completed_files >= 1 and self._reference_study_completed(dod):
957
+            minimum_completed_files = 1
958
+        if completed_files < minimum_completed_files:
947959
             return None
948960
         return missing_label, tuple(planned_roots)
949961
 
962
+    def _reference_study_completed(self, dod) -> bool:
963
+        for item in dod.completed_items:
964
+            text = str(item).strip().lower()
965
+            if not text:
966
+                continue
967
+            if any(hint in text for hint in self._REFERENCE_STUDY_HINTS):
968
+                return True
969
+        return False
970
+
950971
     async def post_tool_use(self, context: HookContext) -> HookResult:
951972
         if context.tool_call.name in _MUTATION_TOOLS:
952973
             self._reset_completed_scope_state()
tests/test_permissions.pymodified
@@ -1440,6 +1440,62 @@ async def test_late_reference_drift_hook_allows_reads_inside_planned_artifact_se
14401440
     assert result.decision == HookDecision.CONTINUE
14411441
 
14421442
 
1443
+@pytest.mark.asyncio
1444
+async def test_late_reference_drift_hook_blocks_reference_reopen_after_study_and_first_output(
1445
+    temp_dir: Path,
1446
+) -> None:
1447
+    registry = create_default_registry(temp_dir)
1448
+    policy = build_permission_policy(
1449
+        active_mode=PermissionMode.WORKSPACE_WRITE,
1450
+        workspace_root=temp_dir,
1451
+        tool_requirements=registry.get_tool_requirements(),
1452
+    )
1453
+    dod_store = DefinitionOfDoneStore(temp_dir)
1454
+    dod = create_definition_of_done("Create a multi-file guide from a reference")
1455
+    dod.status = "in_progress"
1456
+    dod.completed_items = [
1457
+        "First, examine the existing reference guide structure to understand the format and cadence",
1458
+    ]
1459
+    plan_path = temp_dir / "implementation.md"
1460
+    plan_path.write_text(
1461
+        "# File Changes\n"
1462
+        "- `guide/index.html`\n"
1463
+        "- `guide/chapters/01-getting-started.html`\n"
1464
+        "- `guide/chapters/02-installation.html`\n"
1465
+    )
1466
+    dod.implementation_plan = str(plan_path)
1467
+    guide_dir = temp_dir / "guide" / "chapters"
1468
+    guide_dir.mkdir(parents=True, exist_ok=True)
1469
+    (temp_dir / "guide" / "index.html").write_text("index")
1470
+    dod_path = dod_store.save(dod)
1471
+    session = FakeSession(active_dod_path=str(dod_path), messages=[])
1472
+    hook = LateReferenceDriftHook(
1473
+        dod_store=dod_store,
1474
+        project_root=temp_dir,
1475
+        session=session,
1476
+    )
1477
+
1478
+    result = await hook.pre_tool_use(
1479
+        HookContext(
1480
+            tool_call=ToolCall(
1481
+                id="read-reference",
1482
+                name="read",
1483
+                arguments={"file_path": str(temp_dir / "reference" / "index.html")},
1484
+            ),
1485
+            tool=registry.get("read"),
1486
+            registry=registry,
1487
+            permission_policy=policy,
1488
+            source="native",
1489
+        )
1490
+    )
1491
+
1492
+    assert result.decision == HookDecision.DENY
1493
+    assert result.terminal_state == "blocked"
1494
+    assert result.message is not None
1495
+    assert "late reference drift" in result.message
1496
+    assert "01-getting-started.html" in result.message
1497
+
1498
+
14431499
 @pytest.mark.asyncio
14441500
 async def test_late_reference_drift_hook_blocks_reference_reads_after_artifacts_exist(
14451501
     temp_dir: Path,