Skip to content

Commit 484e1d6

Browse files
nhortonclaude
andauthored
fix: tell review agents which project root to read files from (#363)
* fix: tell review agents which project root to read files from In git-worktree setups, reviewer subagents were reading files from the main checkout instead of the worktree where the commits actually live, producing spurious findings against stale (un-refactored) content. The "@filepath" entries in the instruction file's "Files to Review" section are not auto-expanded — the reviewer must Read each file itself — so they were being resolved against whatever cwd the subagent happened to inherit. Emit an explicit "## Project Root" directive at the top of each instruction file stating the absolute project root and instructing the reviewer to prepend it when calling Read. Adds REVIEW-REQ-005.1.9 and tests validating presence, ordering, and the write_instruction_files plumbing that threads project_root through. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: address review findings + changelog entry Pre-existing code-quality issues surfaced by the reviewers on this PR (none were introduced by the original fix, but pulling them in here since the files are already under review): instructions.py: - _run_precompute_command docstring no longer claims "command" is an absolute path — it's a shell command string, invoked via shell=True - Noted the trust model for precompute shell commands (repo-sourced, never fed untrusted input) - ThreadPoolExecutor now caps at max_workers=8 so a repo with many distinct precompute commands can't briefly fork dozens of shells - _build_reference_files_section no longer re-encodes the (possibly truncated) content string to recompute its byte count; the consumed budget is now tracked explicitly as either original_byte_len or the exact remaining budget on truncation config.py: - ReviewRule.precomputed_info_bash_command field comment matches the docstring fix above test_instructions.py: - Moved five traceability comment blocks from inside the method body to the two lines directly preceding the def line, matching the convention enforced by test_file_quality - test_count_cap_triggers_omission no longer hardcodes "f20.txt" as "one of the omitted" — derives the filename from MAX_INLINE_FILES so the test stays correct if the constant changes - _sanitize_for_id import moved from a function-local line to the top-of-module import block REVIEW-REQ-005 spec: - Split five compound requirements so each numbered item carries exactly one RFC 2119 keyword (requirements_file DeepSchema rule). Preserved existing IDs (5.1.4, 5.1.8, 5.8.4–5.8.6) — each retains its primary MUST — and appended the secondary obligations as new sequential IDs (5.1.10, 5.1.11, 5.8.7–5.8.11) CHANGELOG.md: - Added entry for the worktree file-path fix under the Unreleased Fixed section Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: address remaining low-severity review findings instructions.py: - precomputed_info lookup now uses direct key access ([]) instead of .get() so a dict/filter drift between unique_commands and the per-task lookup would fail loudly instead of silently dropping precomputed context. Also aligned both predicates on `is not None` so an empty-string command would not be executed then silently discarded. - Added a docstring note to _content_hash pinning the trust contract: files entries must be repo-root-relative paths from trusted .deepreview config, with no absolute paths or .. traversal. test_instructions.py: - New module-level `instructions_dir` pytest fixture that creates `.deepwork/tmp/review_instructions/` under tmp_path. Seven tests now use it instead of repeating the two-line setup inline. - test_single_file_produces_expected_format uses direct `(tmp_path / "src").mkdir(...)` instead of the indirect `.parent.mkdir` form, matching adjacent tests in TestComputeReviewId. - Added an inline comment on the `2 * MAX_INLINE_FILES` fence-count assertion explaining the 2x relationship. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 567ae88 commit 484e1d6

5 files changed

Lines changed: 149 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
### Fixed
1717

18+
- Review instruction files now include a `## Project Root` directive stating the absolute project root so reviewer subagents read files from the correct working tree — fixes spurious findings in git-worktree setups where the subagent's cwd differed from the worktree the commits actually lived in (REVIEW-REQ-005.1.9)
19+
1820
### Removed
1921
## [0.13.3] - 2026-04-10
2022

specs/deepwork/review/REVIEW-REQ-005-instruction-generation.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@ For each `ReviewTask`, the system generates a self-contained markdown instructio
1111
1. Each instruction file MUST be a valid markdown document.
1212
2. The file MUST begin with a heading identifying the review rule and scope (e.g., `# Review: python_file_best_practices — src/app.py`).
1313
3. The file MUST contain a "Review Instructions" section with the rule's resolved instruction text.
14-
4. The file MUST contain a "Files to Review" section listing the file paths to examine when the task has at least one file to review. Inline-content tasks (see REVIEW-REQ-005.1.8) MUST NOT include a "Files to Review" section.
14+
4. The file MUST contain a "Files to Review" section listing the file paths to examine when the task has at least one file to review.
1515
5. File paths in the "Files to Review" section MUST be relative to the repository root.
1616
6. When the task has `additional_files` (unchanged matching files), the file MUST contain an "Unchanged Matching Files" section listing those file paths.
1717
7. When the task has `all_changed_filenames`, the file MUST contain an "All Changed Files" section listing every changed filename for context.
18-
8. When the task has `inline_content` set (used for `type: string` step outputs — see JOBS-REQ-004.8), the file MUST contain a "Content to Review" section whose body is the inline content verbatim. The review heading scope MUST read `inline content` when the task has `inline_content` and no `files_to_review`.
18+
8. When the task has `inline_content` set (used for `type: string` step outputs — see JOBS-REQ-004.8), the file MUST contain a "Content to Review" section whose body is the inline content verbatim.
19+
9. The file MUST contain a "Project Root" section near the top (between the header and "Review Instructions") that states the absolute path of the project root against which all relative file paths in the document are resolved, and that instructs the reviewer to prepend this root when calling the Read tool. This is required so reviewer subagents read files from the correct working tree when their current working directory differs from the project root — e.g., when the review runs against a git worktree dispatched from the main checkout.
20+
10. Inline-content tasks (see REVIEW-REQ-005.1.8) MUST NOT include a "Files to Review" section.
21+
11. The review heading scope MUST read `inline content` when the task has `inline_content` and no `files_to_review`.
1922

2023
### REVIEW-REQ-005.2: File Path Formatting
2124

@@ -61,6 +64,11 @@ For each `ReviewTask`, the system generates a self-contained markdown instructio
6164
1. When a task's `reference_files` is empty, the instruction file MUST NOT contain a "Relevant File Contents" section.
6265
2. When a task has `reference_files`, the instruction file MUST contain a "## Relevant File Contents" section placed between "Review Instructions" and "Files to Review".
6366
3. Each inlined file MUST be rendered with a `### {relative_label}` subheading, the optional description, and the file contents inside a fenced code block whose language is inferred from the file extension.
64-
4. The number of inlined reference files MUST NOT exceed `MAX_INLINE_FILES` (20). Entries beyond that cap MUST be listed in an "omitted due to size/count caps" summary line rather than inlined.
65-
5. The total inlined byte size of reference file contents MUST NOT exceed `MAX_INLINE_TOTAL_BYTES` (256 * 1024). Files whose contents would exceed the remaining byte budget MUST be truncated with a visible truncation marker, and any subsequent entries MUST be reported in the omitted summary line.
66-
6. When a referenced file cannot be read (missing, permission denied, or invalid UTF-8), the system MUST emit a graceful marker line referencing the file and the error, MUST NOT abort the section, and MUST NOT count the file's would-be bytes against the budget.
67+
4. The number of inlined reference files MUST NOT exceed `MAX_INLINE_FILES` (20).
68+
5. The total inlined byte size of reference file contents MUST NOT exceed `MAX_INLINE_TOTAL_BYTES` (256 * 1024).
69+
6. When a referenced file cannot be read (missing, permission denied, or invalid UTF-8), the system MUST emit a graceful marker line referencing the file and the error.
70+
7. Reference file entries beyond the `MAX_INLINE_FILES` cap MUST be listed in an "omitted due to size/count caps" summary line rather than inlined.
71+
8. When a reference file's content would exceed the remaining byte budget, the file MUST be truncated with a visible truncation marker.
72+
9. Reference file entries that cannot be inlined because the byte budget was exhausted by a preceding truncation MUST be reported in the omitted summary line.
73+
10. When a referenced file cannot be read, the system MUST NOT abort the "Relevant File Contents" section.
74+
11. When a referenced file cannot be read, the system MUST NOT count the file's would-be bytes against the total byte budget.

src/deepwork/review/config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ class ReviewRule:
4242
agent: dict[str, str] | None
4343
all_changed_filenames: bool
4444
unchanged_matching_files: bool
45-
precomputed_info_bash_command: str | None # Resolved absolute command path
45+
precomputed_info_bash_command: (
46+
str | None
47+
) # Shell command string (first path component resolved to absolute)
4648
source_dir: Path # Directory containing the .deepreview file
4749
source_file: Path # Path to the .deepreview file
4850
source_line: int # Line number of the rule name in the .deepreview file

src/deepwork/review/instructions.py

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ def _content_hash(files: list[str], project_root: Path, inline_content: str | No
8585
``inline_content`` is provided, it is mixed into the hash (via a
8686
sentinel marker) so that each distinct string value produces a
8787
distinct review ID.
88+
89+
``files`` entries are expected to be repo-root-relative paths sourced
90+
from trusted ``.deepreview`` config files. Absolute paths or ``..``
91+
segments are not validated here; callers MUST ensure paths stay
92+
inside ``project_root``.
8893
"""
8994
h = hashlib.sha256()
9095
for filepath in sorted(files):
@@ -105,8 +110,15 @@ def _content_hash(files: list[str], project_root: Path, inline_content: str | No
105110
def _run_precompute_command(command: str, project_root: Path) -> str:
106111
"""Run a single precompute bash command and return its stdout.
107112
113+
Precompute commands are fully trusted input sourced from the repo's own
114+
``.deepreview`` files; they run via ``shell=True`` and MUST NOT be fed
115+
any untrusted external data (e.g., user-supplied strings interpolated
116+
into the command).
117+
108118
Args:
109-
command: Resolved absolute path to the command to execute.
119+
command: Shell command string to execute. The first path component
120+
has been resolved to an absolute path by the caller; the rest
121+
of the command is passed through to the shell verbatim.
110122
project_root: Working directory for command execution.
111123
112124
Returns:
@@ -146,8 +158,10 @@ def _run_precompute_commands(commands: set[str], project_root: Path) -> dict[str
146158
if not commands:
147159
return {}
148160

161+
# Cap concurrent precompute shells so a repo with many rules does not
162+
# briefly fork dozens of subprocesses at once on CI runners.
149163
results: dict[str, str] = {}
150-
with ThreadPoolExecutor() as executor:
164+
with ThreadPoolExecutor(max_workers=8) as executor:
151165
futures = {
152166
executor.submit(_run_precompute_command, cmd, project_root): cmd for cmd in commands
153167
}
@@ -205,12 +219,15 @@ def write_instruction_files(
205219
if passed_marker.exists():
206220
continue
207221

222+
# Direct key access (not .get()): the invariant below enforces that
223+
# every non-None command produced unique_commands above MUST have a
224+
# result in precompute_results — a missing key indicates drift.
208225
precomputed_info = (
209-
precompute_results.get(task.precomputed_info_bash_command)
210-
if task.precomputed_info_bash_command
226+
precompute_results[task.precomputed_info_bash_command]
227+
if task.precomputed_info_bash_command is not None
211228
else None
212229
)
213-
content = build_instruction_file(task, review_id, precomputed_info)
230+
content = build_instruction_file(task, review_id, precomputed_info, project_root)
214231
file_path = instructions_dir / f"{review_id}.md"
215232

216233
safe_write(file_path, content)
@@ -223,6 +240,7 @@ def build_instruction_file(
223240
task: ReviewTask,
224241
review_id: str = "",
225242
precomputed_info: str | None = None,
243+
project_root: Path | None = None,
226244
) -> str:
227245
"""Build the markdown content for a single review instruction file.
228246
@@ -231,6 +249,12 @@ def build_instruction_file(
231249
review_id: The deterministic review ID for this task (used in the
232250
"After Review" section).
233251
precomputed_info: Pre-executed command output to include as context.
252+
project_root: Absolute path to the project root that all relative
253+
file paths in this instruction file resolve against. When
254+
provided, the file includes an explicit "Project Root"
255+
directive so the reviewer agent reads files from the correct
256+
working tree even when its cwd differs (e.g., in a git
257+
worktree dispatched from the main checkout).
234258
235259
Returns:
236260
Markdown string containing the complete review instructions.
@@ -241,6 +265,21 @@ def build_instruction_file(
241265
scope = _describe_scope(task)
242266
parts.append(f"# Review: {task.rule_name}{scope}\n")
243267

268+
# Project root directive — tells the reviewer where to read files from.
269+
# Critical in git-worktree setups where the agent's cwd may differ from
270+
# the worktree the commits actually live in.
271+
if project_root is not None:
272+
abs_root = project_root.resolve()
273+
parts.append("## Project Root\n")
274+
parts.append(
275+
f"**All file paths in this document are relative to `{abs_root}`.** "
276+
"When reading any file below with the Read tool, you MUST construct "
277+
"the absolute path by prepending this project root. Do NOT read files "
278+
"relative to your current working directory — it may differ from the "
279+
"project root (e.g., when this review runs against a git worktree)."
280+
)
281+
parts.append("")
282+
244283
# Review instructions
245284
parts.append("## Review Instructions\n")
246285
parts.append(task.instructions.strip())
@@ -353,17 +392,20 @@ def _build_reference_files_section(reference_files: list[ReferenceFile]) -> str:
353392
remaining = MAX_INLINE_TOTAL_BYTES - total_bytes
354393
truncated_marker = ""
355394
content_bytes = content.encode("utf-8")
356-
if len(content_bytes) > remaining:
395+
original_byte_len = len(content_bytes)
396+
if original_byte_len > remaining:
357397
# Truncate at a character boundary near the byte budget.
358398
content = content_bytes[:remaining].decode("utf-8", errors="ignore")
359399
truncated_marker = (
360-
f"\n... (truncated: file is {len(content_bytes)} bytes, "
361-
f"budget left was {remaining})"
400+
f"\n... (truncated: file is {original_byte_len} bytes, budget left was {remaining})"
362401
)
402+
consumed = remaining
403+
else:
404+
consumed = original_byte_len
363405

364406
parts.append(header)
365407
parts.append(f"\n\n```{lang}\n{content}{truncated_marker}\n```\n")
366-
total_bytes += len(content.encode("utf-8"))
408+
total_bytes += consumed
367409
inlined_count += 1
368410

369411
if omitted:

0 commit comments

Comments
 (0)