Skip to content

Commit 7777f76

Browse files
authored
Merge pull request #91 from eduralph/feat/64-advisory-reviewers
feat(check): generalized advisory reviewer leaves
2 parents 0f12ce9 + d36472e commit 7777f76

8 files changed

Lines changed: 246 additions & 4 deletions

File tree

docs/05-check.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,18 @@ hands it over as **`$PDCA_TARGET`** (read-only; for a `claude` reviewer also via
104104
into other checkouts on the machine — without this it can't ground, or hunts the
105105
filesystem for "the target" (issue #75).
106106

107+
### Optional advisory reviewers (a second lens)
108+
109+
The `reviewer` judges fix *adequacy*. For other lenses — correctness bugs the patch
110+
introduces, reuse/simplification/efficiency cleanups — add **advisory reviewer leaves**
111+
(issue #64): an open `[[leaves.advisory]]` list in `pdca.toml`, each a role-distinct,
112+
model-agnostic (`family` + `argv`) leaf. Each writes `check-advisory-<id>.md`; its
113+
`- NEEDS-HUMAN —` findings fold into §6 like the reviewer's. They are **always advisory**
114+
(never gate). Condition one on a brief field with `when = { field = …, substring = … }`
115+
(e.g. run a deeper review only when the brief says so) — the way gate targets condition
116+
on the bundle. A shipped `code-review` agent realizes the correctness+cleanup lens for a
117+
`claude` instance; `family = "codex"` swaps the vendor.
118+
107119
## 3. Assembly — the SUMMARY the human signs
108120

109121
The driver folds brief + gates + review into `SUMMARY.md`, a 10-section document.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
name: code-review
3+
description: >-
4+
Optional ADVISORY code reviewer for {{ project_name }} (issue #64) — a lens the
5+
`reviewer` leaf does not cover: correctness bugs the patch introduces, and
6+
reuse / simplification / efficiency cleanups in the diff. Advisory only; it never
7+
gates. Execute + read, no write to the fix. Invoke as a configured advisory leaf.
8+
tools: Read, Bash, Grep, Glob
9+
model: inherit
10+
---
11+
12+
# Code review (Check, advisory — issue #64)
13+
14+
A **second lens** on the patch, distinct from the `reviewer` leaf (which judges fix
15+
*adequacy* — causal adequacy, scope, validation). You hunt for:
16+
17+
- **Correctness bugs the patch introduces** — off-by-one, error/edge-case handling,
18+
resource leaks, concurrency, API misuse, a test that doesn't actually exercise the fix.
19+
- **Reuse / simplification / efficiency** — duplicated logic that an existing helper
20+
already covers, a simpler equivalent, needless work in a hot path.
21+
22+
You are **advisory: you never gate accept.** Deterministic gates block; you annotate.
23+
24+
## Inputs
25+
26+
`{patch.diff, brief.md, check-gates.json}` only — **not** `build-notes.md` (don't anchor
27+
on the builder's framing). Ground every cited `path:line` on the **target source at
28+
`$PDCA_TARGET`** (read-only; the driver resolves and adds it); do not search other
29+
checkouts. You have **no Write/Edit** — you cannot patch what you judge.
30+
31+
## Output — `check-advisory-code-review.md`
32+
33+
A short list of findings, each a Markdown bullet citing `path:line`. For any finding a
34+
human must adjudicate, prefix the bullet `- NEEDS-HUMAN — ` (the harness lifts those into
35+
`SUMMARY.md` §6). Scope each finding to **this diff** — don't file pre-existing debt the
36+
patch didn't touch. If the diff is clean on both lenses, say so explicitly.

template/pdca.toml.jinja

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,22 @@ mode = "{{ leaves_mode }}"
132132
family = "claude"
133133
interactive = true
134134
argv = ["claude", "--agent", "publisher", "--permission-mode", "acceptEdits"]
135+
136+
# Optional advisory reviewer leaves (issue #64) — an OPEN list. The `reviewer` leaf above
137+
# judges fix ADEQUACY (causal adequacy, scope, validation); add role-distinct ADVISORY
138+
# reviewers here for other lenses (e.g. correctness bugs the patch introduces + reuse /
139+
# simplification / efficiency cleanups). Each writes check-advisory-<id>.md; its
140+
# `- NEEDS-HUMAN —` findings route into SUMMARY §6. Always advisory — never gates.
141+
# Decorrelate: prefer a `family` distinct from the builder AND the reviewer where you can.
142+
# `when = { field = "<brief field>", substring = "<v>" }` runs it only when that brief
143+
# field matches (e.g. a "Review depth" field an Act rule tunes) — omit to always run.
144+
# [[leaves.advisory]]
145+
# id = "code-review"
146+
# role = "correctness bugs introduced by the patch + reuse/simplification/efficiency cleanups"
147+
# mode = "{{ leaves_mode }}"
148+
# family = "claude"
149+
# argv = ["claude", "-p", "--agent", "code-review", "--permission-mode", "acceptEdits", "--allowedTools", "Read,Bash,Grep,Glob"]
150+
# when = { field = "review depth", substring = "deep" }
135151
# Deterministic Check gates (docs 04). Each check runs a shell command; it PASSES
136152
# iff the command exits 0. `scope = "repo"` runs against the working tree (and is
137153
# what CI re-runs via `pdca gates --working-tree`); `scope = "bundle"` needs the

template/src/pdca_harness/assemble.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,23 @@ def assemble_summary(d: Path, cfg: Config) -> None:
2828
if review_path.exists()
2929
else _missing_review_text()
3030
)
31-
# §6 is fed by the reviewer's NEEDS-HUMAN verdicts AND any gate that declared itself
32-
# unverifiable (issue #46) — both become `- [ ]` items the C6 guard makes the human
33-
# clear before accept.
34-
needs_human = _needs_human(review_text) + _unverifiable_items(gates)
31+
# Optional advisory reviewers (issue #64): each check-advisory-<id>.md is folded into
32+
# §5 and its NEEDS-HUMAN findings into §6, exactly like the main reviewer.
33+
advisory_paths = sorted(d.glob("check-advisory-*.md"))
34+
advisory_texts = [p.read_text(encoding="utf-8") for p in advisory_paths]
35+
36+
# §6 is fed by the reviewer's NEEDS-HUMAN verdicts, the advisory reviewers', AND any
37+
# gate that declared itself unverifiable (issue #46) — all become `- [ ]` items the C6
38+
# guard makes the human clear before accept.
39+
needs_human = _needs_human(review_text)
40+
for atext in advisory_texts:
41+
needs_human += _needs_human(atext)
42+
needs_human += _unverifiable_items(gates)
43+
44+
advisory_block = "\n".join(
45+
f"\n### Advisory — {p.stem.removeprefix('check-advisory-')}\n\n{t.strip()}"
46+
for p, t in zip(advisory_paths, advisory_texts)
47+
)
3548

3649
issue = d.name.replace("issue_", "")
3750
out = "\n".join(
@@ -60,6 +73,7 @@ def assemble_summary(d: Path, cfg: Config) -> None:
6073
"Reviewer ran without build-notes.md. Summary:",
6174
"",
6275
review_text.strip(),
76+
advisory_block,
6377
"",
6478
"## 6. NEEDS-HUMAN — items the human must clear before sign-off",
6579
_needs_human_block(needs_human),

template/src/pdca_harness/config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ class Config:
9090
issue_trailer: str = "Fixes #{id}" # commit/PR trailer; "" → none enforced
9191
repo_checkouts: dict[str, str] = field(default_factory=dict) # repo_spec → local path
9292
gates_checks: list[dict] = field(default_factory=list)
93+
# Optional advisory reviewer leaves (issue #64): an OPEN list of extra, role-distinct
94+
# advisory reviewers ([[leaves.advisory]] in pdca.toml), so an instance adds N of them
95+
# (e.g. a correctness/cleanup code-review lens) with no driver change. Each:
96+
# {id, role, family, mode, argv, when?}. Always advisory (never gates); their
97+
# NEEDS-HUMAN findings route into SUMMARY §6. ``when`` ({field, substring}) conditions
98+
# a leaf on a brief field (e.g. a "Review depth" field), the way gate targets do — empty
99+
# ⇒ always run.
100+
advisory_leaves: list[dict] = field(default_factory=list)
93101
# Delegated gates (issue #67): a host runner that single-sources its own gates
94102
# (e.g. "cargo xtask"). A check's bare ``subcmd`` is run as ``<runner> <subcmd>``, so
95103
# PDCA orchestrates the host runner instead of re-declaring the gates. "" ⇒ inline only.
@@ -171,6 +179,13 @@ def leaf(name: str) -> LeafConfig:
171179
interactive=bool(d.get("interactive", False)),
172180
)
173181

182+
# Advisory reviewer leaves (issue #64) — an open list under [[leaves.advisory]].
183+
# PDCA_LEAVES_MODE forces their mode too (CI / offline determinism).
184+
advisory_leaves = [
185+
{**spec, "mode": mode_override or spec.get("mode", "stub")}
186+
for spec in leaves.get("advisory", [])
187+
]
188+
174189
# PDCA_BUNDLE_ROOT redirects bundles to a throwaway location so an offline
175190
# `rehearse` never collides with the real `results/` a live run would use.
176191
bundle_root = root / paths.get("bundle_root", "results")
@@ -218,6 +233,7 @@ def leaf(name: str) -> LeafConfig:
218233
act=leaf("act"),
219234
author=data.get("project", {}).get("author", ""),
220235
gates_checks=gates_checks,
236+
advisory_leaves=advisory_leaves,
221237
gates_runner=gates_runner,
222238
lanes=lanes,
223239
close_dispositions=close_dispositions,

template/src/pdca_harness/driver.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ def advance(d: Path, cfg: Config) -> None:
6464
gates.run_gates(d, cfg) # deterministic gates
6565
_say(f"→ {d.name}: Check — advisory reviewer{_headless_note(cfg.reviewer)}…")
6666
leaves.run_review(d, cfg) # leaf 2 — reviewer (advisory)
67+
if cfg.advisory_leaves: # optional extra advisory reviewers (issue #64)
68+
_say(f"→ {d.name}: Check — advisory reviewers ({len(cfg.advisory_leaves)})…")
69+
leaves.run_advisory_leaves(d, cfg)
6770
elif s == state.CHECKED:
6871
_say(f"→ {d.name}: assembling SUMMARY…")
6972
assemble.assemble_summary(d, cfg) # pure code → SUMMARY.md §1–8
@@ -237,6 +240,7 @@ def _archive_iteration(d: Path, n: int, *, include_brief: bool) -> None:
237240
"""
238241
arch = d / f"iteration-v{n}"
239242
names = list(DOWNSTREAM_OF_BRIEF)
243+
names += [p.name for p in d.glob("check-advisory-*.md")] # advisory artifacts (#64)
240244
if include_brief:
241245
names.append("brief.md")
242246
if (d / "brief.md").exists():

template/src/pdca_harness/leaves.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,104 @@ def _stub_review(d: Path, cfg: Config) -> None:
487487
)
488488

489489

490+
# ----------------------------------------------------------------------------
491+
# Optional advisory reviewer leaves (issue #64) — an OPEN, role-distinct set of extra
492+
# advisory reviewers (e.g. a correctness-bug + reuse/cleanup code-review lens), each a
493+
# reviewer-shaped leaf. Always advisory: they write check-advisory-<id>.md and their
494+
# NEEDS-HUMAN findings route into SUMMARY §6; they never gate. Conditioned per-bundle by
495+
# an optional ``when`` ({field, substring}) brief match — empty ⇒ always run.
496+
# ----------------------------------------------------------------------------
497+
def advisory_artifact(d: Path, leaf_id: str) -> Path:
498+
"""The artifact path an advisory leaf writes (parallel to check-review.md)."""
499+
return d / f"check-advisory-{leaf_id}.md"
500+
501+
502+
def _advisory_applies(spec: dict, d: Path) -> bool:
503+
"""True iff this advisory leaf should run for bundle ``d``. ``when`` ({field,
504+
substring}) matches a brief field case-insensitively (like a gate target flag);
505+
absent ⇒ always run."""
506+
when = spec.get("when") or {}
507+
needle = (when.get("substring") or "").lower()
508+
if not needle:
509+
return True
510+
return needle in brief.field(d / "brief.md", when.get("field", "")).lower()
511+
512+
513+
def _advisory_prompt(spec: dict, leaf_id: str) -> str:
514+
role = spec.get("role") or "review the patch for correctness bugs and reuse / " \
515+
"simplification / efficiency cleanups"
516+
return (
517+
f"You are an ADVISORY code reviewer — lens: {role}. You have ONLY patch.diff, "
518+
"brief.md and check-gates.json here (build-notes.md is withheld); ground every "
519+
"cited path:line on the target source at $PDCA_TARGET, never other checkouts. "
520+
f"Write check-advisory-{leaf_id}.md: a short list of findings, each a Markdown "
521+
"bullet with a path:line. For any finding a human must adjudicate, prefix the "
522+
"bullet '- NEEDS-HUMAN — ' (it becomes a SUMMARY §6 item). You are ADVISORY — you "
523+
"never gate; the human decides at sign-off. If you find nothing, say so explicitly."
524+
)
525+
526+
527+
def run_advisory_leaves(d: Path, cfg: Config) -> None:
528+
"""Run each configured advisory reviewer that applies (issue #64). Each writes
529+
check-advisory-<id>.md; failures degrade to a §6 NEEDS-HUMAN placeholder, never crash
530+
the cycle (advisory, like the main reviewer)."""
531+
for spec in cfg.advisory_leaves:
532+
leaf_id = spec.get("id") or "advisory"
533+
if not _advisory_applies(spec, d):
534+
continue
535+
leaf = LeafConfig(mode=spec.get("mode", "stub"), family=spec.get("family", ""),
536+
argv=list(spec.get("argv", [])))
537+
if leaf.mode == "command":
538+
_run_advisory_sandboxed(d, cfg, leaf, spec, leaf_id)
539+
else:
540+
_stub_advisory(d, spec, leaf_id)
541+
542+
543+
def _run_advisory_sandboxed(d: Path, cfg: Config, leaf: LeafConfig, spec: dict, leaf_id: str) -> None:
544+
"""Run one advisory leaf in a temp dir holding ONLY the reviewer inputs (the same
545+
independence sandbox as the main reviewer), grounding on $PDCA_TARGET (#75)."""
546+
with tempfile.TemporaryDirectory(prefix="pdca-advisory-") as tmp:
547+
sandbox = Path(tmp)
548+
for name in REVIEWER_INPUTS:
549+
if (d / name).exists():
550+
shutil.copy2(d / name, sandbox / name)
551+
target = _reviewer_target(d, cfg)
552+
env = {"PDCA_TARGET": str(target)} if target else None
553+
extra = ["--add-dir", str(target)] if target and leaf.family == "claude" else None
554+
out = sandbox / f"check-advisory-{leaf_id}.md"
555+
try:
556+
_invoke(leaf, sandbox, _advisory_prompt(spec, leaf_id),
557+
label=f"Advisory {leaf_id} {d.name}",
558+
status=lambda: progress.bundle_activity(sandbox, (out.name,)),
559+
stream_json=True, env=env, extra_argv=extra)
560+
except Exception as exc: # noqa: BLE001 — advisory must never crash the cycle
561+
_advisory_unavailable(d, leaf_id, f"leaf failed: {exc}")
562+
return
563+
if out.exists():
564+
shutil.copy2(out, advisory_artifact(d, leaf_id))
565+
else:
566+
_advisory_unavailable(d, leaf_id, "produced no artifact")
567+
568+
569+
def _stub_advisory(d: Path, spec: dict, leaf_id: str) -> None:
570+
role = spec.get("role") or "correctness bugs + reuse/simplification cleanups"
571+
advisory_artifact(d, leaf_id).write_text(
572+
f"# Advisory review — {leaf_id} (stub)\n\nLens: {role}.\n\n"
573+
"- NEEDS-HUMAN — advisory code-review lens is a stub here; a real "
574+
f"`{leaf_id}` leaf (family/argv in [[leaves.advisory]]) reviews the patch and "
575+
"lists findings. The human adjudicates at sign-off.\n",
576+
encoding="utf-8")
577+
578+
579+
def _advisory_unavailable(d: Path, leaf_id: str, reason: str) -> None:
580+
print(f"leaves: {d.name} — advisory '{leaf_id}' unavailable ({reason})", file=sys.stderr)
581+
advisory_artifact(d, leaf_id).write_text(
582+
f"# Advisory review — {leaf_id} — NOT COMPLETED\n\n"
583+
f"- NEEDS-HUMAN — advisory leaf '{leaf_id}' did not produce findings ({reason}); "
584+
"re-run it or adjudicate by hand.\n",
585+
encoding="utf-8")
586+
587+
490588
# ----------------------------------------------------------------------------
491589
# Leaf 3 — Check sign-off (signoff, interactive): Claude + human reach the OK.
492590
# ----------------------------------------------------------------------------

template/tests/test_driver_slice.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,52 @@ def boom(*a, **k):
310310
(self.d / "check-review.md").read_text(encoding="utf-8"))
311311

312312

313+
class AdvisoryReviewers(unittest.TestCase):
314+
"""Optional advisory reviewer leaves (issue #64): an open, role-distinct set that
315+
write check-advisory-<id>.md and route NEEDS-HUMAN into §6; conditioned by `when`."""
316+
317+
def setUp(self) -> None:
318+
self.tmp = Path(tempfile.mkdtemp())
319+
self.cfg = _stub_config(self.tmp)
320+
self.d = self.cfg.bundle("ADV")
321+
self.d.mkdir(parents=True)
322+
shutil.copyfile(TOY_BRIEF, self.d / "brief.md")
323+
324+
def tearDown(self) -> None:
325+
shutil.rmtree(self.tmp, ignore_errors=True)
326+
327+
def test_advisory_leaf_runs_and_routes_to_section6(self) -> None:
328+
self.cfg.advisory_leaves = [{"id": "code-review", "role": "bugs+cleanups", "mode": "stub"}]
329+
driver.run_issue(self.d, self.cfg)
330+
self.assertTrue((self.d / "check-advisory-code-review.md").exists())
331+
summary = self.d / "SUMMARY.md"
332+
self.assertIn("Advisory — code-review", summary.read_text(encoding="utf-8")) # §5
333+
items = signoff.open_needs_human(summary) # the advisory NEEDS-HUMAN → §6
334+
self.assertTrue(any("advisory" in it.lower() for it in items))
335+
336+
def test_when_condition_skips_non_matching(self) -> None:
337+
self.cfg.advisory_leaves = [{"id": "deep", "role": "x", "mode": "stub",
338+
"when": {"field": "review depth", "substring": "deep"}}]
339+
driver.run_issue(self.d, self.cfg) # toy brief has no "Review depth" → skipped
340+
self.assertFalse((self.d / "check-advisory-deep.md").exists())
341+
342+
def test_when_condition_runs_on_match(self) -> None:
343+
bp = self.d / "brief.md"
344+
bp.write_text(bp.read_text() + "\n- **Review depth:** deep\n", encoding="utf-8")
345+
self.cfg.advisory_leaves = [{"id": "deep", "role": "x", "mode": "stub",
346+
"when": {"field": "review depth", "substring": "deep"}}]
347+
driver.run_issue(self.d, self.cfg)
348+
self.assertTrue((self.d / "check-advisory-deep.md").exists())
349+
350+
def test_iterate_archives_advisory_artifact(self) -> None:
351+
self.cfg.advisory_leaves = [{"id": "code-review", "role": "x", "mode": "stub"}]
352+
driver.run_issue(self.d, self.cfg)
353+
signoff.record(self.d / "SUMMARY.md", action="iterate-do", by="t", date="2026-01-01")
354+
driver.advance(self.d, self.cfg) # archive the attempt
355+
self.assertFalse((self.d / "check-advisory-code-review.md").exists())
356+
self.assertTrue((self.d / "iteration-v1" / "check-advisory-code-review.md").exists())
357+
358+
313359
class ConfiguredGates(unittest.TestCase):
314360
"""The config-driven, single-sourced gates (docs 04)."""
315361

0 commit comments

Comments
 (0)