tenseleyflow/loader / 938dadc

Browse files

Keep setup nudges on planned roots

Authored by espadonne
SHA
938dadc9097615763022ae3104d84557df7f7782
Parents
6f92132
Tree
811c0cd

4 changed files

StatusFile+-
M src/loader/runtime/tool_batches.py 81 30
M src/loader/runtime/workflow.py 20 10
M tests/test_tool_batches.py 19 1
M tests/test_workflow.py 31 0
src/loader/runtime/tool_batches.pymodified
@@ -372,8 +372,10 @@ class ToolBatchRunner:
372372
         if next_pending:
373373
             mutation_suffix = ""
374374
             if _todo_is_mutation_step(next_pending):
375
-                mutation_suffix = _missing_artifact_resume_suffix(
376
-                    missing_artifact,
375
+                mutation_suffix = _pending_item_resume_suffix(
376
+                    dod,
377
+                    next_pending=next_pending,
378
+                    missing_artifact=missing_artifact,
377379
                     project_root=self.context.project_root,
378380
                     messages=list(getattr(self.context.session, "messages", []) or []),
379381
                 )
@@ -840,8 +842,10 @@ class ToolBatchRunner:
840842
 
841843
         mutation_suffix = ""
842844
         if _todo_is_mutation_step(next_pending):
843
-            mutation_suffix = _missing_artifact_resume_suffix(
844
-                missing_artifact,
845
+            mutation_suffix = _pending_item_resume_suffix(
846
+                dod,
847
+                next_pending=next_pending,
848
+                missing_artifact=missing_artifact,
845849
                 project_root=self.context.project_root,
846850
                 messages=list(getattr(self.context.session, "messages", []) or []),
847851
             )
@@ -1385,39 +1389,86 @@ def _missing_artifact_resume_suffix(
13851389
         return ""
13861390
 
13871391
     target, expect_directory = missing_artifact
1392
+    return _resume_suffix_for_target(
1393
+        target,
1394
+        expect_directory=expect_directory,
1395
+        project_root=project_root,
1396
+        messages=messages,
1397
+    )
1398
+
1399
+
1400
+def _pending_item_resume_suffix(
1401
+    dod: DefinitionOfDone,
1402
+    *,
1403
+    next_pending: str | None,
1404
+    missing_artifact: tuple[Path, bool] | None,
1405
+    project_root: Path,
1406
+    messages: list[Any] | None = None,
1407
+) -> str:
1408
+    if next_pending:
1409
+        pending_target = infer_pending_todo_output_target(
1410
+            dod,
1411
+            next_pending,
1412
+            project_root=project_root,
1413
+        )
1414
+        if pending_target is not None and not pending_target.exists():
1415
+            normalized_target = pending_target.expanduser().resolve(strict=False)
1416
+            return _resume_suffix_for_target(
1417
+                normalized_target,
1418
+                expect_directory=not bool(normalized_target.suffix),
1419
+                project_root=project_root,
1420
+                messages=messages,
1421
+                allow_inferred_child=False,
1422
+            )
1423
+    return _missing_artifact_resume_suffix(
1424
+        missing_artifact,
1425
+        project_root=project_root,
1426
+        messages=messages,
1427
+    )
1428
+
1429
+
1430
+def _resume_suffix_for_target(
1431
+    target: Path,
1432
+    *,
1433
+    expect_directory: bool,
1434
+    project_root: Path,
1435
+    messages: list[Any] | None = None,
1436
+    allow_inferred_child: bool = True,
1437
+) -> str:
13881438
     label = target.name or str(target)
13891439
     if expect_directory and not label.endswith("/"):
13901440
         label += "/"
13911441
     if expect_directory:
1392
-        next_output_file, next_output_source = infer_next_output_file(
1393
-            target=target,
1394
-            project_root=project_root,
1395
-            messages=list(messages or []),
1396
-        )
1397
-        if next_output_file is not None:
1398
-            guidance_origin = (
1399
-                f"It is the next missing declared output under `{label}`."
1400
-                if next_output_source == "declared"
1401
-                else (
1402
-                    "It mirrors the observed filename pattern from another "
1403
-                    f"`{label}` directory you already inspected."
1404
-                )
1405
-            )
1406
-            guidance = (
1407
-                f" Resume by creating `{next_output_file.name}` now. {guidance_origin} "
1408
-                f"Prefer one `write` call for "
1409
-                f"`{next_output_file}` instead of more rereads."
1442
+        if allow_inferred_child:
1443
+            next_output_file, next_output_source = infer_next_output_file(
1444
+                target=target,
1445
+                project_root=project_root,
1446
+                messages=list(messages or []),
14101447
             )
1411
-            if not next_output_file.parent.exists():
1448
+            if next_output_file is not None:
1449
+                guidance_origin = (
1450
+                    f"It is the next missing declared output under `{label}`."
1451
+                    if next_output_source == "declared"
1452
+                    else (
1453
+                        "It mirrors the observed filename pattern from another "
1454
+                        f"`{label}` directory you already inspected."
1455
+                    )
1456
+                )
1457
+                guidance = (
1458
+                    f" Resume by creating `{next_output_file.name}` now. {guidance_origin} "
1459
+                    f"Prefer one `write` call for "
1460
+                    f"`{next_output_file}` instead of more rereads."
1461
+                )
1462
+                if not next_output_file.parent.exists():
1463
+                    guidance += (
1464
+                        " The `write` tool can create that file's parent directories automatically,"
1465
+                        " so do the write in one step instead of stopping for a separate mkdir."
1466
+                    )
14121467
                 guidance += (
1413
-                    " The `write` tool can create that file's parent directories automatically,"
1414
-                    " so do the write in one step instead of stopping for a separate mkdir."
1468
+                    " Make your next response the concrete mutation tool call itself, not another"
1469
+                    " bookkeeping-only turn."
14151470
                 )
1416
-            guidance += (
1417
-                " Make your next response the concrete mutation tool call itself, not another"
1418
-                " bookkeeping-only turn."
1419
-            )
1420
-            return guidance
1471
+                return guidance
14211472
         if target.is_dir():
14221473
             return (
14231474
                 f" Resume by creating the next output file under `{label}` now. Prefer one "
src/loader/runtime/workflow.pymodified
@@ -927,18 +927,18 @@ def infer_pending_todo_output_target(
927927
         project_root=root,
928928
         max_paths=12,
929929
     )
930
+    planned_files = [
931
+        target
932
+        for target, expect_directory in planned_targets
933
+        if not expect_directory
934
+    ]
935
+    planned_directories = [
936
+        target
937
+        for target, expect_directory in planned_targets
938
+        if expect_directory
939
+    ]
930940
 
931941
     if candidates:
932
-        planned_files = [
933
-            target
934
-            for target, expect_directory in planned_targets
935
-            if not expect_directory
936
-        ]
937
-        planned_directories = [
938
-            target
939
-            for target, expect_directory in planned_targets
940
-            if expect_directory
941
-        ]
942942
         touched_paths = [
943943
             Path(path)
944944
             for path in dod.touched_files
@@ -979,6 +979,16 @@ def infer_pending_todo_output_target(
979979
             for directory in planned_directories:
980980
                 return directory / candidate.name
981981
 
982
+    if target_label and _contains_any(target_label, _BROAD_SETUP_HINTS):
983
+        for directory in planned_directories:
984
+            if not planned_artifact_target_satisfied(
985
+                dod=dod,
986
+                target=directory,
987
+                expect_directory=True,
988
+                project_root=root,
989
+            ):
990
+                return directory
991
+
982992
     if not target_label:
983993
         return None
984994
 
tests/test_tool_batches.pymodified
@@ -1122,6 +1122,21 @@ async def test_tool_batch_runner_queues_next_pending_todo_after_discovery_progre
11221122
     reference = temp_dir / "fortran" / "chapters" / "01-introduction.html"
11231123
     reference.parent.mkdir(parents=True)
11241124
     reference.write_text("<h1>Introduction</h1>\n<p>Guide cadence.</p>\n")
1125
+    nginx_root = temp_dir / "Loader" / "guides" / "nginx"
1126
+    chapters = nginx_root / "chapters"
1127
+    implementation_plan = temp_dir / "implementation.md"
1128
+    implementation_plan.write_text(
1129
+        "\n".join(
1130
+            [
1131
+                "# Implementation Plan",
1132
+                "",
1133
+                "## File Changes",
1134
+                f"- `{chapters}/`",
1135
+                f"- `{nginx_root / 'index.html'}`",
1136
+                "",
1137
+            ]
1138
+        )
1139
+    )
11251140
 
11261141
     context = build_context(
11271142
         temp_dir=temp_dir,
@@ -1137,6 +1152,7 @@ async def test_tool_batch_runner_queues_next_pending_todo_after_discovery_progre
11371152
     context.queue_ephemeral_steering_message_callback = ephemeral_messages.append
11381153
     runner = ToolBatchRunner(context, DefinitionOfDoneStore(temp_dir))
11391154
     dod = create_definition_of_done("Create an equally thorough nginx guide.")
1155
+    dod.implementation_plan = str(implementation_plan)
11401156
     sync_todos_to_definition_of_done(
11411157
         dod,
11421158
         [
@@ -1197,9 +1213,11 @@ async def test_tool_batch_runner_queues_next_pending_todo_after_discovery_progre
11971213
         for message in persistent_messages
11981214
     )
11991215
     assert any(
1200
-        "stop gathering more reference material and perform the change now" in message
1216
+        "Resume by creating `chapters/` now." in message
12011217
         for message in persistent_messages
12021218
     )
1219
+    assert all("01-introduction.html" not in message for message in persistent_messages)
1220
+    assert ephemeral_messages == []
12031221
 
12041222
 
12051223
 @pytest.mark.asyncio
tests/test_workflow.pymodified
@@ -18,6 +18,7 @@ from loader.runtime.workflow import (
1818
     effective_pending_todo_items,
1919
     enrich_clarify_brief_with_grounding,
2020
     extract_verification_commands_from_markdown,
21
+    infer_pending_todo_output_target,
2122
     merge_refreshed_todos_with_existing_scope,
2223
     preserve_task_grounded_acceptance_criteria,
2324
     reconcile_aggregate_completion_steps,
@@ -974,6 +975,36 @@ def test_advance_todos_from_tool_call_tracks_bash_directory_creation_progress()
974975
     assert "Create index.html for nginx guide" in dod.pending_items
975976
 
976977
 
978
+def test_infer_pending_todo_output_target_maps_broad_setup_to_planned_directory(
979
+    tmp_path: Path,
980
+) -> None:
981
+    dod = create_definition_of_done("Create a multi-file nginx guide.")
982
+    nginx_root = tmp_path / "Loader" / "guides" / "nginx"
983
+    chapters = nginx_root / "chapters"
984
+    implementation_plan = tmp_path / "implementation.md"
985
+    implementation_plan.write_text(
986
+        "\n".join(
987
+            [
988
+                "# Implementation Plan",
989
+                "",
990
+                "## File Changes",
991
+                f"- `{chapters}/`",
992
+                f"- `{nginx_root / 'index.html'}`",
993
+                "",
994
+            ]
995
+        )
996
+    )
997
+    dod.implementation_plan = str(implementation_plan)
998
+
999
+    target = infer_pending_todo_output_target(
1000
+        dod,
1001
+        "Create the nginx directory structure",
1002
+        project_root=tmp_path,
1003
+    )
1004
+
1005
+    assert target == chapters.resolve(strict=False)
1006
+
1007
+
9771008
 def test_advance_todos_from_tool_call_does_not_complete_content_study_from_root_index_read() -> None:
9781009
     dod = create_definition_of_done("Create a multi-file nginx guide.")
9791010
     sync_todos_to_definition_of_done(