@@ -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 \n Lens: { 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# ----------------------------------------------------------------------------
0 commit comments