1010import logging
1111import os
1212import re
13+ import subprocess
1314from datetime import datetime
1415from pathlib import Path
1516from typing import Any , Dict , List , Optional
@@ -147,18 +148,33 @@ async def gather_evidence(
147148 project_root = self ._get_project_root (task , state , agent_id = agent_id )
148149 logger .debug (f"Project root: { project_root } " )
149150
150- # 2. Discover source files by scanning project_root
151- source_files = self ._discover_source_files (project_root )
151+ # 2. Scope evidence to git delta when a baseline_commit is available.
152+ # This prevents the token explosion caused by loading all merged-agent
153+ # files when validating a single agent's work (issue #696).
154+ allowed_files = self ._get_git_delta_files (project_root , state , agent_id )
155+
156+ # 3. Discover source files — scoped to delta or full scan as fallback
157+ source_files = self ._discover_source_files (
158+ project_root , allowed_files = allowed_files
159+ )
152160 logger .info (
153161 f"Discovered { len (source_files )} source files "
154162 f"({ sum (f .size_bytes for f in source_files )} total bytes)"
155163 )
156164
157- # 3. Get design artifacts from state
165+ # 4. Collect full file manifest (names only, no content reads).
166+ # Injected into the prompt so the LLM knows which files exist
167+ # even when their extension is excluded from SOURCE_EXTENSIONS
168+ # (e.g. pyproject.toml, Makefile). Prevents false "file missing"
169+ # reports when the delta scan limits content to .py/.js files.
170+ file_manifest = self ._collect_file_manifest (project_root )
171+ logger .info (f"File manifest: { len (file_manifest )} files in project" )
172+
173+ # 5. Get design artifacts from state
158174 design_artifacts = state .task_artifacts .get (task .id , []).copy ()
159175 logger .debug (f"Found { len (design_artifacts )} design artifacts" )
160176
161- # 4 . Get decisions from get_task_context
177+ # 6 . Get decisions from get_task_context
162178 decisions = await self ._get_decisions (task , state )
163179 logger .debug (f"Retrieved { len (decisions )} architectural decisions" )
164180
@@ -167,6 +183,7 @@ async def gather_evidence(
167183 design_artifacts = design_artifacts ,
168184 decisions = decisions ,
169185 project_root = project_root ,
186+ file_manifest = file_manifest ,
170187 collection_time = datetime .utcnow (),
171188 )
172189
@@ -451,29 +468,223 @@ def _get_project_root(
451468 ),
452469 )
453470
454- def _discover_source_files (self , project_root : str ) -> list [SourceFile ]:
471+ def _get_git_delta_files (
472+ self , project_root : str , state : Any , agent_id : str | None
473+ ) -> list [str ] | None :
474+ """Return the relative file paths changed by this agent since their baseline.
475+
476+ Uses ``git diff <baseline_commit>..HEAD --name-only`` to isolate only
477+ the files the agent wrote, preventing the entire merged codebase from
478+ being loaded into the validation prompt (issue #696).
479+
480+ Parameters
481+ ----------
482+ project_root : str
483+ Absolute path to the agent's worktree (or main repo as fallback)
484+ state : Any
485+ Marcus server state — used to look up the agent's TaskAssignment
486+ agent_id : str | None
487+ Agent performing the task
488+
489+ Returns
490+ -------
491+ list[str] | None
492+ Relative file paths in the delta. An empty list means the diff
493+ succeeded but the agent changed no files — the caller must treat
494+ this as "no source-file evidence" (a correct completion failure),
495+ NOT as a reason to fall back to the full worktree scan. None is
496+ returned only when the delta genuinely cannot be computed (no
497+ baseline, git error), where the caller falls back to a full scan.
498+ """
499+ baseline_commit : str | None = None
500+ if agent_id and hasattr (state , "agent_tasks" ):
501+ assignment = state .agent_tasks .get (agent_id )
502+ if assignment :
503+ baseline_commit = getattr (assignment , "baseline_commit" , None )
504+
505+ if not baseline_commit :
506+ logger .debug (
507+ f"No baseline_commit for agent { agent_id } — using full worktree scan"
508+ )
509+ return None
510+
511+ try :
512+ result = subprocess .run (
513+ ["git" , "diff" , f"{ baseline_commit } ..HEAD" , "--name-only" ],
514+ capture_output = True ,
515+ text = True ,
516+ cwd = project_root ,
517+ timeout = 10 ,
518+ )
519+ if result .returncode != 0 :
520+ logger .warning (
521+ f"git diff failed (exit { result .returncode } ): "
522+ f"{ result .stderr .strip ()} — falling back to full worktree scan"
523+ )
524+ return None
525+
526+ files = [f .strip () for f in result .stdout .splitlines () if f .strip ()]
527+ logger .info (
528+ f"Git delta for agent { agent_id } : { len (files )} files changed "
529+ f"since { baseline_commit [:8 ]} "
530+ )
531+ # A successful-but-empty diff means the agent committed no changes.
532+ # Return the empty list (not None) so validation surfaces the
533+ # "no source files" failure instead of scanning the entire merged
534+ # worktree and grading the agent against other agents' work.
535+ return files
536+
537+ except Exception as exc :
538+ logger .warning (
539+ f"Failed to compute git delta for agent { agent_id } : { exc } "
540+ " — falling back to full worktree scan"
541+ )
542+ return None
543+
544+ def _collect_file_manifest (self , project_root : str ) -> list [str ]:
545+ """Return relative paths of every file in project_root (names only).
546+
547+ This is the cheap half of the git-delta approach (issue #696): we
548+ scan the full worktree for filenames — no content reads — and inject
549+ the list into the validation prompt so the LLM knows which files
550+ *exist* even when their extension is excluded from SOURCE_EXTENSIONS
551+ (e.g. ``pyproject.toml``, ``Makefile``, ``.flake8``).
552+
553+ Parameters
554+ ----------
555+ project_root : str
556+ Absolute path to the project directory to scan
557+
558+ Returns
559+ -------
560+ list[str]
561+ Sorted relative file paths (POSIX separators)
562+ """
563+ manifest : list [str ] = []
564+ project_path = Path (project_root ).resolve ()
565+
566+ for root , dirs , files in os .walk (project_root ):
567+ dirs [:] = [d for d in dirs if d not in self .EXCLUDE_DIRS ]
568+ for file in files :
569+ file_path = Path (root ) / file
570+ try :
571+ resolved = file_path .resolve ()
572+ if not resolved .is_relative_to (project_path ):
573+ continue
574+ manifest .append (str (resolved .relative_to (project_path )))
575+ except (ValueError , OSError ):
576+ continue
577+
578+ return sorted (manifest )
579+
580+ def _discover_source_files (
581+ self , project_root : str , allowed_files : list [str ] | None = None
582+ ) -> list [SourceFile ]:
455583 """Discover source files by scanning project_root directory.
456584
457585 Parameters
458586 ----------
459587 project_root : str
460588 Root directory to scan
589+ allowed_files : list[str] | None
590+ When provided, only load these relative paths (git-delta scoping).
591+ When None, walk the entire project_root (legacy behaviour).
461592
462593 Returns
463594 -------
464595 list[SourceFile]
465596 Discovered source files with content
466597 """
467- logger .debug (
468- f"Scanning { project_root } for source files (excluding { self .EXCLUDE_DIRS } )"
469- )
470598 source_files : list [SourceFile ] = []
471- project_path = Path (project_root ).resolve () # Resolve to absolute path
599+ project_path = Path (project_root ).resolve ()
472600
473601 # Track total content size to prevent memory exhaustion
474602 total_content_bytes = 0
475603 MAX_TOTAL_CONTENT = 10_000_000 # 10MB total limit across all files
476604
605+ if allowed_files is not None :
606+ # Git-delta mode: only load the specific files the agent changed
607+ logger .debug (
608+ f"Git-delta mode: loading { len (allowed_files )} files "
609+ f"from { project_root } "
610+ )
611+ delta_pairs = []
612+ for rel in allowed_files :
613+ abs_p = project_path / rel
614+ try :
615+ resolved = abs_p .resolve ()
616+ except (OSError , RuntimeError ):
617+ logger .debug (f"Skipping unresolvable delta file: { rel } " )
618+ continue
619+ # Guard against a source-named symlink escaping the worktree
620+ # (e.g. leak.py -> /etc/secret). Without this, validation would
621+ # follow the link and send external file content to the LLM.
622+ # Mirrors the resolve + containment check in
623+ # _collect_file_manifest / the legacy full scan.
624+ if not resolved .is_relative_to (project_path ):
625+ logger .warning (
626+ "Skipping delta file outside project root "
627+ f"(possible symlink escape): { rel } "
628+ )
629+ continue
630+ if resolved .exists ():
631+ delta_pairs .append ((resolved , resolved ))
632+ else :
633+ logger .debug (f"Skipping missing delta file: { rel } " )
634+
635+ for file_path , resolved_path in delta_pairs :
636+ if resolved_path .suffix not in self .SOURCE_EXTENSIONS :
637+ continue
638+ try :
639+ stat = resolved_path .stat ()
640+ size_bytes = stat .st_size
641+ modified_time = datetime .fromtimestamp (stat .st_mtime )
642+
643+ if size_bytes == 0 :
644+ content = ""
645+ elif total_content_bytes + size_bytes > MAX_TOTAL_CONTENT :
646+ logger .warning (
647+ f"Skipping { resolved_path } — total content limit "
648+ f"({ MAX_TOTAL_CONTENT } bytes) would be exceeded"
649+ )
650+ continue
651+ elif size_bytes > 1_000_000 :
652+ content = resolved_path .read_text (errors = "ignore" )[:100000 ]
653+ content += "\n \n [FILE TRUNCATED - Too large for validation]"
654+ total_content_bytes += len (content )
655+ else :
656+ content = resolved_path .read_text (errors = "ignore" )
657+ total_content_bytes += len (content )
658+
659+ has_placeholders = any (
660+ pattern in content for pattern in self .PLACEHOLDER_PATTERNS
661+ )
662+ source_files .append (
663+ SourceFile (
664+ path = str (resolved_path ),
665+ relative_path = str (resolved_path .relative_to (project_path )),
666+ size_bytes = size_bytes ,
667+ content = content ,
668+ has_placeholders = has_placeholders ,
669+ extension = resolved_path .suffix ,
670+ modified_time = modified_time ,
671+ )
672+ )
673+ except Exception as exc :
674+ logger .warning (f"Failed to read { resolved_path } : { exc } " )
675+ continue
676+
677+ logger .info (
678+ f"Loaded { len (source_files )} delta files, "
679+ f"total { total_content_bytes :,} bytes"
680+ )
681+ return source_files
682+
683+ # Legacy full-worktree mode (no baseline_commit available)
684+ logger .debug (
685+ f"Full-scan mode: walking { project_root } "
686+ f"(excluding { self .EXCLUDE_DIRS } )"
687+ )
477688 for root , dirs , files in os .walk (project_root ):
478689 # Filter out excluded directories (in-place modification)
479690 dirs [:] = [d for d in dirs if d not in self .EXCLUDE_DIRS ]
@@ -1183,6 +1394,24 @@ def _build_validation_prompt(self, task: Any, evidence: WorkEvidence) -> str:
11831394 for i , criterion in enumerate (criteria , 1 ):
11841395 prompt_parts .append (f"{ i } . { criterion } " )
11851396
1397+ # Add file manifest — existence proof for every file in the project.
1398+ # This lets the LLM verify "does pyproject.toml exist?" without
1399+ # needing its content. Files in the manifest but absent from the
1400+ # source file section below exist but aren't readable (wrong
1401+ # extension or excluded by SOURCE_EXTENSIONS) — the LLM must NOT
1402+ # report them as missing. Only files absent from BOTH the manifest
1403+ # AND the source files section are truly missing.
1404+ if evidence .file_manifest :
1405+ prompt_parts .append ("\n \n PROJECT FILE MANIFEST (every file that exists):" )
1406+ for path in evidence .file_manifest :
1407+ prompt_parts .append (f" { path } " )
1408+ prompt_parts .append (
1409+ "\n NOTE: Files listed above exist in the project. "
1410+ "If a file appears in the manifest but not in the source "
1411+ "files section below, it exists but its content is not "
1412+ "shown. Do NOT report manifest-listed files as missing."
1413+ )
1414+
11861415 # Add discovered source files with full content (no truncation).
11871416 # Previously content was truncated to 8KB per file which caused
11881417 # LLM hallucinations — the validator had to infer code it
0 commit comments