Skip to content

Commit ce50d05

Browse files
nhortonclaude
andauthored
fix: execute review: blocks on type: string step outputs (#350) (#351)
* fix: execute review: blocks on type: string step outputs (#350) Previously, review: blocks declared on type: string outputs were silently dropped because the review pipeline only matched ReviewRule patterns against file paths. Authors got no warning and the review appeared correctly configured. This was especially problematic given that the job_yml DeepSchema directs authors toward type: string for transient inter-step data. String outputs with review blocks now produce synthetic ReviewTask objects with the value carried on a new ReviewTask.inline_content field. The value is rendered into the instruction file as a "Content to Review" section and mixed into the review_id content hash so each distinct value gets its own pass-cache key (cache invalidates on value change, persists when unchanged). Adds: - ReviewTask.inline_content field - build_string_output_review_tasks() in quality_gate, wired into run_quality_gate - Shared _build_preamble() helper to deduplicate common_job_info+inputs logic - Requirements: JOBS-REQ-004.8, REVIEW-REQ-005.1.8, REVIEW-REQ-009.1.7 - 18 new tests (unit + end-to-end via run_quality_gate) Fixes #350 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: format inline-content review tasks as "inline content" (not "0 files") Follow-up to the issue #350 string-output review fix. The formatter's _task_name() used len(files_to_review) to describe task scope, which fell through to the multi-file branch for inline-content tasks and produced "review of 0 files" — cosmetic wart visible in the parallel-task listing. Also adds a bespoke test_job (.deepwork/jobs/test_job/) that exercises the full quality gate end-to-end with four outputs covering every review-level combination: file_path + output-ref review, string + output-ref review (NEW), string + arg-level review (NEW, inherited), and string + no review (control). Used as a manual smoke test when verifying the fix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: apply ruff format to satisfy CI format check CI runs `ruff format --check`, which my local `uv run ruff check` calls did not exercise (check runs the linter, not the formatter). Two lines I had manually broken up were re-joined by ruff format. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: split Makefile lint target into lint (check) and lint-fix (auto-fix) Previously `make lint` ran `ruff format` and `ruff check --fix` — both in auto-fix mode — so it silently mutated files instead of reporting issues. CI, on the other hand, runs `ruff format --check` and `ruff check` in check-only mode, which is what actually fails a PR. That asymmetry meant a clean local `make lint` gave no guarantee that CI would pass: running it would just reformat files in-place, and the developer might miss the uncommitted changes. Splits into two targets: - `make lint` now mirrors CI (ruff format --check, ruff check, mypy). A clean run guarantees the CI Lint job will pass. - `make lint-fix` is the previous behavior (auto-fix formatter + linter) for convenient local cleanup. Caught while working on #350 — my local `ruff check` calls did not run the formatter at all, and even `make lint` would have only silently reformatted the files, leaving a surprise CI failure. 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 b872dc9 commit ce50d05

14 files changed

Lines changed: 714 additions & 27 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Test Job Report
2+
3+
This is a short markdown report produced by the test_job workflow to exercise
4+
the file_path review path of the DeepWork quality-gate review system.

.deepwork/jobs/test_job/job.yml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
name: test_job
2+
summary: "Exercise the DeepWork quality-gate review system with string and file_path outputs to verify string-output reviews run"
3+
4+
step_arguments:
5+
- name: report_file
6+
description: "A short markdown report file produced by the step — exercises the file_path review path."
7+
type: file_path
8+
9+
- name: summary_text
10+
description: "A 1-2 sentence narrative summary of the step's work — exercises a string output review defined at the step output-ref level."
11+
type: string
12+
13+
- name: metrics_text
14+
description: "A concise metrics blurb containing at least one numeric value — exercises a string output review defined at the step_argument level (inherited by any step that outputs it)."
15+
type: string
16+
review:
17+
strategy: individual
18+
instructions: |
19+
Verify the value is a concise metrics summary of 1-3 sentences that contains at
20+
least one numeric value. Flag it if the value is empty, a placeholder, or does
21+
not mention any numbers.
22+
23+
- name: notes_text
24+
description: "A freeform notes string with no review attached — control case that should produce no review task."
25+
type: string
26+
27+
workflows:
28+
test_review_system:
29+
summary: "Produce four outputs of mixed types and review levels to verify the quality gate reviews them correctly."
30+
common_job_info_provided_to_all_steps_at_runtime: |
31+
This bespoke test job verifies that the DeepWork quality-gate review system
32+
executes reviews on string outputs as well as file_path outputs. Four outputs are
33+
produced in a single step, each hitting a distinct review code path. The control
34+
case (notes_text) must NOT produce a review task.
35+
steps:
36+
- name: produce_outputs
37+
instructions: |
38+
# Produce test outputs for the quality gate
39+
40+
Create each of the four outputs below, then call `finished_step` with all four
41+
values. Keep the content short.
42+
43+
1. **report_file**: write a small markdown file at
44+
`.deepwork/jobs/test_job/fixtures/report.md` (create the parent directory
45+
first if needed). Two or three lines — a heading plus a sentence is fine.
46+
47+
2. **summary_text**: a 1-2 sentence narrative summary describing the work
48+
performed in this step.
49+
50+
3. **metrics_text**: a 1-3 sentence metrics blurb that includes at least one
51+
number (e.g., "Produced 4 outputs, 3 reviewed, in under 1 second.").
52+
53+
4. **notes_text**: any freeform string — this one has no review attached.
54+
55+
Expected quality-gate result: three review tasks (one per output that has a
56+
review configured). `notes_text` should not produce a review task.
57+
outputs:
58+
report_file:
59+
review:
60+
strategy: individual
61+
instructions: |
62+
Verify the file is a valid markdown document with a recognisable heading
63+
or non-empty first line. Flag it if the file is empty or contains no
64+
markdown structure.
65+
summary_text:
66+
review:
67+
strategy: individual
68+
instructions: |
69+
Verify the value is a non-empty narrative summary of 1-2 sentences
70+
written in plain English that describes some concrete outcome of the
71+
step. Flag it if the value is empty, a placeholder, or does not describe
72+
anything.
73+
metrics_text: {}
74+
notes_text: {}

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2525
### Fixed
2626

2727
- DeepSchema PostToolUse hook (`deepschema_write`) no longer reports `File is not valid JSON` for YAML files whose name has no extension (e.g. `.deepreview`). The hook now parses target files and the referenced JSON Schema as YAML, which is a superset of JSON, so both formats are accepted regardless of file extension. DW-REQ-011.7.3 updated to match. (Mirrors the fix shipped in #338 for the workflow quality gate.)
28+
- `review:` blocks declared on `type: string` step outputs are now actually executed. Previously they were silently dropped because the review pipeline only matched against file paths, leaving authors with misconfigured-but-silent quality gates. String output reviews now produce synthetic `ReviewTask` objects with the string value carried on a new `ReviewTask.inline_content` field and rendered into the instruction file as a "Content to Review" section. New requirements: JOBS-REQ-004.8, REVIEW-REQ-005.1.8, REVIEW-REQ-009.1.7. (#350)
2829

2930
### Removed
3031
## [0.13.1] - 2026-04-06

Makefile

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
1+
# `make lint` mirrors CI (.github/workflows/validate.yml) in check-only mode
2+
# so that a clean local run guarantees CI will pass. Use `make lint-fix` to
3+
# auto-fix formatter and linter issues locally.
4+
5+
.PHONY: lint lint-fix
6+
17
lint:
2-
@echo "## make lint output"
8+
@echo "## make lint output (check-only — matches CI)"
9+
uv run ruff format --check src/ tests/
10+
uv run ruff check src/ tests/
11+
uv run mypy src/
12+
13+
lint-fix:
14+
@echo "## make lint-fix output (auto-fix)"
315
uv run ruff format src/ tests/
416
uv run ruff check --fix src/ tests/
517
uv run mypy src/

specs/deepwork/jobs/JOBS-REQ-004-quality-review-system.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ The quality review system evaluates step outputs against defined quality criteri
2929
4. Each rule's `instructions` MUST be prefixed with a preamble containing workflow `common_job_info` and input context (if available).
3030
5. Outputs with no review blocks MUST be skipped.
3131
6. Outputs with `None` values MUST be skipped.
32-
7. Only `file_path` type arguments with actual file paths MUST generate `ReviewRule` objects.
32+
7. Only `file_path` type arguments with actual file paths MUST generate `ReviewRule` objects. Reviews declared on `type: string` outputs are handled separately per JOBS-REQ-004.8.
3333

3434
### JOBS-REQ-004.4: Process Requirements
3535

@@ -64,3 +64,12 @@ The quality review system evaluates step outputs against defined quality criteri
6464
3. For `string` inputs, values MUST be shown inline.
6565
4. Inputs with no value MUST be shown as "not available".
6666
5. If the step has no inputs, an empty string MUST be returned.
67+
68+
### JOBS-REQ-004.8: String Output Review Tasks
69+
70+
1. When a step output has `type: string` and a review block (either on the `StepOutputRef` or inherited from the `StepArgument`), `run_quality_gate()` MUST produce a synthetic `ReviewTask` whose content-to-review is the string value itself. Such reviews MUST NOT be silently ignored.
71+
2. The synthetic task MUST bypass file-pattern matching; the string value MUST be carried on `ReviewTask.inline_content` so the reviewer agent sees it inlined in its instruction file (per REVIEW-REQ-005.1.8).
72+
3. If a string output has reviews at both the `StepOutputRef` level and the `StepArgument` level, both MUST produce separate synthetic tasks, suffixed `_arg` for the argument-level task to match the file_path naming convention.
73+
4. The synthetic task's instructions MUST be prefixed with the same preamble used for file-based reviews (workflow `common_job_info` and input context).
74+
5. String outputs with `None` values MUST be skipped.
75+
6. The synthetic task's `review_id` MUST incorporate the string value into its content hash so that distinct string values produce distinct cache keys (per REVIEW-REQ-009.1.7).

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ 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.
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.
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`.
1819

1920
### REVIEW-REQ-005.2: File Path Formatting
2021

specs/deepwork/review/REVIEW-REQ-009-review-pass-caching.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ The mechanism relies on a deterministic `review_id` that encodes the rule name,
2020
4. The content hash MUST be the first 12 hex characters of the SHA-256 digest of the concatenated contents of all files to review, with files sorted alphabetically before concatenation.
2121
5. Files that cannot be read MUST contribute the placeholder string `MISSING` instead of their contents.
2222
6. The same inputs (rule name, file paths, file contents) MUST always produce the same `review_id`.
23+
7. For inline-content tasks (where `files_to_review` is empty and `inline_content` is set — used for `type: string` step outputs per JOBS-REQ-004.8), the file paths component MUST be the literal `inline` and the content hash MUST be derived from the inline string value so that distinct string values produce distinct `review_id`s.
2324

2425
### REVIEW-REQ-009.2: `mark_review_as_passed` MCP Tool
2526

src/deepwork/jobs/mcp/quality_gate.py

Lines changed: 113 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,27 @@ def _collect_output_file_paths(
9292
return paths
9393

9494

95+
def _build_preamble(
96+
step: WorkflowStep,
97+
job: JobDefinition,
98+
workflow: Workflow,
99+
input_values: dict[str, ArgumentValue],
100+
) -> str:
101+
"""Build the preamble prefixed to every dynamic review's instructions.
102+
103+
Combines workflow ``common_job_info`` and the rendered step inputs.
104+
Returns an empty string when neither is available.
105+
"""
106+
input_context = _build_input_context(step, job, input_values)
107+
common_info = workflow.common_job_info or ""
108+
preamble_parts: list[str] = []
109+
if common_info:
110+
preamble_parts.append(f"## Job Context\n\n{common_info}")
111+
if input_context:
112+
preamble_parts.append(input_context)
113+
return "\n\n".join(preamble_parts)
114+
115+
95116
def _build_input_context(
96117
step: WorkflowStep,
97118
job: JobDefinition,
@@ -145,16 +166,7 @@ def build_dynamic_review_rules(
145166
targets.
146167
"""
147168
rules: list[ReviewRule] = []
148-
input_context = _build_input_context(step, job, input_values)
149-
common_info = workflow.common_job_info or ""
150-
151-
# Build preamble with common info and inputs
152-
preamble_parts: list[str] = []
153-
if common_info:
154-
preamble_parts.append(f"## Job Context\n\n{common_info}")
155-
if input_context:
156-
preamble_parts.append(input_context)
157-
preamble = "\n\n".join(preamble_parts)
169+
preamble = _build_preamble(step, job, workflow, input_values)
158170

159171
# Process each output
160172
for output_name, output_ref in step.outputs.items():
@@ -289,6 +301,83 @@ def build_dynamic_review_rules(
289301
return rules
290302

291303

304+
def build_string_output_review_tasks(
305+
step: WorkflowStep,
306+
job: JobDefinition,
307+
workflow: Workflow,
308+
outputs: dict[str, ArgumentValue],
309+
input_values: dict[str, ArgumentValue],
310+
project_root: Path,
311+
platform: str = "claude",
312+
) -> list[ReviewTask]:
313+
"""Build synthetic ReviewTasks for ``type: string`` outputs with review blocks.
314+
315+
String outputs have no file to match against, so they cannot flow
316+
through the normal ReviewRule/match_files_to_rules pipeline. Instead,
317+
this function creates ``ReviewTask`` objects directly, with the string
318+
value carried on ``inline_content`` so the reviewer agent sees it
319+
inlined in its instruction file.
320+
321+
Both output-ref-level and step_argument-level review blocks are
322+
honored; when both are present, the argument-level task is suffixed
323+
``_arg`` to distinguish it (matching the file_path rule naming).
324+
"""
325+
tasks: list[ReviewTask] = []
326+
preamble = _build_preamble(step, job, workflow, input_values)
327+
328+
try:
329+
source_rel = (job.job_dir / "job.yml").relative_to(project_root)
330+
except ValueError:
331+
source_rel = job.job_dir / "job.yml"
332+
source_location = f"{source_rel}:0"
333+
334+
for output_name, output_ref in step.outputs.items():
335+
arg = job.get_argument(output_name)
336+
if not arg or arg.type != "string":
337+
continue
338+
339+
review_blocks: list[ReviewBlock] = []
340+
if output_ref.review:
341+
review_blocks.append(output_ref.review)
342+
if arg.review:
343+
review_blocks.append(arg.review)
344+
345+
if not review_blocks:
346+
continue
347+
348+
value = outputs.get(output_name)
349+
if value is None:
350+
continue
351+
# Defensive: string outputs should always be str, but the runtime
352+
# value may technically be any ArgumentValue.
353+
inline_value = value if isinstance(value, str) else str(value)
354+
355+
for i, review_block in enumerate(review_blocks):
356+
full_instructions = (
357+
f"{preamble}\n\n{review_block.instructions}"
358+
if preamble
359+
else review_block.instructions
360+
)
361+
suffix = "_arg" if i > 0 else ""
362+
rule_name = f"step_{step.name}_output_{output_name}{suffix}"
363+
agent_name: str | None = None
364+
if review_block.agent is not None:
365+
agent_name = review_block.agent.get(platform)
366+
367+
tasks.append(
368+
ReviewTask(
369+
rule_name=rule_name,
370+
files_to_review=[],
371+
instructions=full_instructions,
372+
agent_name=agent_name,
373+
source_location=source_location,
374+
inline_content=inline_value,
375+
)
376+
)
377+
378+
return tasks
379+
380+
292381
def run_quality_gate(
293382
step: WorkflowStep,
294383
job: JobDefinition,
@@ -321,6 +410,19 @@ def run_quality_gate(
321410
project_root=project_root,
322411
)
323412

413+
# 2b. Build synthetic ReviewTasks for type: string outputs with review blocks.
414+
# These bypass file-pattern matching entirely — the string value is
415+
# carried on the task via inline_content so the reviewer sees it inline.
416+
string_output_tasks = build_string_output_review_tasks(
417+
step=step,
418+
job=job,
419+
workflow=workflow,
420+
outputs=outputs,
421+
input_values=input_values,
422+
project_root=project_root,
423+
platform=platform,
424+
)
425+
324426
# 3. Load .deepreview rules
325427
deepreview_rules, _errors = load_all_rules(project_root)
326428

@@ -354,7 +456,7 @@ def run_quality_gate(
354456
dynamic_tasks = match_files_to_rules(output_files, dynamic_rules, project_root, platform)
355457

356458
# 7. Combine all tasks
357-
all_tasks = dynamic_tasks + deepreview_tasks
459+
all_tasks = dynamic_tasks + string_output_tasks + deepreview_tasks
358460

359461
if not all_tasks:
360462
return None

src/deepwork/review/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class ReviewTask:
5151
additional_files: list[str] = field(default_factory=list) # Unchanged matching files
5252
all_changed_filenames: list[str] | None = None
5353
precomputed_info_bash_command: str | None = None # Resolved command to run
54+
inline_content: str | None = None # Inline string value for type: string outputs
5455

5556

5657
def parse_deepreview_file(filepath: Path) -> list[ReviewRule]:

src/deepwork/review/formatter.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,18 @@ def _task_name(task: ReviewTask) -> str:
137137
rule comes from a subdirectory .deepreview file. This disambiguates
138138
same-named rules from different directories (REVIEW-REQ-004.10).
139139
140+
For inline-content tasks (type: string step outputs per JOBS-REQ-004.8)
141+
the scope reads ``inline content`` instead of a file count.
142+
140143
Args:
141144
task: The ReviewTask to name.
142145
143146
Returns:
144147
Task name string.
145148
"""
146149
prefix = _scope_prefix(task)
150+
if not task.files_to_review and task.inline_content is not None:
151+
return f"{prefix}{task.rule_name} review of inline content"
147152
if len(task.files_to_review) == 1:
148153
return f"{prefix}{task.rule_name} review of {task.files_to_review[0]}"
149154
return f"{prefix}{task.rule_name} review of {len(task.files_to_review)} files"

0 commit comments

Comments
 (0)