Python · 134804 bytes Raw Blame History
1 """Tool-batch execution and recovery bookkeeping for the typed runtime."""
2
3 from __future__ import annotations
4
5 import re
6 import shlex
7 from collections.abc import Awaitable, Callable
8 from dataclasses import dataclass, field
9 from pathlib import Path
10 from typing import Any
11
12 from ..llm.base import Message, ToolCall
13 from .compaction import infer_preferred_next_step, summarize_confirmed_facts
14 from .context import RuntimeContext
15 from .dod import (
16 DefinitionOfDone,
17 DefinitionOfDoneStore,
18 all_planned_artifact_outputs_exist,
19 all_planned_artifacts_exist,
20 begin_new_verification_attempt,
21 collect_planned_artifact_targets,
22 derive_verification_commands,
23 ensure_active_verification_attempt,
24 infer_next_output_file,
25 is_state_mutating_tool_call,
26 planned_artifact_target_satisfied,
27 record_successful_tool_call,
28 synthesize_todo_items,
29 )
30 from .events import AgentEvent, TurnSummary
31 from .evidence_provenance import EvidenceProvenance, EvidenceProvenanceStatus
32 from .executor import ToolExecutionState, ToolExecutor
33 from .logging import get_runtime_logger
34 from .path_display import display_runtime_path
35 from .policy_timeline import append_verification_timeline_entry
36 from .recovery import RecoveryContext, detect_missing_mutation_payload
37 from .repair_focus import (
38 extract_active_repair_context,
39 path_within_allowed_roots,
40 recent_repair_mutation_context_failed,
41 )
42 from .safeguard_services import extract_shell_text_rewrite_target
43 from .tool_batch_checks import ToolBatchConfidenceGate, ToolBatchVerificationGate
44 from .tool_batch_recovery import ToolBatchRecoveryController
45 from .verification_observations import (
46 VerificationObservation,
47 VerificationObservationStatus,
48 )
49 from .workflow import (
50 advance_todos_from_tool_call,
51 effective_pending_todo_items,
52 infer_pending_todo_output_target,
53 preferred_pending_todo_item,
54 reconcile_aggregate_completion_steps,
55 sync_todos_to_definition_of_done,
56 todo_describes_aggregate_mutation,
57 todo_describes_broad_setup_step,
58 )
59
60 EventSink = Callable[[AgentEvent], Awaitable[None]]
61 ConfirmationHandler = (
62 Callable[[str, str, str, dict[str, Any] | None], Awaitable[bool]] | None
63 )
64 UserQuestionHandler = Callable[[str, list[str] | None], Awaitable[str]] | None
65
66 _VERIFY_ITEM = "Collect verification evidence"
67 _TODO_NUDGE_EXCLUDED_ITEMS = {
68 "Complete the requested work",
69 _VERIFY_ITEM,
70 }
71 _MUTATION_TODO_HINTS = (
72 "create",
73 "creating",
74 "develop",
75 "developing",
76 "populate",
77 "populating",
78 "build",
79 "building",
80 "update",
81 "updating",
82 "edit",
83 "editing",
84 "write",
85 "writing",
86 "fix",
87 "fixing",
88 "modify",
89 "modifying",
90 "change",
91 "changing",
92 "patch",
93 "patching",
94 "replace",
95 "replacing",
96 "correct",
97 "correcting",
98 "rewrite",
99 "rewriting",
100 )
101 _CONSISTENCY_REVIEW_HINTS = (
102 "consistent",
103 "consistently",
104 "formatted",
105 "link",
106 "linked",
107 "navigation",
108 "work properly",
109 "all files",
110 "every file",
111 "ensure",
112 )
113 _BOOKKEEPING_NOTE_TOOL_NAMES = {
114 "notepad_write_working",
115 "notepad_append",
116 "notepad_write_priority",
117 "notepad_write_manual",
118 }
119 _SUMMARY_ARTIFACT_NAMES = {
120 "index.html",
121 "index.htm",
122 "readme",
123 "readme.md",
124 "readme.rst",
125 "readme.txt",
126 }
127 _OBSERVATION_TOOLS = frozenset({"read", "glob", "grep", "bash"})
128 _READ_ONLY_BASH_PREFIXES = frozenset(
129 {"ls", "pwd", "find", "stat", "cat", "head", "tail", "rg", "grep"}
130 )
131 _MUTATING_BASH_FRAGMENTS = (
132 " >",
133 ">>",
134 "| tee",
135 "touch ",
136 "mkdir ",
137 "rm ",
138 "mv ",
139 "cp ",
140 "sed -i",
141 "perl -pi",
142 "git add",
143 "git commit",
144 "git apply",
145 )
146
147
148 def _verification_handoff_instruction() -> str:
149 return (
150 "Finish with a final response now so Loader can run verification automatically. "
151 "Do not run more ad hoc audit commands unless you know a specific mismatch to repair."
152 )
153
154
155 @dataclass
156 class ToolBatchResult:
157 """Outcome of running one assistant-proposed tool batch."""
158
159 actions_taken: list[str] = field(default_factory=list)
160 consecutive_errors: int = 0
161 halted: bool = False
162 continue_after_batch: bool = False
163 final_response: str = ""
164
165
166 class ToolBatchRunner:
167 """Owns tool-batch execution, recovery, and post-tool bookkeeping."""
168
169 def __init__(
170 self,
171 context: RuntimeContext,
172 dod_store: DefinitionOfDoneStore,
173 *,
174 confidence_gate: ToolBatchConfidenceGate | None = None,
175 recovery_controller: ToolBatchRecoveryController | None = None,
176 verification_gate: ToolBatchVerificationGate | None = None,
177 ) -> None:
178 self.context = context
179 self.dod_store = dod_store
180 self.confidence_gate = confidence_gate or ToolBatchConfidenceGate(context)
181 self.recovery_controller = recovery_controller or ToolBatchRecoveryController(context)
182 self.verification_gate = verification_gate or ToolBatchVerificationGate(context)
183
184 async def execute_batch(
185 self,
186 *,
187 tool_calls: list[ToolCall],
188 tool_source: str,
189 pending_tool_calls_seen: set[str],
190 emit: EventSink,
191 summary: TurnSummary,
192 dod: DefinitionOfDone,
193 executor: ToolExecutor,
194 on_confirmation: ConfirmationHandler,
195 on_user_question: UserQuestionHandler,
196 emit_confirmation,
197 consecutive_errors: int,
198 ) -> ToolBatchResult:
199 """Run one assistant tool batch through the shared executor seam."""
200
201 result = ToolBatchResult(consecutive_errors=consecutive_errors)
202
203 # Pre-populate planned items for the entire batch so the todo
204 # widget shows what's coming, not just what's done.
205 planned_labels = _batch_planned_labels(tool_calls)
206 completed_labels: list[str] = []
207
208 async def _emit_batch_todos() -> None:
209 """Emit a todo update combining DoD state with batch progress."""
210 items = synthesize_todo_items(dod)
211 for label in planned_labels:
212 if label in completed_labels:
213 continue
214 # Don't duplicate items already in DoD
215 if any(item["content"] == label for item in items):
216 continue
217 items.append({"content": label, "status": "in_progress", "active_form": label})
218 if items:
219 await emit(AgentEvent(type="todo_update", todo_items=items))
220
221 await _emit_batch_todos()
222
223 for tool_call in tool_calls:
224 cfg = self.context.config.reasoning
225
226 if cfg.confidence_scoring:
227 should_skip = await self.confidence_gate.should_skip(
228 tool_call=tool_call,
229 emit=emit,
230 )
231 if should_skip:
232 continue
233
234 if await self._maybe_preempt_post_build_user_question(
235 tool_call=tool_call,
236 dod=dod,
237 emit=emit,
238 summary=summary,
239 ):
240 result.continue_after_batch = True
241 return result
242
243 if tool_call.id not in pending_tool_calls_seen:
244 await emit(
245 AgentEvent(
246 type="tool_call",
247 tool_name=tool_call.name,
248 tool_call_id=tool_call.id,
249 tool_args=tool_call.arguments,
250 phase="assistant",
251 )
252 )
253
254 result.actions_taken.append(
255 f"{tool_call.name}: {str(tool_call.arguments)[:100]}"
256 )
257
258 outcome = await executor.execute_tool_call(
259 tool_call,
260 on_confirmation=on_confirmation,
261 on_user_question=on_user_question,
262 emit_confirmation=emit_confirmation,
263 source=tool_source,
264 )
265 executed_tool_call = outcome.tool_call
266 if (
267 outcome.rollback_action is not None
268 and self.context.config.reasoning.show_rollback_plan
269 ):
270 await emit(
271 AgentEvent(
272 type="rollback",
273 content=(
274 f"Rollback tracked: {outcome.rollback_action.description}"
275 ),
276 rollback_action=outcome.rollback_action,
277 )
278 )
279
280 if (
281 outcome.state == ToolExecutionState.EXECUTED
282 and outcome.is_error
283 and self.context.config.auto_recover
284 ):
285 recovery_result = await self.recovery_controller.build_follow_up(
286 tool_call=executed_tool_call,
287 outcome=outcome,
288 emit=emit,
289 )
290 if recovery_result is not None:
291 summary.tool_result_messages.append(recovery_result)
292 self.context.session.append(recovery_result)
293 continue
294
295 if outcome.state == ToolExecutionState.EXECUTED and not outcome.is_error:
296 loop_response = await self._record_successful_execution(
297 tool_call=executed_tool_call,
298 outcome=outcome,
299 dod=dod,
300 emit=emit,
301 summary=summary,
302 )
303 # Mark this tool's label as completed and emit live progress
304 label = _tool_call_label(executed_tool_call)
305 if label:
306 completed_labels.append(label)
307 await _emit_batch_todos()
308 if loop_response is not None:
309 result.halted = True
310 result.final_response = loop_response
311 return result
312
313 if outcome.is_error:
314 if _is_recoverable_guidance_block(outcome.event_content):
315 result.consecutive_errors = 0
316 else:
317 result.consecutive_errors += 1
318 else:
319 result.consecutive_errors = 0
320
321 await emit(
322 AgentEvent(
323 type="tool_result",
324 content=outcome.event_content,
325 tool_name=executed_tool_call.name,
326 tool_call_id=outcome.tool_call.id,
327 tool_metadata=(
328 outcome.registry_result.metadata
329 if outcome.registry_result is not None
330 else None
331 ),
332 is_error=outcome.is_error,
333 phase="assistant",
334 )
335 )
336
337 # Always append tool results to the session so the model sees
338 # its own output. The verification gate may inject a correction
339 # prompt, but the original result must still be in context —
340 # otherwise the model operates blind and loops.
341 self.context.session.append(outcome.message)
342 summary.tool_result_messages.append(outcome.message)
343 if outcome.state == ToolExecutionState.DUPLICATE:
344 self._queue_duplicate_mutation_nudge(tool_call, dod=dod)
345 self._queue_duplicate_observation_nudge(tool_call, dod=dod)
346 elif outcome.state == ToolExecutionState.BLOCKED:
347 self._queue_blocked_invalid_mutation_nudge(
348 tool_call,
349 outcome.event_content,
350 dod=dod,
351 )
352 self._queue_blocked_html_declared_file_creation_nudge(
353 tool_call,
354 outcome.event_content,
355 dod=dod,
356 )
357 self._queue_blocked_html_declared_target_nudge(
358 tool_call,
359 outcome.event_content,
360 )
361 self._queue_blocked_html_missing_target_nudge(
362 tool_call,
363 outcome.event_content,
364 dod=dod,
365 )
366 self._queue_blocked_html_asset_nudge(
367 tool_call,
368 outcome.event_content,
369 )
370 self._queue_blocked_active_repair_nudge(outcome.event_content)
371 self._queue_blocked_active_repair_mutation_nudge(outcome.event_content)
372 self._queue_blocked_completed_artifact_scope_nudge(
373 outcome.event_content,
374 dod=dod,
375 )
376 self._queue_blocked_late_reference_drift_nudge(
377 outcome.event_content,
378 dod=dod,
379 )
380 self._queue_blocked_shell_rewrite_nudge(tool_call)
381 self._queue_blocked_html_edit_nudge(
382 tool_call,
383 outcome.event_content,
384 dod=dod,
385 )
386 else:
387 self._queue_active_repair_success_handoff_nudge(tool_call)
388 self._queue_post_mutation_self_audit_nudge(tool_call, dod=dod)
389 self._queue_completed_artifact_observation_handoff_nudge(
390 tool_call,
391 dod=dod,
392 )
393 completed_todowrite_response = (
394 self._completed_todowrite_verification_response(
395 tool_call=executed_tool_call,
396 dod=dod,
397 single_tool_batch=len(tool_calls) == 1,
398 )
399 )
400 if completed_todowrite_response is not None:
401 summary.final_response = completed_todowrite_response
402 await emit(
403 AgentEvent(
404 type="response",
405 content=completed_todowrite_response,
406 )
407 )
408 result.halted = True
409 result.final_response = completed_todowrite_response
410 return result
411 if self._should_preempt_for_verification_handoff(
412 tool_call=executed_tool_call,
413 dod=dod,
414 ):
415 result.continue_after_batch = True
416 return result
417
418 should_continue = await self.verification_gate.should_continue(
419 tool_call=tool_call,
420 outcome=outcome,
421 emit=emit,
422 )
423
424 rlog = get_runtime_logger()
425 rlog.tool_exec(
426 name=tool_call.name,
427 state=outcome.state.value,
428 is_error=outcome.is_error,
429 result_preview=outcome.event_content,
430 appended_to_session=True,
431 )
432 if should_continue:
433 rlog.verification_gate(tool_call.name, should_continue=True)
434 continue
435
436 if result.consecutive_errors >= 3:
437 final_response = (
438 "I ran into some issues. "
439 "Let me know if you'd like me to try a different approach."
440 )
441 summary.final_response = final_response
442 summary.failures.append("three consecutive tool errors")
443 await emit(AgentEvent(type="response", content=final_response))
444 result.halted = True
445 result.final_response = final_response
446
447 return result
448
449 async def _maybe_preempt_post_build_user_question(
450 self,
451 *,
452 tool_call: ToolCall,
453 dod: DefinitionOfDone,
454 emit: EventSink,
455 summary: TurnSummary,
456 ) -> bool:
457 if tool_call.name != "AskUserQuestion":
458 return False
459
460 pending_items = effective_pending_todo_items(dod)
461 verification_commands = dod.verification_commands or derive_verification_commands(
462 dod,
463 project_root=self.context.project_root,
464 task_statement=getattr(self.context.session, "current_task", "") or "",
465 supplement_existing=True,
466 )
467 if pending_items:
468 if not all(
469 item == _VERIFY_ITEM or _todo_is_consistency_review_step(item)
470 for item in pending_items
471 ):
472 return False
473 elif not verification_commands:
474 return False
475 guidance = (
476 "The remaining work is review/verification of the generated files. "
477 "Do not ask the user for more clarification about the reference pattern now. "
478 "Use the generated files as the source of truth and repair any concrete failures "
479 f"in those files. {_verification_handoff_instruction()}"
480 )
481 if verification_commands:
482 self.context.workflow_mode = "verify"
483 guidance += " Do not run more ad hoc audit commands."
484
485 self.context.queue_steering_message(guidance)
486 message = Message.tool_result_message(
487 tool_call_id=tool_call.id,
488 display_content=f"[Skipped - stale post-build user question] {guidance}",
489 result_content=f"[Skipped - stale post-build user question] {guidance}",
490 is_error=False,
491 )
492 await emit(
493 AgentEvent(
494 type="tool_result",
495 content=message.content,
496 tool_name=tool_call.name,
497 tool_call_id=tool_call.id,
498 is_error=False,
499 phase="assistant",
500 )
501 )
502 self.context.session.append(message)
503 summary.tool_result_messages.append(message)
504 return True
505
506 def _should_preempt_for_verification_handoff(
507 self,
508 *,
509 tool_call: ToolCall,
510 dod: DefinitionOfDone,
511 ) -> bool:
512 """Yield back to the main loop once post-build work has clearly transitioned to verify."""
513
514 if dod.status in {"fixing", "done"}:
515 return False
516 if not all_planned_artifact_outputs_exist(dod, project_root=self.context.project_root):
517 return False
518 if tool_call.name in _OBSERVATION_TOOLS:
519 return True
520
521 if self.context.workflow_mode != "verify":
522 return False
523 verification_commands = dod.verification_commands or derive_verification_commands(
524 dod,
525 project_root=self.context.project_root,
526 task_statement=getattr(self.context.session, "current_task", "") or "",
527 supplement_existing=True,
528 )
529 if not verification_commands:
530 return False
531 return tool_call.name in ({"TodoWrite"} | _BOOKKEEPING_NOTE_TOOL_NAMES)
532
533 def _completed_todowrite_verification_response(
534 self,
535 *,
536 tool_call: ToolCall,
537 dod: DefinitionOfDone,
538 single_tool_batch: bool,
539 ) -> str | None:
540 if tool_call.name != "TodoWrite" or not single_tool_batch:
541 return None
542 repair = extract_active_repair_context(self.context.session.messages)
543 if repair is not None and _repair_context_is_html_quality(repair):
544 return None
545 if not all_planned_artifact_outputs_exist(
546 dod,
547 project_root=self.context.project_root,
548 ):
549 return None
550
551 pending_items = [
552 item
553 for item in effective_pending_todo_items(
554 dod,
555 project_root=self.context.project_root,
556 )
557 if item not in _TODO_NUDGE_EXCLUDED_ITEMS
558 ]
559 if pending_items:
560 return None
561
562 verification_commands = dod.verification_commands or derive_verification_commands(
563 dod,
564 project_root=self.context.project_root,
565 task_statement=getattr(self.context.session, "current_task", "") or "",
566 supplement_existing=True,
567 )
568 if not verification_commands:
569 return None
570
571 self.context.set_workflow_mode("verify")
572 return (
573 "Todo tracking is complete; running Loader verification on the generated "
574 "files now."
575 )
576
577 def _queue_duplicate_observation_nudge(
578 self,
579 tool_call: ToolCall,
580 *,
581 dod: DefinitionOfDone,
582 ) -> None:
583 """Queue a concrete next-step nudge after duplicate observational actions."""
584
585 if tool_call.name not in {"read", "glob", "grep", "bash"}:
586 return
587
588 current_task = getattr(self.context.session, "current_task", None)
589 missing_artifact = _next_missing_planned_artifact(
590 dod,
591 project_root=self.context.project_root,
592 messages=list(getattr(self.context.session, "messages", []) or []),
593 )
594 next_pending = preferred_pending_todo_item(
595 dod,
596 project_root=self.context.project_root,
597 missing_artifact=missing_artifact,
598 )
599 confirmed_facts = summarize_confirmed_facts(
600 self.context.session.messages,
601 max_items=2,
602 )
603 edit_mismatch_target = _recent_edit_string_mismatch_target(
604 self.context.recovery_context,
605 )
606 if edit_mismatch_target and _tool_call_targets_path(tool_call, edit_mismatch_target):
607 self.context.queue_steering_message(
608 "Reuse the earlier observation instead of repeating it. "
609 f"The last edit on `{edit_mismatch_target}` failed because `old_string` "
610 "did not exactly match the current file. Use the already-read contents "
611 "as the source of truth and send one concrete mutation now: `edit` with "
612 "an exact `old_string` copied from that file, `patch`, or `write` with "
613 "the complete replacement content if the change rewrites most of the file. "
614 "Do not read the same file again first."
615 )
616 return
617 if _should_prioritize_missing_artifact(
618 dod=dod,
619 next_pending=next_pending,
620 missing_artifact=missing_artifact,
621 project_root=self.context.project_root,
622 ):
623 prefix = "Reuse the earlier observation instead of repeating it. "
624 if confirmed_facts:
625 prefix += f"Confirmed facts: {confirmed_facts}. "
626 self.context.queue_steering_message(
627 prefix
628 + "A declared output artifact is still missing."
629 + _missing_artifact_resume_suffix(
630 missing_artifact,
631 project_root=self.context.project_root,
632 messages=list(getattr(self.context.session, "messages", []) or []),
633 )
634 + " Do not switch into review or consistency-check mode until the missing artifact exists."
635 )
636 return
637 if next_pending:
638 mutation_suffix = ""
639 if _todo_is_mutation_step(next_pending):
640 mutation_suffix = _pending_item_resume_suffix(
641 dod,
642 next_pending=next_pending,
643 missing_artifact=missing_artifact,
644 project_root=self.context.project_root,
645 messages=list(getattr(self.context.session, "messages", []) or []),
646 )
647 if not mutation_suffix:
648 mutation_suffix = (
649 " You already have enough evidence for that step, so stop gathering "
650 "more reference material and perform the change now."
651 )
652 if confirmed_facts:
653 self.context.queue_steering_message(
654 "Reuse the earlier observation instead of repeating it. "
655 f"Confirmed facts: {confirmed_facts}. "
656 f"Continue with the next pending item: `{next_pending}`. "
657 "Only gather more evidence if a specific fact required for that step is still unknown."
658 + mutation_suffix
659 )
660 else:
661 self.context.queue_steering_message(
662 "Reuse the earlier observation instead of repeating it. "
663 f"Continue with the next pending item: `{next_pending}`. "
664 "Only gather more evidence if a specific fact required for that step is still unknown."
665 + mutation_suffix
666 )
667 return
668
669 if missing_artifact is not None:
670 self.context.queue_steering_message(
671 "Reuse the earlier observation instead of repeating it. "
672 + _missing_artifact_resume_suffix(
673 missing_artifact,
674 project_root=self.context.project_root,
675 messages=list(getattr(self.context.session, "messages", []) or []),
676 ).strip()
677 )
678 return
679
680 if all_planned_artifact_outputs_exist(dod, project_root=self.context.project_root):
681 verification_commands = dod.verification_commands or derive_verification_commands(
682 dod,
683 project_root=self.context.project_root,
684 task_statement=current_task,
685 supplement_existing=True,
686 )
687 verification_suffix = (
688 _verification_handoff_instruction()
689 if verification_commands
690 else "Finish the current review using the files already on disk."
691 )
692 self.context.queue_steering_message(
693 "Reuse the earlier observation instead of repeating it. "
694 "All explicitly planned artifacts already exist on disk. "
695 "Use the current task artifacts as the source of truth and do not reopen "
696 "reference materials unless one specific gap is still unknown. "
697 "If anything is still wrong, repair the current files instead of expanding the artifact set. "
698 + verification_suffix
699 )
700 return
701
702 preferred_next_step = infer_preferred_next_step(
703 self.context.session.messages,
704 current_task=current_task,
705 )
706 if preferred_next_step and confirmed_facts:
707 self.context.queue_steering_message(
708 "Reuse the earlier observation instead of repeating it. "
709 f"Confirmed facts: {confirmed_facts}. "
710 f"{preferred_next_step} "
711 "Only gather more evidence if a specific filename, href, or title is still unknown."
712 )
713 return
714
715 if preferred_next_step:
716 self.context.queue_steering_message(
717 "Reuse the earlier observation instead of repeating it. "
718 f"{preferred_next_step} "
719 "Only gather more evidence if a specific filename, href, or title is still unknown."
720 )
721 return
722
723 target_path = str(
724 tool_call.arguments.get("file_path")
725 or tool_call.arguments.get("path")
726 or ""
727 ).strip()
728 if target_path:
729 self.context.queue_steering_message(
730 "Reuse the earlier observation instead of repeating it. "
731 f"Use the current contents of `{target_path}` and take a different next step. "
732 "Only gather more evidence if a specific filename, href, or title is still unknown."
733 )
734 return
735
736 self.context.queue_steering_message(
737 "Reuse the earlier observation instead of repeating it. "
738 "Choose a different next step that makes progress."
739 )
740
741 def _queue_duplicate_mutation_nudge(
742 self,
743 tool_call: ToolCall,
744 *,
745 dod: DefinitionOfDone,
746 ) -> None:
747 """After a duplicate mutation, restate concrete repair deltas."""
748
749 if tool_call.name not in {"write", "edit", "patch"}:
750 return
751
752 target = str(
753 tool_call.arguments.get("file_path")
754 or tool_call.arguments.get("path")
755 or ""
756 ).strip()
757 repair = extract_active_repair_context(self.context.session.messages)
758 if repair is not None:
759 repair_preview = _active_repair_focus_preview(repair.repair_lines)
760 target_label = f"`{target}`" if target else "that file"
761 self.context.queue_steering_message(
762 f"That {tool_call.name} was skipped because it would not change {target_label}. "
763 "Do not submit the same content again. "
764 f"Verification still requires these concrete repair deltas: {repair_preview} "
765 "Use the current generated file as the source of truth and make one real edit, "
766 "patch, or write that expands or changes the flagged artifact."
767 )
768 return
769
770 if all_planned_artifact_outputs_exist(dod, project_root=self.context.project_root):
771 target_label = f"`{target}`" if target else "the target file"
772 self.context.queue_steering_message(
773 f"That {tool_call.name} was skipped because it would not change {target_label}. "
774 "All explicitly planned artifacts already exist, so do not rewrite the same content. "
775 "If verification identified a mismatch, make a different concrete mutation that fixes it; "
776 "otherwise finish so Loader can verify the files already on disk."
777 )
778
779 def _queue_post_mutation_self_audit_nudge(
780 self,
781 tool_call: ToolCall,
782 *,
783 dod: DefinitionOfDone,
784 ) -> None:
785 """Steer out of rereading the file that was just written when the next output is known."""
786
787 if tool_call.name != "read":
788 return
789
790 file_path = str(tool_call.arguments.get("file_path", "")).strip()
791 if not file_path:
792 return
793
794 missing_artifact = _next_missing_planned_artifact(
795 dod,
796 project_root=self.context.project_root,
797 messages=list(getattr(self.context.session, "messages", []) or []),
798 )
799 if missing_artifact is None:
800 return
801
802 read_target = Path(file_path).expanduser().resolve(strict=False)
803 last_touched = _last_touched_file_path(dod)
804 if last_touched is None or read_target != last_touched:
805 return
806
807 self.context.queue_steering_message(
808 f"You already have the current contents of `{read_target.name}` from the successful write. "
809 "A declared output artifact is still missing."
810 + _missing_artifact_resume_suffix(
811 missing_artifact,
812 project_root=self.context.project_root,
813 messages=list(getattr(self.context.session, "messages", []) or []),
814 )
815 + " Do not spend another turn rereading the file you just wrote or on TodoWrite alone."
816 )
817
818 def _queue_completed_artifact_observation_handoff_nudge(
819 self,
820 tool_call: ToolCall,
821 *,
822 dod: DefinitionOfDone,
823 ) -> None:
824 """Turn successful post-build audit reads into verify/finalize handoffs."""
825
826 if tool_call.name not in _OBSERVATION_TOOLS:
827 return
828 if dod.status in {"fixing", "done"}:
829 return
830 if extract_active_repair_context(self.context.session.messages) is not None:
831 return
832 if not all_planned_artifact_outputs_exist(dod, project_root=self.context.project_root):
833 return
834
835 observed_paths = _extract_observation_paths(tool_call)
836 if not observed_paths:
837 return
838
839 planned_roots = _planned_output_roots(
840 dod,
841 project_root=self.context.project_root,
842 )
843 if not planned_roots:
844 return
845 if not all(path_within_allowed_roots(path, planned_roots) for path in observed_paths):
846 return
847
848 next_pending = preferred_pending_todo_item(
849 dod,
850 project_root=self.context.project_root,
851 )
852 verification_commands = dod.verification_commands or derive_verification_commands(
853 dod,
854 project_root=self.context.project_root,
855 task_statement=getattr(self.context.session, "current_task", "") or "",
856 supplement_existing=True,
857 )
858 roots_preview = ", ".join(f"`{root}`" for root in planned_roots[:2])
859 if len(planned_roots) > 2:
860 roots_preview += ", ..."
861
862 if next_pending and _todo_is_consistency_review_step(next_pending):
863 verification_suffix = (
864 " If no specific mismatch remains, finish with a final response so Loader can verify."
865 if verification_commands
866 else " If no specific mismatch remains, finish the task now."
867 )
868 self.context.queue_ephemeral_steering_message(
869 "All explicitly planned artifacts already exist. "
870 f"Continue with `{next_pending}` using the generated files under {roots_preview} "
871 "as the source of truth, but do not keep broad-rereading the output set. "
872 "If you already know a concrete mismatch, fix it directly."
873 + verification_suffix
874 )
875 return
876
877 if verification_commands:
878 self.context.set_workflow_mode("verify")
879 self.context.queue_steering_message(
880 "All explicitly planned artifacts already exist. "
881 f"Use the generated files under {roots_preview} as the source of truth and stop broad rereads. "
882 "If you already know a concrete mismatch, fix it directly. "
883 f"{_verification_handoff_instruction()} Do not reopen reference materials "
884 "or keep auditing the same files."
885 )
886 return
887
888 verification_suffix = (
889 _verification_handoff_instruction()
890 if verification_commands
891 else "Finish the task using the files already on disk."
892 )
893 self.context.queue_ephemeral_steering_message(
894 "All explicitly planned artifacts already exist. "
895 f"Use the generated files under {roots_preview} as the source of truth and stop broad rereads. "
896 "If you already know a concrete mismatch, fix it directly. "
897 + verification_suffix
898 )
899
900 def _queue_blocked_shell_rewrite_nudge(self, tool_call: ToolCall) -> None:
901 """Steer the model back to file tools after a blocked shell text rewrite."""
902
903 if tool_call.name != "bash":
904 return
905
906 target = extract_shell_text_rewrite_target(
907 str(tool_call.arguments.get("command", ""))
908 )
909 if target is None:
910 return
911
912 current_task = getattr(self.context.session, "current_task", None)
913 confirmed_facts = summarize_confirmed_facts(
914 self.context.session.messages,
915 max_items=2,
916 )
917 preferred_next_step = infer_preferred_next_step(
918 self.context.session.messages,
919 current_task=current_task,
920 )
921
922 if preferred_next_step and confirmed_facts:
923 self.context.queue_steering_message(
924 "Use Loader's file tools for this text edit instead of a shell rewrite. "
925 f"Confirmed facts: {confirmed_facts}. "
926 f"{preferred_next_step} "
927 f"Target `{target}` with edit/patch/write rather than `bash`."
928 )
929 return
930
931 self.context.queue_steering_message(
932 "Use Loader's file tools for this text edit instead of a shell rewrite. "
933 f"Apply the change to `{target}` with edit/patch/write."
934 )
935
936 def _queue_blocked_active_repair_nudge(self, event_content: str) -> None:
937 """Reinforce active repair focus after an out-of-scope blocked observation."""
938
939 if "[Blocked - active repair scope:" not in event_content:
940 return
941
942 repair = extract_active_repair_context(self.context.session.messages)
943 if repair is None:
944 return
945
946 if repair.allowed_paths:
947 allowed_preview = ", ".join(f"`{path}`" for path in repair.allowed_paths[:3])
948 if len(repair.allowed_paths) > 3:
949 allowed_preview += ", ..."
950 self.context.queue_steering_message(
951 "Verification already identified the active repair target. "
952 f"Stay on the concrete repair files {allowed_preview} "
953 f"and repair `{repair.artifact_path}` directly. "
954 "Do not reopen unrelated reference materials while this repair target is unresolved."
955 )
956 return
957
958 roots_preview = ", ".join(f"`{root}`" for root in repair.allowed_roots[:2])
959 if len(repair.allowed_roots) > 2:
960 roots_preview += ", ..."
961 self.context.queue_steering_message(
962 "Verification already identified the active repair target. "
963 f"Stay within the current artifact set under {roots_preview} "
964 f"and repair `{repair.artifact_path}` directly. "
965 "Do not reopen unrelated reference materials while this repair target is unresolved."
966 )
967
968 def _queue_blocked_active_repair_mutation_nudge(self, event_content: str) -> None:
969 """Keep repair-phase mutations pinned to the named repair files."""
970
971 if "[Blocked - active repair mutation scope:" not in event_content:
972 return
973
974 repair = extract_active_repair_context(self.context.session.messages)
975 if repair is None or not repair.allowed_paths:
976 return
977
978 allowed_preview = ", ".join(f"`{path}`" for path in repair.allowed_paths[:3])
979 if len(repair.allowed_paths) > 3:
980 allowed_preview += ", ..."
981 self.context.queue_steering_message(
982 "Verification already identified the concrete repair files. "
983 f"Keep mutations pinned to {allowed_preview} "
984 f"and repair `{repair.artifact_path}` before widening the change set."
985 )
986
987 def _queue_active_repair_success_handoff_nudge(self, tool_call: ToolCall) -> None:
988 """After a repair mutation, hand back to finalization instead of ad hoc edits."""
989
990 if tool_call.name not in {"write", "edit", "patch"}:
991 return
992 raw_path = str(tool_call.arguments.get("file_path", "")).strip()
993 if not raw_path:
994 return
995
996 repair = extract_active_repair_context(self.context.session.messages)
997 if repair is None or not repair.allowed_paths:
998 return
999
1000 try:
1001 changed_path = str(Path(raw_path).expanduser().resolve(strict=False))
1002 except (OSError, RuntimeError, ValueError):
1003 changed_path = str(Path(raw_path).expanduser())
1004 allowed_paths = {
1005 str(Path(path).expanduser().resolve(strict=False))
1006 for path in repair.allowed_paths
1007 }
1008 if changed_path not in allowed_paths:
1009 return
1010
1011 if _repair_context_is_html_quality(repair):
1012 next_target = _next_quality_repair_path(
1013 repair,
1014 changed_path=changed_path,
1015 )
1016 if next_target:
1017 repair_issue = _quality_repair_issue_for_target(repair, next_target)
1018 issue_line = (
1019 f"- {repair_issue}\n"
1020 if repair_issue
1021 else f"- Improve `{next_target}` until it satisfies the active content-quality verifier.\n"
1022 )
1023 self.context.queue_steering_message(
1024 "The active HTML content-quality repair target was updated. "
1025 "If the current file now comfortably clears its stated threshold, "
1026 f"continue directly with the next listed quality target `{next_target}` "
1027 "using one substantial write/edit/patch anchored to current content. "
1028 "If it still looks thin, expand this same file further now. "
1029 "Do not rerun verification, reopen unrelated references, or summarize "
1030 "completion after only one quality target.\n\n"
1031 "Repair focus:\n"
1032 f"{issue_line}"
1033 f"- Immediate next step: edit `{next_target}`.\n"
1034 "- Continue with one concrete `edit`, `patch`, or `write` call that "
1035 "actually changes the current generated file."
1036 )
1037 return
1038
1039 if changed_path == str(Path(repair.artifact_path).expanduser().resolve(strict=False)):
1040 self.context.queue_steering_message(
1041 "The active verification repair target was updated. "
1042 "Do not keep auditing or retarget nearby links by guesswork. "
1043 "Finish with a final response now so Loader can re-run verification."
1044 )
1045 return
1046
1047 self.context.queue_steering_message(
1048 "The support file for the active verification repair now exists. "
1049 f"Do not retarget `{repair.artifact_path}` to a different missing path by guesswork. "
1050 "Finish with a final response now so Loader can re-run verification."
1051 )
1052
1053 def _queue_blocked_late_reference_drift_nudge(
1054 self,
1055 event_content: str,
1056 *,
1057 dod: DefinitionOfDone,
1058 ) -> None:
1059 """Reinforce missing-artifact progress after late-stage reference drift is blocked."""
1060
1061 if "[Blocked - late reference drift:" not in event_content:
1062 return
1063
1064 missing_artifact = _next_missing_planned_artifact(
1065 dod,
1066 project_root=self.context.project_root,
1067 messages=list(getattr(self.context.session, "messages", []) or []),
1068 )
1069 if missing_artifact is None:
1070 return
1071
1072 planned_roots: list[str] = []
1073 seen_roots: set[str] = set()
1074 for target, expect_directory in collect_planned_artifact_targets(
1075 dod,
1076 project_root=self.context.project_root,
1077 ):
1078 root = str(target if expect_directory else target.parent)
1079 if root in seen_roots:
1080 continue
1081 seen_roots.add(root)
1082 planned_roots.append(root)
1083
1084 roots_preview = ", ".join(f"`{root}`" for root in planned_roots[:2])
1085 if len(planned_roots) > 2:
1086 roots_preview += ", ..."
1087 self.context.queue_steering_message(
1088 "Late-stage reference rereads are no longer helping. "
1089 "One explicitly planned artifact is still missing."
1090 + _missing_artifact_resume_suffix(
1091 missing_artifact,
1092 project_root=self.context.project_root,
1093 messages=list(getattr(self.context.session, "messages", []) or []),
1094 )
1095 + f" Stay within the current output roots under {roots_preview}"
1096 + " and finish that artifact before reopening older reference materials."
1097 )
1098
1099 def _queue_blocked_completed_artifact_scope_nudge(
1100 self,
1101 event_content: str,
1102 *,
1103 dod: DefinitionOfDone,
1104 ) -> None:
1105 """Keep post-build review anchored to the generated artifact set."""
1106
1107 blocked_completed_scope = (
1108 "[Blocked - completed artifact set scope:" in event_content
1109 )
1110 blocked_post_build_audit = "[Blocked - post-build audit loop:" in event_content
1111 if not blocked_completed_scope and not blocked_post_build_audit:
1112 return
1113
1114 planned_roots: list[str] = []
1115 seen_roots: set[str] = set()
1116 for target, expect_directory in collect_planned_artifact_targets(
1117 dod,
1118 project_root=self.context.project_root,
1119 ):
1120 root = str(target if expect_directory else target.parent)
1121 if root in seen_roots:
1122 continue
1123 seen_roots.add(root)
1124 planned_roots.append(root)
1125
1126 next_pending = preferred_pending_todo_item(
1127 dod,
1128 project_root=self.context.project_root,
1129 )
1130 verification_commands = dod.verification_commands or derive_verification_commands(
1131 dod,
1132 project_root=self.context.project_root,
1133 task_statement=getattr(self.context.session, "current_task", "") or "",
1134 supplement_existing=True,
1135 )
1136 if verification_commands:
1137 self.context.set_workflow_mode("verify")
1138 roots_preview = ", ".join(f"`{root}`" for root in planned_roots[:2])
1139 if len(planned_roots) > 2:
1140 roots_preview += ", ..."
1141 if next_pending and _todo_is_consistency_review_step(next_pending):
1142 self.context.queue_steering_message(
1143 "All explicitly planned artifacts already exist. "
1144 f"Stay within the current output roots under {roots_preview} and continue "
1145 f"with `{next_pending}` using the generated files as the source of truth. "
1146 "Do not reopen earlier reference materials."
1147 + (
1148 " Finish with a final response so Loader can verify those generated files."
1149 if verification_commands
1150 else ""
1151 )
1152 )
1153 return
1154
1155 self.context.queue_steering_message(
1156 "All explicitly planned artifacts already exist. "
1157 f"Stay within the current output roots under {roots_preview} "
1158 "and finish with a final response so Loader can verify the generated files. "
1159 "Do not reopen earlier reference materials."
1160 )
1161
1162 def _queue_blocked_html_edit_nudge(
1163 self,
1164 tool_call: ToolCall,
1165 event_content: str,
1166 *,
1167 dod: DefinitionOfDone,
1168 ) -> None:
1169 """Keep blocked edit feedback generic; avoid task-class-specific steering."""
1170
1171 if tool_call.name != "edit":
1172 return
1173 if "old_string and new_string are identical - no change would occur" not in event_content:
1174 return
1175
1176 repair = extract_active_repair_context(self.context.session.messages)
1177 if repair is None:
1178 return
1179
1180 target = (
1181 str(tool_call.arguments.get("file_path") or "").strip() or repair.artifact_path
1182 )
1183 if not target:
1184 return
1185
1186 if _repair_context_is_html_quality(repair):
1187 repair_issue = _quality_repair_issue_for_target(repair, target)
1188 issue_line = (
1189 f"- {repair_issue}\n"
1190 if repair_issue
1191 else f"- Improve `{target}` until it satisfies the active content-quality verifier.\n"
1192 )
1193 self.context.queue_steering_message(
1194 "That edit would make no on-disk change. "
1195 f"`{target}` already matches the change you attempted, but the active "
1196 "content-quality repair is not complete until a verifier-targeted "
1197 "mutation actually changes the file. Do not mark the task complete, "
1198 "do not use TodoWrite as a substitute for repair, and do not finish yet.\n\n"
1199 "Repair focus:\n"
1200 f"{issue_line}"
1201 f"- Immediate next step: edit `{target}`.\n"
1202 "- Submit one `edit`, `patch`, or `write` call that adds substantial "
1203 "new content or structure to the current generated file."
1204 )
1205 return
1206
1207 verification_commands = dod.verification_commands or derive_verification_commands(
1208 dod,
1209 project_root=self.context.project_root,
1210 task_statement=getattr(self.context.session, "current_task", "") or "",
1211 supplement_existing=True,
1212 )
1213 if all_planned_artifacts_exist(dod, project_root=self.context.project_root):
1214 verification_suffix = (
1215 " " + _verification_handoff_instruction()
1216 if verification_commands
1217 else " If no concrete mismatch remains, stop editing and finish from the files already on disk."
1218 )
1219 self.context.queue_steering_message(
1220 "That edit would make no on-disk change. "
1221 f"`{target}` already matches the change you attempted. "
1222 "All explicitly planned artifacts already exist."
1223 + verification_suffix
1224 )
1225 return
1226
1227 self.context.queue_steering_message(
1228 "That edit would make no on-disk change. "
1229 f"Stay on `{target}` and use the current file contents as the source of truth. "
1230 "Read the exact current text you need to change, then submit one `edit`, `patch`, "
1231 "or `write` call that actually changes the file. "
1232 "If a narrow single-line edit keeps bouncing, replace the surrounding block in one "
1233 "mutation instead of retrying the same no-op edit. "
1234 "Do not reopen unrelated reference materials while this concrete repair target is unresolved."
1235 )
1236
1237 def _queue_blocked_html_declared_target_nudge(
1238 self,
1239 tool_call: ToolCall,
1240 event_content: str,
1241 ) -> None:
1242 """Steer blocked HTML graph edits back to the root-declared local targets."""
1243
1244 if tool_call.name not in {"write", "edit", "patch"}:
1245 return
1246 if "HTML page introduces new local targets outside the current declared artifact set" not in event_content:
1247 return
1248
1249 target = str(
1250 tool_call.arguments.get("file_path")
1251 or tool_call.arguments.get("path")
1252 or ""
1253 ).strip()
1254 if not target:
1255 return
1256
1257 closest_targets = _extract_blocked_html_target_list(
1258 event_content,
1259 "Closest declared local targets include:",
1260 )
1261 declared_targets = _extract_blocked_html_target_list(
1262 event_content,
1263 "Already-declared local targets include:",
1264 )
1265 allowed_hrefs = _extract_blocked_html_target_list(
1266 event_content,
1267 "Allowed hrefs from this file include:",
1268 )
1269
1270 guidance = (
1271 "That HTML mutation introduced sibling targets outside the current declared local-link set. "
1272 f"Stay on `{target}`."
1273 )
1274 if allowed_hrefs:
1275 guidance += (
1276 " Remove the invented hrefs and use only these exact href values "
1277 "from this file, or omit navigation links entirely: "
1278 + ", ".join(f"`{candidate}`" for candidate in allowed_hrefs[:6])
1279 + "."
1280 )
1281 elif closest_targets:
1282 guidance += (
1283 " Remove the invented hrefs or replace them with the closest declared target(s): "
1284 + ", ".join(f"`{candidate}`" for candidate in closest_targets[:3])
1285 + "."
1286 )
1287 elif declared_targets:
1288 guidance += (
1289 " Remove the invented hrefs or keep local links within the declared target set, for example: "
1290 + ", ".join(f"`{candidate}`" for candidate in declared_targets[:3])
1291 + "."
1292 )
1293 guidance += (
1294 " Resend one concrete mutation for that same file now instead of rereading the reference guide."
1295 )
1296 self.context.queue_steering_message(guidance)
1297
1298 def _queue_blocked_html_declared_file_creation_nudge(
1299 self,
1300 tool_call: ToolCall,
1301 event_content: str,
1302 *,
1303 dod: DefinitionOfDone,
1304 ) -> None:
1305 """Steer blocked undeclared HTML file creation back through the root guide."""
1306
1307 if tool_call.name not in {"write", "edit", "patch"}:
1308 return
1309 if "HTML file creation falls outside the current declared artifact set" not in event_content:
1310 return
1311
1312 target = str(
1313 tool_call.arguments.get("file_path")
1314 or tool_call.arguments.get("path")
1315 or ""
1316 ).strip()
1317 if not target:
1318 return
1319
1320 target_path = Path(target).expanduser().resolve(strict=False)
1321 root_index = (target_path.parent.parent / "index.html").resolve(strict=False)
1322 relative_target = None
1323 try:
1324 relative_target = target_path.relative_to(root_index.parent).as_posix()
1325 except ValueError:
1326 relative_target = target_path.name
1327 closest_targets = _extract_blocked_html_target_list(
1328 event_content,
1329 "Closest declared local targets include:",
1330 )
1331 declared_targets = _extract_blocked_html_target_list(
1332 event_content,
1333 "Already-declared local targets include:",
1334 )
1335
1336 if all_planned_artifact_outputs_exist(dod, project_root=self.context.project_root):
1337 verification_commands = dod.verification_commands or derive_verification_commands(
1338 dod,
1339 project_root=self.context.project_root,
1340 task_statement=getattr(self.context.session, "current_task", "") or "",
1341 supplement_existing=True,
1342 )
1343 verification_suffix = (
1344 " " + _verification_handoff_instruction()
1345 if verification_commands
1346 else " Finish the task using the files already on disk."
1347 )
1348 self.context.queue_steering_message(
1349 "All explicitly planned artifacts already exist on disk. "
1350 f"Do not expand the output set with `{relative_target}`. "
1351 "Use the current generated files as the source of truth and repair or verify them instead."
1352 + verification_suffix
1353 )
1354 return
1355
1356 guidance = (
1357 "That new HTML file is outside the current root-declared artifact set. "
1358 f"Do not create `{relative_target}`."
1359 )
1360 if closest_targets:
1361 guidance += (
1362 " Stay within the declared set and continue with the closest declared target instead: "
1363 + ", ".join(f"`{candidate}`" for candidate in closest_targets[:3])
1364 + "."
1365 )
1366 else:
1367 guidance += (
1368 f" Before creating `{relative_target}`, update `{root_index}` so the guide root "
1369 "explicitly links to that page, then retry the file creation."
1370 )
1371 if declared_targets:
1372 guidance += (
1373 " Already-declared local targets include: "
1374 + ", ".join(f"`{candidate}`" for candidate in declared_targets[:3])
1375 + "."
1376 )
1377 guidance += " Stay on the active guide files; do not reopen the earlier reference guide first."
1378 self.context.queue_steering_message(guidance)
1379
1380 def _queue_blocked_html_missing_target_nudge(
1381 self,
1382 tool_call: ToolCall,
1383 event_content: str,
1384 *,
1385 dod: DefinitionOfDone,
1386 ) -> None:
1387 """Turn post-build missing-link expansions into verify/repair handoffs."""
1388
1389 if tool_call.name not in {"write", "edit", "patch"}:
1390 return
1391 if "Edited HTML links point to files that do not exist" not in event_content:
1392 return
1393 if not all_planned_artifact_outputs_exist(dod, project_root=self.context.project_root):
1394 return
1395
1396 verification_commands = dod.verification_commands or derive_verification_commands(
1397 dod,
1398 project_root=self.context.project_root,
1399 task_statement=getattr(self.context.session, "current_task", "") or "",
1400 supplement_existing=True,
1401 )
1402 verification_suffix = (
1403 " " + _verification_handoff_instruction()
1404 if verification_commands
1405 else " Finish the task using the files already on disk."
1406 )
1407 target = str(
1408 tool_call.arguments.get("file_path")
1409 or tool_call.arguments.get("path")
1410 or ""
1411 ).strip()
1412 target_suffix = f" Stay on `{target}`." if target else ""
1413 self.context.queue_steering_message(
1414 "All explicitly planned artifacts already exist on disk. "
1415 + target_suffix
1416 + " "
1417 "Do not introduce new local-link targets beyond the current output set. "
1418 "Repair the existing generated files instead of expanding the guide. "
1419 "Replace broken hrefs with existing local targets or remove the broken link."
1420 + verification_suffix
1421 )
1422
1423 def _queue_blocked_html_asset_nudge(
1424 self,
1425 tool_call: ToolCall,
1426 event_content: str,
1427 ) -> None:
1428 """Make missing local asset feedback sticky for the immediate retry."""
1429
1430 if tool_call.name not in {"write", "edit", "patch"}:
1431 return
1432 if "HTML local asset references do not exist" not in event_content:
1433 return
1434
1435 target = str(
1436 tool_call.arguments.get("file_path")
1437 or tool_call.arguments.get("path")
1438 or ""
1439 ).strip()
1440 if not target:
1441 return
1442
1443 missing_assets = _extract_blocked_html_target_list(
1444 event_content,
1445 "Missing local asset href(s):",
1446 )
1447 asset_preview = ""
1448 if missing_assets:
1449 asset_preview = (
1450 " Remove or replace "
1451 + ", ".join(f"`{asset}`" for asset in missing_assets[:3])
1452 + "."
1453 )
1454
1455 repeat_count = _count_recent_blocked_html_asset_events(
1456 self.context.session.messages,
1457 missing_assets,
1458 )
1459 if repeat_count >= 2 and missing_assets:
1460 missing_preview = ", ".join(f"`{asset}`" for asset in missing_assets[:3])
1461 self.context.queue_steering_message(
1462 f"The same HTML mutation for `{target}` has now been blocked "
1463 f"{repeat_count} times because local asset href(s) {missing_preview} "
1464 "do not exist. Do not resend another `write`/`edit`/`patch` for "
1465 f"`{target}` while it still contains those hrefs. Recommended next "
1466 "action: retry the same file with the entire stylesheet `<link>` "
1467 "line removed and inline any necessary styling. Alternative: create "
1468 "the referenced asset file first, then link to it. Do not claim "
1469 "completion until this blocked file write succeeds."
1470 )
1471 return
1472
1473 self.context.queue_steering_message(
1474 f"The last HTML mutation for `{target}` was blocked, so that file was "
1475 "not created or updated. Retry the same file with one concrete "
1476 "`write`, `edit`, or `patch` call that omits missing local asset hrefs."
1477 f"{asset_preview} Inline necessary styling/content, or create the referenced "
1478 "asset before linking it. Do not resend the same `<link>` tag and do "
1479 "not claim completion until the blocked file write succeeds."
1480 )
1481
1482 def _queue_blocked_invalid_mutation_nudge(
1483 self,
1484 tool_call: ToolCall,
1485 event_content: str,
1486 *,
1487 dod: DefinitionOfDone,
1488 ) -> None:
1489 """Recover blocked mutations that omitted a real target path or text payload."""
1490
1491 fix = detect_missing_mutation_payload(
1492 tool_call.name,
1493 tool_call.arguments,
1494 event_content,
1495 )
1496 if fix is None:
1497 return
1498
1499 self._record_blocked_invalid_mutation_attempt(tool_call, event_content)
1500
1501 messages = list(getattr(self.context.session, "messages", []) or [])
1502 missing_artifact = _next_missing_planned_artifact(
1503 dod,
1504 project_root=self.context.project_root,
1505 messages=messages,
1506 )
1507 next_pending = preferred_pending_todo_item(
1508 dod,
1509 project_root=self.context.project_root,
1510 missing_artifact=missing_artifact,
1511 )
1512 missing_artifact = _prefer_missing_artifact_for_pending_item(
1513 dod,
1514 missing_artifact=missing_artifact,
1515 next_pending=next_pending,
1516 project_root=self.context.project_root,
1517 )
1518 resume_target = _preferred_resume_target_path(
1519 dod,
1520 next_pending=next_pending,
1521 missing_artifact=missing_artifact,
1522 project_root=self.context.project_root,
1523 messages=messages,
1524 )
1525 resume_suffix = _pending_item_resume_suffix(
1526 dod,
1527 next_pending=next_pending,
1528 missing_artifact=missing_artifact,
1529 project_root=self.context.project_root,
1530 messages=messages,
1531 )
1532 target_label = f"`{resume_target.name or str(resume_target)}`" if resume_target else ""
1533
1534 if fix.get("kind") == "missing_target":
1535 prefix = f"That `{tool_call.name}` call did not provide a valid `file_path`."
1536 if target_label:
1537 prefix += f" Stay on {target_label}."
1538 self.context.queue_steering_message(
1539 prefix
1540 + resume_suffix
1541 + " Resend one concrete "
1542 + _invalid_mutation_call_shape(tool_call.name)
1543 + " now instead of another working note, reread, or empty response."
1544 )
1545 return
1546
1547 invalid_fields = ", ".join(f"`{field}`" for field in fix["invalid_fields"])
1548 prefix = f"That `{tool_call.name}` call omitted the real text payload."
1549 if invalid_fields:
1550 prefix += f" {invalid_fields} are summary fields, not valid mutation inputs."
1551 if target_label:
1552 prefix += f" Stay on {target_label}."
1553 self.context.queue_steering_message(
1554 prefix
1555 + resume_suffix
1556 + " Resend one concrete "
1557 + _invalid_mutation_call_shape(tool_call.name)
1558 + " now instead of rereading more files."
1559 )
1560
1561 def _record_blocked_invalid_mutation_attempt(
1562 self,
1563 tool_call: ToolCall,
1564 error: str,
1565 ) -> None:
1566 """Seed recovery state from blocked malformed mutations for later retry guidance."""
1567
1568 recovery_context = self.context.recovery_context
1569 if recovery_context is None or not recovery_context.is_related_failure(
1570 tool_call.name,
1571 tool_call.arguments,
1572 error,
1573 ):
1574 recovery_context = RecoveryContext(
1575 original_tool=tool_call.name,
1576 original_args=tool_call.arguments,
1577 max_retries=self.context.config.max_recovery_attempts,
1578 )
1579 self.context.recovery_context = recovery_context
1580
1581 if not recovery_context.is_similar_attempt(
1582 tool_call.name,
1583 tool_call.arguments,
1584 ):
1585 recovery_context.add_attempt(
1586 tool_call.name,
1587 tool_call.arguments,
1588 error,
1589 )
1590
1591 async def _record_successful_execution(
1592 self,
1593 *,
1594 tool_call: ToolCall,
1595 outcome,
1596 dod: DefinitionOfDone,
1597 emit: EventSink,
1598 summary: TurnSummary,
1599 ) -> str | None:
1600 """Update DoD bookkeeping after a successful tool execution."""
1601
1602 is_mutating = is_state_mutating_tool_call(tool_call)
1603 previously_verified = dod.last_verification_result == "passed"
1604 record_successful_tool_call(dod, tool_call)
1605 if tool_call.name == "TodoWrite" and outcome.registry_result is not None:
1606 repair = extract_active_repair_context(self.context.session.messages)
1607 if repair is None or not _repair_context_is_html_quality(repair):
1608 new_todos = outcome.registry_result.metadata.get("new_todos", [])
1609 if isinstance(new_todos, list):
1610 sync_todos_to_definition_of_done(
1611 dod,
1612 new_todos,
1613 project_root=self.context.project_root,
1614 )
1615 self._refresh_todowrite_outcome_summary(outcome=outcome, dod=dod)
1616 self._queue_todowrite_resume_nudge(dod=dod)
1617 else:
1618 pending_before = list(dod.pending_items)
1619 if advance_todos_from_tool_call(dod, tool_call):
1620 reconcile_aggregate_completion_steps(
1621 dod,
1622 project_root=self.context.project_root,
1623 )
1624 self._queue_next_pending_todo_nudge(
1625 tool_call=tool_call,
1626 pending_before=pending_before,
1627 dod=dod,
1628 )
1629 self._queue_bookkeeping_resume_nudge(
1630 tool_call=tool_call,
1631 dod=dod,
1632 )
1633 self._queue_missing_artifact_progress_nudge(
1634 tool_call=tool_call,
1635 dod=dod,
1636 )
1637 self._queue_planned_artifact_handoff_nudge(
1638 tool_call=tool_call,
1639 dod=dod,
1640 )
1641 if previously_verified and is_mutating:
1642 _mark_verification_stale(
1643 context=self.context,
1644 summary=summary,
1645 dod=dod,
1646 tool_call=tool_call,
1647 )
1648 elif is_mutating and _should_plan_verification_for_tool_call(
1649 dod,
1650 tool_call=tool_call,
1651 project_root=self.context.project_root,
1652 ):
1653 _mark_verification_planned(
1654 context=self.context,
1655 summary=summary,
1656 dod=dod,
1657 tool_call=tool_call,
1658 )
1659 self.dod_store.save(dod)
1660 recovery_context = self.context.recovery_context
1661 if recovery_context is not None:
1662 recovery_context.note_success(tool_call.name, tool_call.arguments)
1663 if recovery_context.should_clear_after_success(
1664 tool_call.name,
1665 tool_call.arguments,
1666 ):
1667 self.context.recovery_context = None
1668 return None
1669
1670 def _refresh_todowrite_outcome_summary(
1671 self,
1672 *,
1673 outcome,
1674 dod: DefinitionOfDone,
1675 ) -> None:
1676 """Rewrite TodoWrite summaries from reconciled DoD state instead of raw snapshots."""
1677
1678 pending = [
1679 item
1680 for item in effective_pending_todo_items(
1681 dod,
1682 project_root=self.context.project_root,
1683 )
1684 if item not in _TODO_NUDGE_EXCLUDED_ITEMS
1685 ]
1686 completed = [
1687 item
1688 for item in dod.completed_items
1689 if item not in _TODO_NUDGE_EXCLUDED_ITEMS
1690 ]
1691 summary_parts = [
1692 "updated todo list",
1693 f"{len(completed)} completed",
1694 f"{len(pending)} pending",
1695 ]
1696 if pending:
1697 summary_parts.append(f"next pending: {pending[0]}")
1698 if not pending and (
1699 dod.verification_commands
1700 or all_planned_artifact_outputs_exist(
1701 dod,
1702 project_root=self.context.project_root,
1703 )
1704 ):
1705 summary_parts.append("final response should be provided next for Loader verification")
1706
1707 result = "; ".join(summary_parts)
1708 content = f"Observation [TodoWrite]: Result: {result}"
1709 outcome.event_content = content
1710 outcome.result_output = content
1711 outcome.message.content = content
1712 if outcome.message.tool_results:
1713 outcome.message.tool_results[0].content = content
1714
1715 def _queue_next_pending_todo_nudge(
1716 self,
1717 *,
1718 tool_call: ToolCall,
1719 pending_before: list[str],
1720 dod: DefinitionOfDone,
1721 ) -> None:
1722 if is_state_mutating_tool_call(tool_call):
1723 return
1724 if tool_call.name not in {"read", "glob", "grep", "bash"}:
1725 return
1726 if tool_call.name == "bash":
1727 command = str(tool_call.arguments.get("command", "")).lower()
1728 if not any(
1729 token in command
1730 for token in (
1731 "ls ",
1732 " ls",
1733 "find ",
1734 "grep ",
1735 "rg ",
1736 "cat ",
1737 "sed ",
1738 "head ",
1739 "tail ",
1740 )
1741 ):
1742 return
1743
1744 completed_label = next(
1745 (
1746 item
1747 for item in pending_before
1748 if item not in dod.pending_items
1749 and item not in _TODO_NUDGE_EXCLUDED_ITEMS
1750 ),
1751 None,
1752 )
1753 missing_artifact = _next_missing_planned_artifact(
1754 dod,
1755 project_root=self.context.project_root,
1756 messages=list(getattr(self.context.session, "messages", []) or []),
1757 )
1758 next_pending = preferred_pending_todo_item(
1759 dod,
1760 project_root=self.context.project_root,
1761 missing_artifact=missing_artifact,
1762 )
1763 missing_artifact = _prefer_missing_artifact_for_pending_item(
1764 dod,
1765 missing_artifact=missing_artifact,
1766 next_pending=next_pending,
1767 project_root=self.context.project_root,
1768 )
1769 has_substantive_file_artifact_progress = (
1770 _has_confirmed_substantive_file_artifact_progress(
1771 dod,
1772 project_root=self.context.project_root,
1773 )
1774 )
1775 if not completed_label or not next_pending or next_pending == completed_label:
1776 return
1777 if _should_prioritize_missing_artifact(
1778 dod=dod,
1779 next_pending=next_pending,
1780 missing_artifact=missing_artifact,
1781 project_root=self.context.project_root,
1782 ):
1783 if not has_substantive_file_artifact_progress:
1784 compact_handoff = _compact_missing_artifact_handoff(
1785 missing_artifact,
1786 project_root=self.context.project_root,
1787 messages=list(getattr(self.context.session, "messages", []) or []),
1788 encourage_initial_version=True,
1789 )
1790 if compact_handoff:
1791 self.context.queue_steering_message(
1792 f"Confirmed progress: `{completed_label}` is now satisfied by the successful "
1793 f"`{tool_call.name}` result. {compact_handoff}"
1794 " Do not reread reference material or spend the next turn on bookkeeping."
1795 )
1796 return
1797 self.context.queue_steering_message(
1798 f"Confirmed progress: `{completed_label}` is now satisfied by the successful "
1799 f"`{tool_call.name}` result. One declared output artifact is still missing."
1800 + _missing_artifact_resume_suffix(
1801 missing_artifact,
1802 project_root=self.context.project_root,
1803 messages=list(getattr(self.context.session, "messages", []) or []),
1804 )
1805 + " Do not switch into review or consistency-check mode until the missing artifact exists."
1806 )
1807 return
1808
1809 mutation_suffix = ""
1810 if _todo_is_mutation_step(next_pending):
1811 mutation_suffix = _pending_item_resume_suffix(
1812 dod,
1813 next_pending=next_pending,
1814 missing_artifact=missing_artifact,
1815 project_root=self.context.project_root,
1816 messages=list(getattr(self.context.session, "messages", []) or []),
1817 )
1818 if not mutation_suffix:
1819 mutation_suffix = (
1820 " You already have enough evidence for that step, so stop gathering "
1821 "more reference material and perform the change now."
1822 )
1823
1824 self.context.queue_steering_message(
1825 f"Confirmed progress: `{completed_label}` is now satisfied by the successful "
1826 f"`{tool_call.name}` result. Continue with the next pending item: "
1827 f"`{next_pending}` instead of rereading the same evidence.{mutation_suffix}"
1828 )
1829
1830 def _queue_planned_artifact_handoff_nudge(
1831 self,
1832 *,
1833 tool_call: ToolCall,
1834 dod: DefinitionOfDone,
1835 ) -> None:
1836 if not is_state_mutating_tool_call(tool_call):
1837 return
1838 if not all_planned_artifact_outputs_exist(dod, project_root=self.context.project_root):
1839 return
1840 repair = extract_active_repair_context(self.context.session.messages)
1841 if repair is not None and _repair_context_is_html_quality(repair):
1842 return
1843
1844 next_pending = preferred_pending_todo_item(
1845 dod,
1846 project_root=self.context.project_root,
1847 )
1848 verification_commands = dod.verification_commands or derive_verification_commands(
1849 dod,
1850 project_root=self.context.project_root,
1851 task_statement=getattr(self.context.session, "current_task", "") or "",
1852 supplement_existing=True,
1853 )
1854
1855 if next_pending and _todo_is_consistency_review_step(next_pending):
1856 verification_suffix = (
1857 " Finish with a final response once no specific mismatch remains so Loader can verify."
1858 if verification_commands
1859 else " Avoid another full reread unless one specific inconsistency is still unknown."
1860 )
1861 self.context.queue_steering_message(
1862 "All explicitly planned artifacts now exist on disk. "
1863 f"Continue with the next pending item: `{next_pending}`. "
1864 "Use the files already on disk as the source of truth instead of restarting "
1865 "discovery or inventing alternate filenames."
1866 + verification_suffix
1867 )
1868 return
1869
1870 if verification_commands:
1871 self.context.queue_steering_message(
1872 "All explicitly planned artifacts now exist on disk. "
1873 "Do not expand the artifact set or restart discovery unless a specific gap is "
1874 "still known. Finish with a final response now so Loader can verify the files "
1875 "that already exist."
1876 )
1877
1878 def _queue_missing_artifact_progress_nudge(
1879 self,
1880 *,
1881 tool_call: ToolCall,
1882 dod: DefinitionOfDone,
1883 ) -> None:
1884 if not is_state_mutating_tool_call(tool_call):
1885 return
1886 missing_artifact = _next_missing_planned_artifact(
1887 dod,
1888 project_root=self.context.project_root,
1889 )
1890 if missing_artifact is None:
1891 return
1892 next_pending = preferred_pending_todo_item(
1893 dod,
1894 project_root=self.context.project_root,
1895 missing_artifact=missing_artifact,
1896 )
1897 missing_artifact = _prefer_missing_artifact_for_pending_item(
1898 dod,
1899 missing_artifact=missing_artifact,
1900 next_pending=next_pending,
1901 project_root=self.context.project_root,
1902 )
1903
1904 current_label = _current_mutation_label(tool_call)
1905 has_substantive_file_artifact_progress = (
1906 _has_confirmed_substantive_file_artifact_progress(
1907 dod,
1908 project_root=self.context.project_root,
1909 )
1910 )
1911 resume_target = _preferred_resume_target_path(
1912 dod,
1913 next_pending=next_pending,
1914 missing_artifact=missing_artifact,
1915 project_root=self.context.project_root,
1916 messages=list(getattr(self.context.session, "messages", []) or []),
1917 )
1918 resume_suffix = _pending_item_resume_suffix(
1919 dod,
1920 next_pending=next_pending,
1921 missing_artifact=missing_artifact,
1922 project_root=self.context.project_root,
1923 messages=list(getattr(self.context.session, "messages", []) or []),
1924 )
1925 if (
1926 not has_substantive_file_artifact_progress
1927 and _is_pure_directory_creation_tool_call(tool_call)
1928 ):
1929 if (
1930 next_pending
1931 and _todo_is_mutation_step(next_pending)
1932 and resume_target is not None
1933 and resume_target.suffix
1934 ):
1935 compact_resume = _compact_missing_artifact_handoff(
1936 (resume_target, False),
1937 project_root=self.context.project_root,
1938 messages=list(getattr(self.context.session, "messages", []) or []),
1939 encourage_initial_version=True,
1940 )
1941 if compact_resume:
1942 self.context.queue_steering_message(
1943 "Directory setup is complete. "
1944 + compact_resume
1945 + " Do not reread older reference files before that mutation."
1946 )
1947 return
1948 self.context.queue_steering_message(
1949 f"Directory setup is complete. Continue with the next pending item: `{next_pending}`."
1950 + resume_suffix
1951 + " Do not reread older reference files before that mutation."
1952 )
1953 return
1954 use_persistent_handoff = _should_use_persistent_missing_artifact_handoff(
1955 dod,
1956 project_root=self.context.project_root,
1957 )
1958 session_messages = list(getattr(self.context.session, "messages", []) or [])
1959 if (
1960 use_persistent_handoff
1961 and _recent_recovery_prompt(session_messages)
1962 and not _should_preserve_first_child_handoff_after_recovery(
1963 tool_call=tool_call,
1964 resume_target=resume_target,
1965 dod=dod,
1966 project_root=self.context.project_root,
1967 )
1968 ):
1969 use_persistent_handoff = False
1970 queue_message = (
1971 self.context.queue_steering_message
1972 if use_persistent_handoff
1973 else self.context.queue_ephemeral_steering_message
1974 )
1975 if resume_target is not None and resume_target.suffix:
1976 compact_resume = _compact_missing_artifact_handoff(
1977 (resume_target, False),
1978 project_root=self.context.project_root,
1979 messages=session_messages,
1980 encourage_initial_version=False,
1981 )
1982 if compact_resume:
1983 queue_message(
1984 f"Confirmed progress: {current_label} is now recorded. "
1985 + compact_resume
1986 + " Do not reread reference material or spend the next turn on bookkeeping."
1987 )
1988 return
1989 todo_refresh = _todo_refresh_guidance(
1990 dod,
1991 project_root=self.context.project_root,
1992 )
1993 if not has_substantive_file_artifact_progress:
1994 compact_handoff = _compact_missing_artifact_handoff(
1995 missing_artifact,
1996 project_root=self.context.project_root,
1997 messages=session_messages,
1998 encourage_initial_version=False,
1999 )
2000 if compact_handoff:
2001 queue_message(
2002 f"Confirmed progress: {current_label} is now recorded. "
2003 + compact_handoff
2004 + " Do not reread reference material or spend the next turn on bookkeeping."
2005 )
2006 return
2007 if _late_stage_missing_artifact_build(
2008 dod,
2009 project_root=self.context.project_root,
2010 ):
2011 queue_message(
2012 f"Confirmed progress: {current_label} is now recorded."
2013 + resume_suffix
2014 + " No TodoWrite, no verification, no rereads until that artifact exists."
2015 )
2016 return
2017 queue_message(
2018 f"Confirmed progress: {current_label} is now recorded."
2019 " One declared output artifact is still missing."
2020 + resume_suffix
2021 + todo_refresh
2022 + " Do not move to verification, final confirmation, or TodoWrite-only "
2023 "bookkeeping until that artifact exists."
2024 + " Do not spend another turn on working notes or rediscovery alone."
2025 )
2026
2027 def _queue_todowrite_resume_nudge(
2028 self,
2029 *,
2030 dod: DefinitionOfDone,
2031 ) -> None:
2032 repair = extract_active_repair_context(self.context.session.messages)
2033 if repair is not None and _repair_context_is_html_quality(repair):
2034 target = repair.artifact_path or (
2035 repair.allowed_paths[0] if repair.allowed_paths else ""
2036 )
2037 if not target:
2038 return
2039 repair_issue = _quality_repair_issue_for_target(repair, target)
2040 issue_line = (
2041 f"- {repair_issue}\n"
2042 if repair_issue
2043 else f"- Improve `{target}` until it satisfies the active content-quality verifier.\n"
2044 )
2045 force_write = recent_repair_mutation_context_failed(
2046 self.context.session.messages,
2047 target,
2048 )
2049 if force_write:
2050 immediate_step = (
2051 f"- Immediate next step: rewrite `{target}` with one `write` call.\n"
2052 "- Recent `edit`/`patch` attempts for this file failed against stale "
2053 "or malformed context. Use `write(file_path=..., content=...)` with "
2054 "a complete valid HTML document, and do not call `read`, `edit`, "
2055 "`patch`, or TodoWrite again first."
2056 )
2057 else:
2058 immediate_step = (
2059 f"- Immediate next step: edit `{target}`.\n"
2060 "- Continue with one concrete `edit`, `patch`, or `write` call that "
2061 "actually changes the current generated file."
2062 )
2063 self.context.set_workflow_mode("execute")
2064 self.context.queue_steering_message(
2065 "Todo tracking is updated, but verification still has an active "
2066 "HTML content-quality repair. TodoWrite cannot satisfy that verifier "
2067 "or close the repair by itself. Do not mark the task complete and do "
2068 "not finish yet.\n\n"
2069 "Repair focus:\n"
2070 f"{issue_line}"
2071 f"{immediate_step}"
2072 )
2073 return
2074
2075 session_messages = list(getattr(self.context.session, "messages", []) or [])
2076 missing_artifact = _next_missing_planned_artifact(
2077 dod,
2078 project_root=self.context.project_root,
2079 messages=session_messages,
2080 )
2081 next_pending = preferred_pending_todo_item(
2082 dod,
2083 project_root=self.context.project_root,
2084 missing_artifact=missing_artifact,
2085 )
2086 missing_artifact = _prefer_missing_artifact_for_pending_item(
2087 dod,
2088 missing_artifact=missing_artifact,
2089 next_pending=next_pending,
2090 project_root=self.context.project_root,
2091 )
2092 outputs_exist = all_planned_artifact_outputs_exist(
2093 dod,
2094 project_root=self.context.project_root,
2095 )
2096 if missing_artifact is None:
2097 if next_pending and _todo_is_mutation_step(next_pending) and not outputs_exist:
2098 pending_target = infer_pending_todo_output_target(
2099 dod,
2100 next_pending,
2101 project_root=self.context.project_root,
2102 )
2103 if pending_target is not None:
2104 concrete_message = (
2105 "Todo tracking is updated. Continue with the next pending item: "
2106 f"`{next_pending}`. Resume by creating `{pending_target.name}` now. "
2107 f"Prefer one `write` call for `{pending_target}` instead of more rereads. "
2108 )
2109 if not pending_target.parent.exists():
2110 concrete_message += (
2111 "The `write` tool can create that file's parent directories "
2112 "automatically, so do the write in one step instead of stopping "
2113 "for a separate mkdir. "
2114 )
2115 concrete_message += (
2116 "Use the current output files as the source of truth, and do not "
2117 "reopen reference materials unless one specific fact required for "
2118 "that step is still unknown. Make your next response the concrete "
2119 "mutation tool call itself, not another bookkeeping-only turn. "
2120 "Perform the mutation now instead of spending another turn on "
2121 "planning, rereads, or verification."
2122 )
2123 self.context.queue_steering_message(concrete_message)
2124 return
2125 self.context.queue_steering_message(
2126 "Todo tracking is updated. Continue with the next pending item: "
2127 f"`{next_pending}`. Use the current output files as the source of "
2128 "truth, and do not reopen reference materials unless one specific "
2129 "fact required for that step is still unknown. Perform the mutation "
2130 "now instead of spending another turn on planning, rereads, or "
2131 "verification."
2132 )
2133 return
2134
2135 if (
2136 next_pending
2137 and _todo_is_consistency_review_step(next_pending)
2138 and not outputs_exist
2139 ):
2140 self.context.queue_ephemeral_steering_message(
2141 "Todo tracking is updated. Continue with the next pending item: "
2142 f"`{next_pending}`. Use the current output files as the source of "
2143 "truth, and do not reopen reference materials unless one specific "
2144 "mismatch is still unknown."
2145 )
2146 return
2147
2148 if not outputs_exist:
2149 return
2150
2151 verification_commands = dod.verification_commands or derive_verification_commands(
2152 dod,
2153 project_root=self.context.project_root,
2154 task_statement=getattr(self.context.session, "current_task", "") or "",
2155 supplement_existing=True,
2156 )
2157 if next_pending and _todo_is_consistency_review_step(next_pending):
2158 verification_suffix = (
2159 " Finish with a final response once no specific mismatch remains so Loader can verify."
2160 if verification_commands
2161 else " Finish the targeted consistency pass without reopening reference materials."
2162 )
2163 self.context.queue_ephemeral_steering_message(
2164 "Todo tracking is updated. All explicitly planned artifacts now exist on disk. "
2165 f"Continue with the next pending item: `{next_pending}`. "
2166 "Use the current output files as the source of truth, and do not restart "
2167 "early discovery or reopen reference materials."
2168 + verification_suffix
2169 )
2170 return
2171
2172 if verification_commands:
2173 self.context.set_workflow_mode("verify")
2174 self.context.queue_steering_message(
2175 "Todo tracking is updated. All explicitly planned artifacts now exist on disk. "
2176 "Finish with a final response now so Loader can run verification automatically. "
2177 "Use the current output files as the source of truth, and do not restart discovery, "
2178 "reopen reference materials, run more ad hoc audit commands, or spend another turn "
2179 "on TodoWrite alone."
2180 )
2181 return
2182
2183 verification_suffix = (
2184 " " + _verification_handoff_instruction()
2185 if verification_commands
2186 else " Finish the task using the files already on disk."
2187 )
2188 self.context.queue_ephemeral_steering_message(
2189 "Todo tracking is updated. All explicitly planned artifacts now exist on disk. "
2190 "Do not restart discovery, reopen reference materials, or spend another turn "
2191 "on TodoWrite alone. Repair or verify the current files instead of expanding the artifact set."
2192 + verification_suffix
2193 )
2194 return
2195
2196 todo_refresh = _todo_refresh_guidance(
2197 dod,
2198 project_root=self.context.project_root,
2199 )
2200 resume_target = _preferred_resume_target_path(
2201 dod,
2202 next_pending=next_pending,
2203 missing_artifact=missing_artifact,
2204 project_root=self.context.project_root,
2205 messages=session_messages,
2206 )
2207 pending_target = _preferred_pending_target_path(
2208 dod,
2209 next_pending=next_pending,
2210 project_root=self.context.project_root,
2211 )
2212 next_pending_suffix = _pending_item_handoff_prefix(
2213 next_pending,
2214 pending_target=pending_target,
2215 resume_target=resume_target,
2216 )
2217 if resume_target is not None and resume_target.suffix:
2218 compact_resume = _compact_missing_artifact_handoff(
2219 (resume_target, False),
2220 project_root=self.context.project_root,
2221 messages=session_messages,
2222 encourage_initial_version=False,
2223 )
2224 if compact_resume:
2225 self.context.queue_steering_message(
2226 "Todo tracking is updated. "
2227 + compact_resume
2228 + " Do not spend the next turn on TodoWrite alone, bookkeeping notes, "
2229 "verification, or final confirmation until that artifact exists."
2230 )
2231 return
2232 self.context.queue_steering_message(
2233 "Todo tracking is updated. A declared output artifact is still missing."
2234 + next_pending_suffix
2235 + _missing_artifact_resume_suffix(
2236 missing_artifact,
2237 project_root=self.context.project_root,
2238 messages=session_messages,
2239 )
2240 + todo_refresh
2241 + " Do not spend the next turn on TodoWrite alone, bookkeeping notes, "
2242 "verification, or final confirmation until that artifact exists."
2243 )
2244
2245 def _queue_bookkeeping_resume_nudge(
2246 self,
2247 *,
2248 tool_call: ToolCall,
2249 dod: DefinitionOfDone,
2250 ) -> None:
2251 if tool_call.name not in _BOOKKEEPING_NOTE_TOOL_NAMES:
2252 return
2253
2254 session_messages = list(getattr(self.context.session, "messages", []) or [])
2255 missing_artifact = _next_missing_planned_artifact(
2256 dod,
2257 project_root=self.context.project_root,
2258 messages=session_messages,
2259 )
2260 if missing_artifact is None:
2261 return
2262
2263 next_pending = preferred_pending_todo_item(
2264 dod,
2265 project_root=self.context.project_root,
2266 missing_artifact=missing_artifact,
2267 )
2268 missing_artifact = _prefer_missing_artifact_for_pending_item(
2269 dod,
2270 missing_artifact=missing_artifact,
2271 next_pending=next_pending,
2272 project_root=self.context.project_root,
2273 )
2274 todo_refresh = _todo_refresh_guidance(
2275 dod,
2276 project_root=self.context.project_root,
2277 )
2278 if (
2279 next_pending
2280 and not _todo_is_mutation_step(next_pending)
2281 and not _todo_is_consistency_review_step(next_pending)
2282 and not _should_prioritize_missing_artifact(
2283 dod=dod,
2284 next_pending=next_pending,
2285 missing_artifact=(
2286 missing_artifact
2287 if _has_confirmed_artifact_progress(
2288 dod,
2289 project_root=self.context.project_root,
2290 )
2291 else None
2292 ),
2293 project_root=self.context.project_root,
2294 )
2295 ):
2296 self.context.queue_ephemeral_steering_message(
2297 "Bookkeeping note is recorded. Continue with the next pending item: "
2298 f"`{next_pending}`. Make your next response one concrete evidence-gathering "
2299 "tool call that advances that step, not another bookkeeping-only turn."
2300 + todo_refresh
2301 + " Do not jump ahead to later artifact creation, verification, or final "
2302 "confirmation until that step is satisfied."
2303 )
2304 return
2305
2306 self.context.queue_ephemeral_steering_message(
2307 "Bookkeeping note is recorded. A declared output artifact is still missing."
2308 + _missing_artifact_resume_suffix(
2309 missing_artifact,
2310 project_root=self.context.project_root,
2311 messages=session_messages,
2312 )
2313 + todo_refresh
2314 + " Do not spend the next turn on additional notes, rediscovery, "
2315 "verification, or final confirmation until that artifact exists."
2316 )
2317
2318
2319 def _todo_is_consistency_review_step(item: str) -> bool:
2320 text = item.lower()
2321 return any(hint in text for hint in _CONSISTENCY_REVIEW_HINTS)
2322
2323
2324 def _planned_output_roots(
2325 dod: DefinitionOfDone,
2326 *,
2327 project_root: Path,
2328 ) -> tuple[str, ...]:
2329 planned_roots: list[str] = []
2330 seen_roots: set[str] = set()
2331 for target, expect_directory in collect_planned_artifact_targets(
2332 dod,
2333 project_root=project_root,
2334 ):
2335 root = str(target if expect_directory else target.parent)
2336 if root in seen_roots:
2337 continue
2338 seen_roots.add(root)
2339 planned_roots.append(root)
2340 return tuple(planned_roots)
2341
2342
2343 def _extract_observation_paths(tool_call: ToolCall) -> list[str]:
2344 arguments = tool_call.arguments
2345 if tool_call.name == "read":
2346 file_path = str(arguments.get("file_path", "")).strip()
2347 return [file_path] if file_path else []
2348
2349 if tool_call.name in {"glob", "grep"}:
2350 candidates: list[str] = []
2351 search_path = str(arguments.get("path", "")).strip()
2352 if search_path:
2353 anchored_path = _derive_search_anchor(
2354 search_path,
2355 str(arguments.get("pattern", "")).strip(),
2356 )
2357 candidates.append(anchored_path or search_path)
2358 pattern = str(arguments.get("pattern", "")).strip()
2359 if not search_path and pattern.startswith(("/", "~")):
2360 candidates.append(str(Path(pattern).expanduser().parent))
2361 return candidates
2362
2363 command = str(arguments.get("command", "")).strip()
2364 if not _is_read_only_bash(command):
2365 return []
2366 return _extract_bash_paths(command)
2367
2368
2369 def _derive_search_anchor(search_path: str, pattern: str) -> str:
2370 base = str(Path(search_path).expanduser())
2371 normalized_pattern = pattern.strip()
2372 if not normalized_pattern:
2373 return base
2374 if normalized_pattern.startswith(("~", "/")):
2375 pattern_path = Path(normalized_pattern).expanduser()
2376 try:
2377 return str(pattern_path.parent.resolve(strict=False))
2378 except Exception:
2379 return str(pattern_path.parent)
2380 if "/" in normalized_pattern:
2381 prefix = normalized_pattern.rsplit("/", 1)[0].strip()
2382 if prefix and prefix not in {".", ".."}:
2383 joined = Path(base).joinpath(prefix).expanduser()
2384 try:
2385 return str(joined.resolve(strict=False))
2386 except Exception:
2387 return str(joined)
2388 return base
2389
2390
2391 def _is_read_only_bash(command: str) -> bool:
2392 normalized = " ".join(command.split())
2393 if not normalized:
2394 return False
2395 if extract_shell_text_rewrite_target(normalized) is not None:
2396 return False
2397 if any(fragment in normalized for fragment in _MUTATING_BASH_FRAGMENTS):
2398 return False
2399 try:
2400 argv = shlex.split(normalized)
2401 except ValueError:
2402 return False
2403 if not argv:
2404 return False
2405 return argv[0] in _READ_ONLY_BASH_PREFIXES
2406
2407
2408 def _extract_bash_paths(command: str) -> list[str]:
2409 try:
2410 argv = shlex.split(command)
2411 except ValueError:
2412 return []
2413 if not argv:
2414 return []
2415
2416 command_name = argv[0]
2417 if command_name == "pwd":
2418 return [str(Path.cwd())]
2419
2420 paths: list[str] = []
2421 for arg in argv[1:]:
2422 if arg.startswith("-"):
2423 continue
2424 if command_name in {"ls", "stat", "cat", "head", "tail"}:
2425 paths.append(arg)
2426 continue
2427 if command_name in {"find", "rg", "grep"}:
2428 paths.append(str(Path.cwd()) if arg in {".", "./"} else arg)
2429 break
2430 return paths
2431
2432
2433 def _should_prioritize_missing_artifact(
2434 *,
2435 dod: DefinitionOfDone,
2436 next_pending: str | None,
2437 missing_artifact: tuple[Path, bool] | None,
2438 project_root: Path,
2439 ) -> bool:
2440 if missing_artifact is None:
2441 return False
2442 if not next_pending:
2443 return True
2444 if _pending_todo_conflicts_with_missing_artifact(
2445 dod,
2446 item=next_pending,
2447 missing_artifact=missing_artifact,
2448 project_root=project_root,
2449 ):
2450 return True
2451 if _todo_is_consistency_review_step(next_pending):
2452 return True
2453 return not _todo_is_mutation_step(next_pending)
2454
2455
2456 def _pending_todo_conflicts_with_missing_artifact(
2457 dod: DefinitionOfDone,
2458 *,
2459 item: str,
2460 missing_artifact: tuple[Path, bool],
2461 project_root: Path,
2462 ) -> bool:
2463 text = item.strip().lower()
2464 if not text or item in _TODO_NUDGE_EXCLUDED_ITEMS:
2465 return False
2466
2467 target, expect_directory = missing_artifact
2468 inferred_target = infer_pending_todo_output_target(
2469 dod,
2470 item,
2471 project_root=project_root,
2472 )
2473 if inferred_target is None:
2474 return not expect_directory and _todo_is_mutation_step(item)
2475
2476 inferred_target = inferred_target.resolve(strict=False)
2477 target = target.resolve(strict=False)
2478 if expect_directory:
2479 return target != inferred_target and target not in inferred_target.parents
2480 return inferred_target != target
2481
2482
2483 def _next_missing_planned_artifact(
2484 dod: DefinitionOfDone,
2485 *,
2486 project_root: Path,
2487 messages: list[Any] | None = None,
2488 ) -> tuple[Path, bool] | None:
2489 for target, expect_directory in collect_planned_artifact_targets(
2490 dod,
2491 project_root=project_root,
2492 max_paths=12,
2493 ):
2494 if not planned_artifact_target_satisfied(
2495 dod,
2496 target=target,
2497 expect_directory=expect_directory,
2498 project_root=project_root,
2499 ):
2500 return target, expect_directory
2501 if not _pending_mutation_work_requires_inferred_children(
2502 dod,
2503 project_root=project_root,
2504 ):
2505 return None
2506 for target, expect_directory in collect_planned_artifact_targets(
2507 dod,
2508 project_root=project_root,
2509 max_paths=12,
2510 ):
2511 if not expect_directory or not target.is_dir():
2512 continue
2513 next_output_file, _ = infer_next_output_file(
2514 target=target,
2515 project_root=project_root,
2516 messages=list(messages or []),
2517 )
2518 if next_output_file is not None and not next_output_file.exists():
2519 return next_output_file, False
2520 return None
2521
2522
2523 def _pending_mutation_work_requires_inferred_children(
2524 dod: DefinitionOfDone,
2525 *,
2526 project_root: Path,
2527 ) -> bool:
2528 actionable_pending = [
2529 item
2530 for item in effective_pending_todo_items(dod, project_root=project_root)
2531 if item not in _TODO_NUDGE_EXCLUDED_ITEMS
2532 ]
2533 return any(_todo_is_mutation_step(item) for item in actionable_pending)
2534
2535
2536 def _prefer_missing_artifact_for_pending_item(
2537 dod: DefinitionOfDone,
2538 *,
2539 missing_artifact: tuple[Path, bool] | None,
2540 next_pending: str | None,
2541 project_root: Path,
2542 ) -> tuple[Path, bool] | None:
2543 if missing_artifact is None or not next_pending:
2544 return missing_artifact
2545
2546 inferred_target = infer_pending_todo_output_target(
2547 dod,
2548 next_pending,
2549 project_root=project_root,
2550 )
2551 if inferred_target is None or inferred_target.exists():
2552 return missing_artifact
2553
2554 normalized_target = inferred_target.expanduser().resolve(strict=False)
2555 for planned_target, expect_directory in collect_planned_artifact_targets(
2556 dod,
2557 project_root=project_root,
2558 max_paths=12,
2559 ):
2560 normalized_planned = planned_target.expanduser().resolve(strict=False)
2561 if expect_directory and normalized_planned == normalized_target:
2562 return normalized_target, True
2563 if expect_directory:
2564 try:
2565 normalized_target.relative_to(normalized_planned)
2566 except ValueError:
2567 continue
2568 return normalized_target, False
2569 if normalized_planned == normalized_target:
2570 return normalized_target, False
2571 return missing_artifact
2572
2573
2574 def _late_stage_missing_artifact_build(
2575 dod: DefinitionOfDone,
2576 *,
2577 project_root: Path,
2578 ) -> bool:
2579 completed = 0
2580 missing = 0
2581 for target, expect_directory in collect_planned_artifact_targets(
2582 dod,
2583 project_root=project_root,
2584 max_paths=12,
2585 ):
2586 if planned_artifact_target_satisfied(
2587 dod,
2588 target=target,
2589 expect_directory=expect_directory,
2590 project_root=project_root,
2591 ):
2592 completed += 1
2593 else:
2594 missing += 1
2595 return completed >= 7 and missing > 0
2596
2597
2598 def _has_confirmed_artifact_progress(
2599 dod: DefinitionOfDone,
2600 *,
2601 project_root: Path,
2602 ) -> bool:
2603 for target, expect_directory in collect_planned_artifact_targets(
2604 dod,
2605 project_root=project_root,
2606 max_paths=12,
2607 ):
2608 if planned_artifact_target_satisfied(
2609 dod,
2610 target=target,
2611 expect_directory=expect_directory,
2612 project_root=project_root,
2613 ):
2614 return True
2615 return bool(dod.touched_files)
2616
2617
2618 def _has_confirmed_file_artifact_progress(
2619 dod: DefinitionOfDone,
2620 *,
2621 project_root: Path,
2622 ) -> bool:
2623 return _confirmed_file_artifact_count(dod, project_root=project_root) > 0
2624
2625
2626 def _has_confirmed_substantive_file_artifact_progress(
2627 dod: DefinitionOfDone,
2628 *,
2629 project_root: Path,
2630 ) -> bool:
2631 return _confirmed_substantive_file_artifact_count(
2632 dod,
2633 project_root=project_root,
2634 ) > 0
2635
2636
2637 def _last_touched_file_path(dod: DefinitionOfDone) -> Path | None:
2638 for raw_path in reversed(dod.touched_files):
2639 path_text = str(raw_path or "").strip()
2640 if not path_text:
2641 continue
2642 candidate = Path(path_text).expanduser().resolve(strict=False)
2643 if candidate.suffix:
2644 return candidate
2645 return None
2646
2647
2648 def _confirmed_file_artifact_count(
2649 dod: DefinitionOfDone,
2650 *,
2651 project_root: Path,
2652 ) -> int:
2653 count = 0
2654 for target, expect_directory in collect_planned_artifact_targets(
2655 dod,
2656 project_root=project_root,
2657 max_paths=12,
2658 ):
2659 if expect_directory:
2660 continue
2661 if planned_artifact_target_satisfied(
2662 dod,
2663 target=target,
2664 expect_directory=False,
2665 project_root=project_root,
2666 ):
2667 count += 1
2668 if count:
2669 return count
2670 return sum(
2671 1
2672 for path in dod.touched_files
2673 if str(path).strip()
2674 and Path(path).expanduser().resolve(strict=False).suffix
2675 )
2676
2677
2678 def _confirmed_substantive_file_artifact_count(
2679 dod: DefinitionOfDone,
2680 *,
2681 project_root: Path,
2682 ) -> int:
2683 count = 0
2684 for target, expect_directory in collect_planned_artifact_targets(
2685 dod,
2686 project_root=project_root,
2687 max_paths=12,
2688 ):
2689 if expect_directory or _is_summary_artifact_path(target):
2690 continue
2691 if planned_artifact_target_satisfied(
2692 dod,
2693 target=target,
2694 expect_directory=False,
2695 project_root=project_root,
2696 ):
2697 count += 1
2698 if count:
2699 return count
2700 return sum(
2701 1
2702 for path in dod.touched_files
2703 if str(path).strip()
2704 and Path(path).expanduser().resolve(strict=False).suffix
2705 and not _is_summary_artifact_path(path)
2706 )
2707
2708
2709 def _should_use_persistent_missing_artifact_handoff(
2710 dod: DefinitionOfDone,
2711 *,
2712 project_root: Path,
2713 ) -> bool:
2714 return _confirmed_substantive_file_artifact_count(
2715 dod,
2716 project_root=project_root,
2717 ) == 0
2718
2719
2720 def _should_preserve_first_child_handoff_after_recovery(
2721 *,
2722 tool_call: ToolCall,
2723 resume_target: Path | None,
2724 dod: DefinitionOfDone,
2725 project_root: Path,
2726 ) -> bool:
2727 if resume_target is None or not resume_target.suffix or _is_summary_artifact_path(resume_target):
2728 return False
2729 raw_target = str(tool_call.arguments.get("file_path", "")).strip()
2730 if not raw_target:
2731 return False
2732 written_target = Path(raw_target).expanduser().resolve(strict=False)
2733 if not _is_summary_artifact_path(written_target):
2734 return False
2735 return _confirmed_substantive_file_artifact_count(
2736 dod,
2737 project_root=project_root,
2738 ) == 0
2739
2740
2741 def _is_summary_artifact_path(path: str | Path) -> bool:
2742 return Path(path).name.lower() in _SUMMARY_ARTIFACT_NAMES
2743
2744
2745 def _next_missing_planned_file_within_directory(
2746 dod: DefinitionOfDone,
2747 *,
2748 target: Path,
2749 project_root: Path,
2750 ) -> Path | None:
2751 normalized_target = target.expanduser().resolve(strict=False)
2752 if normalized_target.suffix:
2753 return None
2754
2755 for planned_target, expect_directory in collect_planned_artifact_targets(
2756 dod,
2757 project_root=project_root,
2758 max_paths=12,
2759 ):
2760 if expect_directory:
2761 continue
2762 normalized_planned = planned_target.expanduser().resolve(strict=False)
2763 try:
2764 normalized_planned.relative_to(normalized_target)
2765 except ValueError:
2766 continue
2767 if planned_artifact_target_satisfied(
2768 dod,
2769 target=normalized_planned,
2770 expect_directory=False,
2771 project_root=project_root,
2772 ):
2773 continue
2774 return normalized_planned
2775 return None
2776
2777
2778 def _missing_artifact_resume_suffix(
2779 missing_artifact: tuple[Path, bool] | None,
2780 *,
2781 project_root: Path,
2782 messages: list[Any] | None = None,
2783 ) -> str:
2784 if missing_artifact is None:
2785 return ""
2786
2787 target, expect_directory = missing_artifact
2788 return _resume_suffix_for_target(
2789 target,
2790 expect_directory=expect_directory,
2791 project_root=project_root,
2792 messages=messages,
2793 )
2794
2795
2796 def _pending_item_resume_suffix(
2797 dod: DefinitionOfDone,
2798 *,
2799 next_pending: str | None,
2800 missing_artifact: tuple[Path, bool] | None,
2801 project_root: Path,
2802 messages: list[Any] | None = None,
2803 ) -> str:
2804 if next_pending:
2805 pending_target = infer_pending_todo_output_target(
2806 dod,
2807 next_pending,
2808 project_root=project_root,
2809 )
2810 if pending_target is not None and not pending_target.exists():
2811 normalized_target = pending_target.expanduser().resolve(strict=False)
2812 return _resume_suffix_for_target(
2813 normalized_target,
2814 expect_directory=not bool(normalized_target.suffix),
2815 project_root=project_root,
2816 messages=messages,
2817 allow_inferred_child=False,
2818 )
2819 if missing_artifact is not None and missing_artifact[1]:
2820 next_planned_file = _next_missing_planned_file_within_directory(
2821 dod,
2822 target=missing_artifact[0],
2823 project_root=project_root,
2824 )
2825 if next_planned_file is not None:
2826 parent_label = missing_artifact[0].name or str(missing_artifact[0])
2827 return (
2828 f" Resume by creating `{next_planned_file.name}` now."
2829 f" It is the next missing declared output under `{parent_label}/`."
2830 f" Prefer one `write` call for `{next_planned_file}` instead of more rereads."
2831 " Make your next response the concrete mutation tool call itself, not another"
2832 " bookkeeping-only turn."
2833 )
2834 return _missing_artifact_resume_suffix(
2835 missing_artifact,
2836 project_root=project_root,
2837 messages=messages,
2838 )
2839
2840
2841 def _pending_item_handoff_prefix(
2842 next_pending: str | None,
2843 *,
2844 pending_target: Path | None,
2845 resume_target: Path | None,
2846 ) -> str:
2847 if not next_pending:
2848 return ""
2849 if (
2850 pending_target is None
2851 and resume_target is not None
2852 and resume_target.suffix
2853 and todo_describes_aggregate_mutation(next_pending)
2854 and not todo_describes_broad_setup_step(next_pending)
2855 ):
2856 return f" Continue with the next concrete output: `{resume_target.name}`."
2857 return f" Continue with the next pending item: `{next_pending}`."
2858
2859
2860 def _preferred_pending_target_path(
2861 dod: DefinitionOfDone,
2862 *,
2863 next_pending: str | None,
2864 project_root: Path,
2865 ) -> Path | None:
2866 if not next_pending:
2867 return None
2868 pending_target = infer_pending_todo_output_target(
2869 dod,
2870 next_pending,
2871 project_root=project_root,
2872 )
2873 if pending_target is None:
2874 return None
2875 return pending_target.expanduser().resolve(strict=False)
2876
2877
2878 def _preferred_resume_target_path(
2879 dod: DefinitionOfDone,
2880 *,
2881 next_pending: str | None,
2882 missing_artifact: tuple[Path, bool] | None,
2883 project_root: Path,
2884 messages: list[Any] | None = None,
2885 ) -> Path | None:
2886 if next_pending:
2887 pending_target = infer_pending_todo_output_target(
2888 dod,
2889 next_pending,
2890 project_root=project_root,
2891 )
2892 if pending_target is not None and not pending_target.exists():
2893 return pending_target.expanduser().resolve(strict=False)
2894
2895 if missing_artifact is None:
2896 return None
2897
2898 target, expect_directory = missing_artifact
2899 normalized_target = target.expanduser().resolve(strict=False)
2900 if not expect_directory:
2901 return normalized_target
2902
2903 next_planned_file = _next_missing_planned_file_within_directory(
2904 dod,
2905 target=normalized_target,
2906 project_root=project_root,
2907 )
2908 if next_planned_file is not None:
2909 return next_planned_file.expanduser().resolve(strict=False)
2910
2911 next_output_file, _ = infer_next_output_file(
2912 target=normalized_target,
2913 project_root=project_root,
2914 messages=list(messages or []),
2915 )
2916 if next_output_file is not None:
2917 return next_output_file.expanduser().resolve(strict=False)
2918 return normalized_target
2919
2920
2921 def _invalid_mutation_call_shape(tool_name: str) -> str:
2922 if tool_name == "write":
2923 return "`write(file_path=..., content=...)`"
2924 if tool_name == "edit":
2925 return "`edit(file_path=..., old_string=..., new_string=...)`"
2926 if tool_name == "patch":
2927 return "`patch(file_path=..., patch='...')` or `patch(..., hunks=[...])`"
2928 return f"`{tool_name}(...)`"
2929
2930
2931 def _extract_blocked_html_target_list(event_content: str, marker: str) -> list[str]:
2932 if marker not in event_content:
2933 return []
2934 tail = event_content.split(marker, 1)[1].strip()
2935 target_text = tail.split(". ", 1)[0].strip()
2936 if not target_text:
2937 return []
2938 return [item.strip() for item in target_text.split(",") if item.strip()]
2939
2940
2941 def _count_recent_blocked_html_asset_events(
2942 messages: list[Any],
2943 missing_assets: list[str],
2944 ) -> int:
2945 if not missing_assets:
2946 return 0
2947
2948 count = 0
2949 for message in reversed(messages[-12:]):
2950 content = str(getattr(message, "content", "") or "")
2951 if "HTML local asset references do not exist" not in content:
2952 continue
2953 if any(asset and asset in content for asset in missing_assets):
2954 count += 1
2955 return count
2956
2957
2958 def _resume_suffix_for_target(
2959 target: Path,
2960 *,
2961 expect_directory: bool,
2962 project_root: Path,
2963 messages: list[Any] | None = None,
2964 allow_inferred_child: bool = True,
2965 ) -> str:
2966 label = target.name or str(target)
2967 display_target = display_runtime_path(target)
2968 if expect_directory and not label.endswith("/"):
2969 label += "/"
2970 if expect_directory:
2971 if allow_inferred_child:
2972 next_output_file, next_output_source = infer_next_output_file(
2973 target=target,
2974 project_root=project_root,
2975 messages=list(messages or []),
2976 )
2977 if next_output_file is not None:
2978 guidance_origin = (
2979 f"It is the next missing declared output under `{label}`."
2980 if next_output_source == "declared"
2981 else (
2982 "It mirrors the observed filename pattern from another "
2983 f"`{label}` directory you already inspected."
2984 )
2985 )
2986 guidance = (
2987 f" Resume by creating `{next_output_file.name}` now. {guidance_origin} "
2988 f"Prefer one `write` call for "
2989 f"`{display_runtime_path(next_output_file)}` instead of more rereads."
2990 )
2991 if not next_output_file.parent.exists():
2992 guidance += (
2993 " The `write` tool can create that file's parent directories automatically,"
2994 " so do the write in one step instead of stopping for a separate mkdir."
2995 )
2996 guidance += (
2997 " Make your next response the concrete mutation tool call itself, not another"
2998 " bookkeeping-only turn."
2999 )
3000 return guidance
3001 if target.is_dir():
3002 return (
3003 f" Resume by creating the next output file under `{label}` now. Prefer one "
3004 f"concrete `write` call for a file inside `{display_target}` instead of more rereads."
3005 " Make your next response the concrete mutation tool call itself, not another"
3006 " bookkeeping-only turn."
3007 )
3008 return (
3009 f" Resume by creating `{label}` now. Prefer one concrete directory-creation "
3010 f"step for `{display_target}` instead of more rereads."
3011 )
3012 guidance = (
3013 f" Resume by creating `{label}` now. Prefer one `write` call for `{display_target}` "
3014 "instead of more rereads."
3015 )
3016 if not target.parent.exists():
3017 guidance += (
3018 " The `write` tool can create that file's parent directories automatically,"
3019 " so do the write in one step instead of stopping for a separate mkdir."
3020 )
3021 guidance += (
3022 " Make your next response the concrete mutation tool call itself, not another"
3023 " bookkeeping-only turn."
3024 )
3025 return guidance
3026
3027
3028 def _compact_missing_artifact_handoff(
3029 missing_artifact: tuple[Path, bool] | None,
3030 *,
3031 project_root: Path,
3032 messages: list[Any] | None = None,
3033 encourage_initial_version: bool = False,
3034 ) -> str:
3035 """Build a shorter first-mutation handoff once the next output target is known."""
3036
3037 if missing_artifact is None:
3038 return ""
3039
3040 target, expect_directory = missing_artifact
3041 label = target.name or str(target)
3042 display_target = display_runtime_path(target)
3043 if expect_directory and not label.endswith("/"):
3044 label += "/"
3045 if expect_directory:
3046 next_output_file, _ = infer_next_output_file(
3047 target=target,
3048 project_root=project_root,
3049 messages=list(messages or []),
3050 )
3051 if next_output_file is None:
3052 if target.is_dir():
3053 return (
3054 f"Next step: create the next output file under `{label}`. Prefer one "
3055 f"concrete `write` call inside `{display_target}` now."
3056 )
3057 return (
3058 f"Next step: create `{label}`. Prefer one concrete directory-creation step "
3059 f"for `{display_target}` now."
3060 )
3061 guidance = (
3062 f"Next step: create `{next_output_file.name}`. Prefer one "
3063 f"`write(file_path=..., content=...)` call for `{display_runtime_path(next_output_file)}` now."
3064 )
3065 if not next_output_file.parent.exists():
3066 guidance += (
3067 " The `write` tool can create that file's parent directories automatically."
3068 )
3069 if encourage_initial_version:
3070 guidance += (
3071 " Write a compact but real initial version of that file now; you can expand "
3072 "or refine it in later edits."
3073 )
3074 guidance += " Make your next response the concrete mutation tool call itself."
3075 return guidance
3076
3077 guidance = (
3078 f"Next step: create `{label}`. Prefer one "
3079 f"`write(file_path=..., content=...)` call for `{display_target}` now."
3080 )
3081 if not target.parent.exists():
3082 guidance += (
3083 " The `write` tool can create that file's parent directories automatically."
3084 )
3085 if encourage_initial_version:
3086 guidance += (
3087 " Write a compact but real initial version of that file now; you can expand "
3088 "or refine it in later edits."
3089 )
3090 guidance += " Make your next response the concrete mutation tool call itself."
3091 return guidance
3092
3093
3094 def _todo_refresh_guidance(
3095 dod: DefinitionOfDone,
3096 *,
3097 project_root: Path | None = None,
3098 ) -> str:
3099 non_special_pending = [
3100 item
3101 for item in effective_pending_todo_items(dod, project_root=project_root)
3102 if item not in _TODO_NUDGE_EXCLUDED_ITEMS
3103 ]
3104 non_special_completed = [
3105 item for item in dod.completed_items if item not in _TODO_NUDGE_EXCLUDED_ITEMS
3106 ]
3107 if len(dod.touched_files) < 2 and (len(non_special_pending) + len(non_special_completed)) < 3:
3108 return ""
3109 return (
3110 " If the tracked steps no longer match the confirmed progress, refresh `TodoWrite` "
3111 "in the same response as the next concrete step instead of spending a full turn on "
3112 "bookkeeping alone."
3113 )
3114
3115
3116 def _mark_verification_stale(
3117 *,
3118 context: RuntimeContext,
3119 summary: TurnSummary,
3120 dod: DefinitionOfDone,
3121 tool_call: ToolCall,
3122 ) -> None:
3123 detail = _stale_verification_detail(tool_call)
3124 stale_attempt = ensure_active_verification_attempt(dod)
3125 next_attempt = begin_new_verification_attempt(
3126 dod,
3127 supersedes_attempt_id=stale_attempt.attempt_id,
3128 )
3129 append_verification_timeline_entry(
3130 context,
3131 summary,
3132 reason_code="verification_stale",
3133 reason_summary="previous verification became stale after new mutating work",
3134 evidence_summary=[f"fresh verification required after {detail}"],
3135 evidence_provenance=_stale_verification_provenance(dod, detail=detail),
3136 verification_observations=_stale_verification_observations(
3137 dod,
3138 detail=detail,
3139 stale_attempt_id=stale_attempt.attempt_id,
3140 stale_attempt_number=stale_attempt.attempt_number,
3141 superseded_by_attempt_id=next_attempt.attempt_id,
3142 ),
3143 )
3144 dod.last_verification_result = VerificationObservationStatus.STALE.value
3145 dod.evidence = []
3146 while _VERIFY_ITEM in dod.completed_items:
3147 dod.completed_items.remove(_VERIFY_ITEM)
3148 if _VERIFY_ITEM not in dod.pending_items:
3149 dod.pending_items.append(_VERIFY_ITEM)
3150
3151
3152 def _todo_is_mutation_step(label: str) -> bool:
3153 lowered = label.lower()
3154 return any(token in lowered for token in _MUTATION_TODO_HINTS)
3155
3156
3157 def _should_plan_verification_for_tool_call(
3158 dod: DefinitionOfDone,
3159 *,
3160 tool_call: ToolCall,
3161 project_root: Path,
3162 ) -> bool:
3163 actionable_pending = [
3164 item
3165 for item in effective_pending_todo_items(
3166 dod,
3167 project_root=project_root,
3168 )
3169 if item not in _TODO_NUDGE_EXCLUDED_ITEMS
3170 ]
3171 if any(
3172 _todo_is_mutation_step(item) or _todo_is_consistency_review_step(item)
3173 for item in actionable_pending
3174 ):
3175 return False
3176 if tool_call.name in {"write", "edit", "patch"}:
3177 return True
3178 if tool_call.name != "bash":
3179 return False
3180 if any(
3181 Path(path).expanduser().resolve(strict=False).suffix
3182 for path in dod.touched_files
3183 if str(path).strip()
3184 ):
3185 return True
3186 return any(
3187 not expect_directory
3188 and planned_artifact_target_satisfied(
3189 dod,
3190 target=target,
3191 expect_directory=False,
3192 project_root=project_root,
3193 )
3194 for target, expect_directory in collect_planned_artifact_targets(
3195 dod,
3196 project_root=project_root,
3197 max_paths=12,
3198 )
3199 )
3200
3201
3202 def _mark_verification_planned(
3203 *,
3204 context: RuntimeContext,
3205 summary: TurnSummary,
3206 dod: DefinitionOfDone,
3207 tool_call: ToolCall,
3208 ) -> None:
3209 if dod.last_verification_result in {
3210 VerificationObservationStatus.PLANNED.value,
3211 VerificationObservationStatus.PENDING.value,
3212 VerificationObservationStatus.STALE.value,
3213 }:
3214 return
3215 if not dod.verification_commands:
3216 dod.verification_commands = derive_verification_commands(
3217 dod,
3218 project_root=context.project_root,
3219 task_statement=dod.task_statement,
3220 )
3221 commands = [command for command in dod.verification_commands if command]
3222 if not commands:
3223 return
3224
3225 attempt = begin_new_verification_attempt(dod)
3226 detail = _stale_verification_detail(tool_call)
3227 append_verification_timeline_entry(
3228 context,
3229 summary,
3230 reason_code="verification_planned",
3231 reason_summary="verification is planned after new mutating work",
3232 evidence_summary=[f"verification planned for `{command}`" for command in commands[:2]],
3233 evidence_provenance=[
3234 EvidenceProvenance(
3235 category="verification",
3236 source="dod.verification_commands",
3237 summary=f"verification planned for `{command}`",
3238 status=EvidenceProvenanceStatus.MISSING.value,
3239 subject=command,
3240 detail=detail,
3241 )
3242 for command in commands
3243 ],
3244 verification_observations=[
3245 VerificationObservation(
3246 status=VerificationObservationStatus.PLANNED.value,
3247 summary=f"verification planned for `{command}`",
3248 command=command,
3249 kind="runtime",
3250 detail=detail,
3251 attempt_id=attempt.attempt_id,
3252 attempt_number=attempt.attempt_number,
3253 )
3254 for command in commands
3255 ],
3256 )
3257 dod.last_verification_result = VerificationObservationStatus.PLANNED.value
3258 while _VERIFY_ITEM in dod.completed_items:
3259 dod.completed_items.remove(_VERIFY_ITEM)
3260 if _VERIFY_ITEM not in dod.pending_items:
3261 dod.pending_items.append(_VERIFY_ITEM)
3262
3263
3264 def _stale_verification_observations(
3265 dod: DefinitionOfDone,
3266 *,
3267 detail: str,
3268 stale_attempt_id: str,
3269 stale_attempt_number: int,
3270 superseded_by_attempt_id: str,
3271 ) -> list[VerificationObservation]:
3272 return [
3273 VerificationObservation(
3274 status=VerificationObservationStatus.STALE.value,
3275 summary=f"verification became stale for `{command}` after new mutating work",
3276 command=command,
3277 kind="runtime",
3278 detail=detail,
3279 attempt_id=stale_attempt_id,
3280 attempt_number=stale_attempt_number,
3281 supersedes_attempt_id=superseded_by_attempt_id,
3282 )
3283 for command in _stale_verification_commands(dod)
3284 ]
3285
3286
3287 def _stale_verification_provenance(
3288 dod: DefinitionOfDone,
3289 *,
3290 detail: str,
3291 ) -> list[EvidenceProvenance]:
3292 return [
3293 EvidenceProvenance(
3294 category="verification",
3295 source="tool_execution",
3296 summary=f"fresh verification required for `{command}` after new mutating work",
3297 status=EvidenceProvenanceStatus.MISSING.value,
3298 subject=command,
3299 detail=detail,
3300 )
3301 for command in _stale_verification_commands(dod)
3302 ]
3303
3304
3305 def _stale_verification_commands(dod: DefinitionOfDone) -> list[str]:
3306 commands = [command for command in dod.verification_commands if command]
3307 if commands:
3308 return commands
3309 observed = [evidence.command for evidence in dod.evidence if evidence.command]
3310 if observed:
3311 return observed
3312 return ["verification"]
3313
3314
3315 def _stale_verification_detail(tool_call: ToolCall) -> str:
3316 if tool_call.name in {"write", "edit", "patch"}:
3317 file_path = str(tool_call.arguments.get("file_path", "")).strip()
3318 if file_path:
3319 return f"{tool_call.name} changed {file_path}"
3320 if tool_call.name == "bash":
3321 command = str(tool_call.arguments.get("command", "")).strip()
3322 if command:
3323 return f"bash ran `{command}`"
3324 return f"{tool_call.name} changed the workspace"
3325
3326
3327 def _current_mutation_label(tool_call: ToolCall) -> str:
3328 if tool_call.name in {"write", "edit", "patch"}:
3329 file_path = str(tool_call.arguments.get("file_path", "")).strip()
3330 if file_path:
3331 return f"`{Path(file_path).name or file_path}`"
3332 if tool_call.name == "bash":
3333 command = str(tool_call.arguments.get("command", "")).strip()
3334 if command:
3335 return f"`{command}`"
3336 return f"the successful `{tool_call.name}` result"
3337
3338
3339 def _is_pure_directory_creation_tool_call(tool_call: ToolCall) -> bool:
3340 if tool_call.name != "bash":
3341 return False
3342 command = str(tool_call.arguments.get("command", "")).strip()
3343 if not command or any(
3344 operator in command for operator in ("&&", "||", ";", "|", "$(", ">", "<")
3345 ):
3346 return False
3347 try:
3348 parts = shlex.split(command)
3349 except ValueError:
3350 return False
3351 return bool(parts) and parts[0] == "mkdir"
3352
3353
3354 def _recent_recovery_prompt(messages: list[Any]) -> bool:
3355 for message in reversed(messages[-4:]):
3356 role = getattr(message, "role", None)
3357 if getattr(role, "value", role) != "user":
3358 continue
3359 content = getattr(message, "content", "")
3360 if not isinstance(content, str):
3361 continue
3362 if content.startswith("[EMPTY ASSISTANT RESPONSE]"):
3363 return True
3364 if content.startswith("[CONTINUE CURRENT STEP]"):
3365 return True
3366 return False
3367
3368
3369 def _is_recoverable_guidance_block(event_content: str) -> bool:
3370 """Return whether a blocked observation should steer without tripping fatal error limits."""
3371
3372 normalized = str(event_content or "")
3373 return (
3374 "[Blocked - completed artifact set scope:" in normalized
3375 or "[Blocked - post-build audit loop:" in normalized
3376 )
3377
3378
3379 def _recent_edit_string_mismatch_target(recovery_context: RecoveryContext | None) -> str:
3380 """Return the active edit target when recovery is from an old_string miss."""
3381
3382 if recovery_context is None:
3383 return ""
3384 for attempt in reversed(recovery_context.attempts):
3385 if attempt.tool_name != "edit":
3386 continue
3387 if "old_string not found" not in str(attempt.error or "").lower():
3388 continue
3389 target = str(
3390 attempt.arguments.get("file_path")
3391 or attempt.arguments.get("path")
3392 or ""
3393 ).strip()
3394 if target:
3395 return target
3396 return ""
3397
3398
3399 def _repair_context_is_html_quality(repair: Any) -> bool:
3400 """Return whether the active repair context is for generated HTML quality."""
3401
3402 return any(
3403 _repair_line_is_html_quality(line)
3404 for line in getattr(repair, "repair_lines", ()) or ()
3405 )
3406
3407
3408 def _repair_line_is_html_quality(line: str) -> bool:
3409 lowered = str(line or "").lower()
3410 return (
3411 "thin content" in lowered
3412 or "insufficient structured content" in lowered
3413 or "content-quality" in lowered
3414 or "content quality" in lowered
3415 )
3416
3417
3418 def _next_quality_repair_path(repair: Any, *, changed_path: str) -> str:
3419 """Return the next concrete repair file after a successful quality mutation."""
3420
3421 try:
3422 normalized_changed = str(Path(changed_path).expanduser().resolve(strict=False))
3423 except (OSError, RuntimeError, ValueError):
3424 normalized_changed = str(Path(changed_path).expanduser())
3425
3426 normalized_paths: list[str] = []
3427 for raw_path in getattr(repair, "allowed_paths", ()) or ():
3428 try:
3429 normalized = str(Path(raw_path).expanduser().resolve(strict=False))
3430 except (OSError, RuntimeError, ValueError):
3431 normalized = str(Path(raw_path).expanduser())
3432 if normalized and normalized not in normalized_paths:
3433 normalized_paths.append(normalized)
3434
3435 if normalized_changed in normalized_paths:
3436 index = normalized_paths.index(normalized_changed)
3437 if index + 1 < len(normalized_paths):
3438 return normalized_paths[index + 1]
3439 for normalized in normalized_paths:
3440 if normalized != normalized_changed:
3441 return normalized
3442 return ""
3443
3444
3445 def _tool_call_targets_path(tool_call: ToolCall, target: str) -> bool:
3446 if not target:
3447 return False
3448 candidate = str(
3449 tool_call.arguments.get("file_path")
3450 or tool_call.arguments.get("path")
3451 or ""
3452 ).strip()
3453 if not candidate and tool_call.name == "bash":
3454 paths = _extract_bash_paths(
3455 str(tool_call.arguments.get("command") or "").strip(),
3456 )
3457 candidate = paths[0] if paths else ""
3458 if not candidate:
3459 return False
3460 try:
3461 return Path(candidate).expanduser().resolve(strict=False) == Path(
3462 target
3463 ).expanduser().resolve(strict=False)
3464 except (OSError, RuntimeError, ValueError):
3465 return candidate == target
3466
3467
3468 def _active_repair_focus_preview(repair_lines: list[str], *, max_lines: int = 4) -> str:
3469 """Compact repair-focus bullets for steering after no-op mutations."""
3470
3471 preview: list[str] = []
3472 for raw_line in repair_lines:
3473 line = str(raw_line or "").strip()
3474 if not line.startswith("- "):
3475 continue
3476 if line.startswith("- Immediate next step:"):
3477 continue
3478 preview.append(line[2:].strip())
3479 if len(preview) >= max_lines:
3480 break
3481 if not preview:
3482 return "the active verifier repair focus"
3483 return "; ".join(preview)
3484
3485
3486 def _quality_repair_issue_for_target(repair: Any, target: str) -> str:
3487 """Return the most relevant content-quality repair line for a target path."""
3488
3489 target_text = str(target or "").strip()
3490 try:
3491 normalized_target = str(Path(target_text).expanduser().resolve(strict=False))
3492 except (OSError, RuntimeError, ValueError):
3493 normalized_target = str(Path(target_text).expanduser()) if target_text else ""
3494
3495 first_quality_line = ""
3496 for raw_line in getattr(repair, "repair_lines", ()) or ():
3497 line = str(raw_line or "").strip()
3498 if not line:
3499 continue
3500 clean_line = line[2:].strip() if line.startswith("- ") else line
3501 if not _repair_line_is_html_quality(clean_line):
3502 continue
3503 if not first_quality_line:
3504 first_quality_line = clean_line
3505 if target_text and target_text in clean_line:
3506 return clean_line
3507 if normalized_target and normalized_target in clean_line:
3508 return clean_line
3509 for candidate in re.findall(r"`([^`]+)`", clean_line):
3510 try:
3511 normalized_candidate = str(
3512 Path(candidate).expanduser().resolve(strict=False)
3513 )
3514 except (OSError, RuntimeError, ValueError):
3515 normalized_candidate = str(Path(candidate).expanduser())
3516 if normalized_target and normalized_candidate == normalized_target:
3517 return clean_line
3518
3519 return first_quality_line
3520
3521
3522 def _tool_call_label(tool_call: ToolCall) -> str:
3523 """Human-readable label for one tool call."""
3524 name = tool_call.name
3525 if name in ("write", "edit", "patch"):
3526 path = str(tool_call.arguments.get("file_path", "")).strip()
3527 if path:
3528 short = Path(path).name
3529 verb = "Write" if name == "write" else "Edit"
3530 return f"{verb} {short}"
3531 if name == "bash":
3532 cmd = str(tool_call.arguments.get("command", "")).strip()
3533 if cmd:
3534 return f"Run {cmd[:40]}"
3535 if name == "read":
3536 path = str(tool_call.arguments.get("file_path", "")).strip()
3537 if path:
3538 return f"Read {Path(path).name}"
3539 if name == "glob":
3540 pattern = str(tool_call.arguments.get("pattern", "")).strip()
3541 if pattern:
3542 return f"Search {pattern[:30]}"
3543 return ""
3544
3545
3546 def _batch_planned_labels(tool_calls: list[ToolCall]) -> list[str]:
3547 """Build labels for all tool calls in a batch (for upfront planning display)."""
3548 labels = []
3549 for tc in tool_calls:
3550 label = _tool_call_label(tc)
3551 if label and label not in labels:
3552 labels.append(label)
3553 return labels