Skip to content

Commit 0cb2bd8

Browse files
authored
Merge pull request #78 from eduralph/feat/75-neutral-agents
feat(agents): project-neutral prompts + reviewer target access
2 parents 62b5d5a + ee7eb33 commit 0cb2bd8

10 files changed

Lines changed: 146 additions & 32 deletions

File tree

copier.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,19 @@ default_branch:
126126
help: Default branch fixes target unless an area rule overrides
127127
default: main
128128

129+
# How this project contributes (issue #75). "fork": contribute upstream through a
130+
# fork (upstream/maintenance-line targeting, cross-version cherry-picks) — the agents
131+
# cite docs/fork-discipline.md. "own-repo": this repo IS the target (branch from the
132+
# default branch, no upstream/fork) — the agents drop the fork doctrine and defer
133+
# targeting to docs/INTEGRATION.md §2. Default "fork" preserves prior behavior.
134+
contribution_model:
135+
type: str
136+
help: How this project contributes — own repo (branch from default) or a fork (upstream line)
137+
choices:
138+
Fork (contribute upstream via a fork): fork
139+
Own repo (this repo is the target): own-repo
140+
default: fork
141+
129142
# ----------------------------------------------------------------------------
130143
# Integration: bundle + process layout (docs 05 item 7)
131144
# ----------------------------------------------------------------------------

docs/05-check.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ re-checks that cited `path:line`s exist on the target branch, and flags scope
9797
creep. Its output, `check-review.md`, is **advisory** — it annotates, it never
9898
gates. The blocking path contains no LLM at all.
9999

100+
The reviewer runs in an isolation sandbox (only `{patch.diff, brief.md,
101+
check-gates.json}` are present), so the driver resolves the brief's target checkout and
102+
hands it over as **`$PDCA_TARGET`** (read-only; for a `claude` reviewer also via
103+
`--add-dir`). The reviewer grounds every citation there and is told **not** to wander
104+
into other checkouts on the machine — without this it can't ground, or hunts the
105+
filesystem for "the target" (issue #75).
106+
100107
## 3. Assembly — the SUMMARY the human signs
101108

102109
The driver folds brief + gates + review into `SUMMARY.md`, a 10-section document.

template/.claude/agents/builder.md.jinja

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,13 @@ failing gate. Address it; do **not** re-submit the rejected approach unchanged.
4545

4646
Cite `path:line` on the target branch for every claim and change.
4747

48-
Write the patch against the brief's **target branch** and follow
49-
`docs/fork-discipline.md`: a cross-version cherry-pick must *remain correct* on the
50-
target, not just apply cleanly (§3); ship the test in the location the target branch
51-
uses (§3); make the patch commit-ready for the target's own hooks (§4).
48+
Write the patch against the brief's **target branch** (targeting resolved at Plan per
49+
`docs/INTEGRATION.md` §2). Ship the test in the location the target uses, and make the
50+
patch commit-ready for the target's own commit hooks (formatter / linters).
51+
{% if contribution_model == "fork" %}
52+
This is a fork contribution — also follow `docs/fork-discipline.md`: a cross-version
53+
cherry-pick must *remain correct* on the target, not just apply cleanly (§3).
54+
{% endif %}
5255

5356
**When you reject an alternative on cost, show the cost** — a diff sketch or a concrete
5457
line count someone can check, never an adjective ("heavier", "larger", "touches every
@@ -69,13 +72,13 @@ ad-hoc test command, or similar): it has **no timeout**, so a hung test blocks
6972
the whole Do beat forever.
7073

7174
Do **not** assume the runner gives you a display, GUI, or other rich runtime —
72-
many are **headless**. If your test imports a heavy module (a GUI toolkit, a
75+
many are **headless**. If your test pulls in a heavy dependency (a GUI toolkit, a
7376
display/IO-bound library, …) **at load time**, a headless runner can crash on
74-
import — and it recurs on every iterate-do until the test stops importing it.
75-
Keep the unit under test import-light: extract the logic into a module free of
76-
those heavy imports and test *that*. Check what the
77-
runner actually provides (`pdca.toml`, `docs/INTEGRATION.md`) rather than assuming
78-
— an inaccurate belief about the environment is what makes a test crash silently.
77+
load — and it recurs on every iterate-do until the test stops pulling it in.
78+
Keep the unit under test load-light: extract the logic into a unit free of those
79+
heavy dependencies and test *that*. Check what the runner actually provides
80+
(`pdca.toml`, `docs/INTEGRATION.md`) rather than assuming — an inaccurate belief
81+
about the environment is what makes a test crash silently.
7982
This pre-fix/post-fix check is a fast sanity pass (Check's gates re-run the real
8083
suite), so a single quick run through the wrapper is enough.
8184

template/.claude/agents/planner.md.jinja

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,12 @@ Either way the output file is `brief.md`, and you must keep the parsed
3333
`- **Label:** value` lines (the driver reads the spec from them). The load-bearing
3434
field is the **success criterion** — the sentence Check later tests "did this work"
3535
against. Resolve the **repo + branch target** here, and state **scope / out of
36-
scope** so Do can't sprawl. Follow `docs/fork-discipline.md` for the targeting and
37-
prior-art rules — fixes ride the current maintenance line (§3); run the prior-art
38-
check by **affected file path**, across merged history *and* closed/rejected PRs (§5).
36+
scope** so Do can't sprawl. Resolve targeting per `docs/INTEGRATION.md` §2, and run the
37+
prior-art check by **affected file path**, across merged history *and* closed/rejected work.
38+
{% if contribution_model == "fork" %}
39+
This is a fork contribution — follow `docs/fork-discipline.md` for the fork targeting +
40+
prior-art rules: fixes ride the current maintenance line (§3); the check spans upstream PRs (§5).
41+
{% endif %}
3942

4043
**One issue or several (batch).** You run from the project root; your prompt names
4144
where to write. For a single issue it gives one bundle directory — write the one

template/.claude/agents/publisher.md.jinja

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,14 @@ not push, branch, or open a PR yourself.**
5858
`cd <checkout> && …` (`git -C` is the safe idiom).
5959
- Resolve the branch target per INTEGRATION §2. One logical fix per PR; do not invent
6060
scope the brief didn't accept.
61-
- Follow `docs/fork-discipline.md` §1–§2: the contribution branches from
62-
`upstream/<base>` (not the fork's drifted branch), the PR is **draft-only** and the
63-
human marks it ready, and the deterministic `pdca publish` performs the push. Write
64-
the commit-msg/PR prose to match the target; do not push or open the PR yourself.
61+
- The contribution branches from the brief's **target branch** (per INTEGRATION §2),
62+
the PR is **draft-only** and the human marks it ready, and the deterministic `pdca
63+
publish` performs the push. Write the commit-msg/PR prose to match the target; do not
64+
push or open the PR yourself.
65+
{% if contribution_model == "fork" %}
66+
- This is a fork contribution — branch from `upstream/<base>` (not the fork's drifted
67+
branch); see `docs/fork-discipline.md` §1–§2.
68+
{% endif %}
6569

6670
## Boundaries
6771

template/.claude/agents/reviewer.md.jinja

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,11 @@ physically cannot patch what you judge.
3232

3333
- Re-run the asserted evidence: stash the fix → confirm red; unstash → confirm
3434
green. Re-run the validator/scanners yourself. Trust re-runs, not claims.
35-
- Re-check that every cited `path:line` grounds on the target branch; drop
36-
findings that do not ground.
35+
- Re-check that every cited `path:line` grounds on the **target source at
36+
`$PDCA_TARGET`** (read-only; the driver resolves it from the brief's target and adds
37+
it for you). Ground only there — do **not** wander into other checkouts on the
38+
machine; if `$PDCA_TARGET` is unset, ground against `patch.diff` alone. Drop findings
39+
that do not ground.
3740
- Emit per item `PASS / FAIL / NEEDS-HUMAN` + one-line rationale + path:line.
3841

3942
## Always emit the complete 5/5/1 verdict table
@@ -69,17 +72,21 @@ upstream-isn't-ahead; scope-creep / Plan re-entry; visual / manual-repro
6972
outcomes; and the project's enumerated human-only items (INTEGRATION.md §4).
7073
Each becomes a `- [ ]` row in `SUMMARY.md` §6 the human must clear.
7174

72-
Judge the contribution against `docs/fork-discipline.md`: a cross-version
73-
cherry-pick that *applies cleanly* is not necessarily *correct* on the target (§3);
74-
validation must be against the **clean upstream** target, not the fork's branches
75-
(§4); and confirm the prior-art check ran by file path (§5). Where these can't be
76-
mechanically settled, raise them NEEDS-HUMAN.
75+
Confirm the prior-art check ran by **affected file path** (merged history + closed/
76+
rejected work); where it can't be mechanically settled, raise it NEEDS-HUMAN.
77+
{% if contribution_model == "fork" %}
78+
This is a fork contribution — judge it against `docs/fork-discipline.md`: a
79+
cross-version cherry-pick that *applies cleanly* is not necessarily *correct* on the
80+
target (§3), and validation must be against the **clean upstream** target, not the
81+
fork's drifted branches (§4). Raise NEEDS-HUMAN where these can't be mechanically settled.
82+
{% endif %}
7783

7884
### C5 symptom-guard smell-test
7985

8086
The "contested symptom-vs-root-cause" trigger above has a concrete detection rule —
8187
apply it to `patch.diff` every cycle. If the fix adds a **capability probe** (a
82-
capability check, `hasattr`, `try: import …`) or a **runtime guard** *inside code that
88+
feature/attribute check, or a try-it-and-fall-back around an optional capability —
89+
e.g. in Python `hasattr` / `try: import …`) or a **runtime guard** *inside code that
8390
is meant to run with that capability present* — the guard protects a path that, by
8491
design, only executes when the capability exists — flag C5 **NEEDS-HUMAN** and ask in
8592
the basis: can the eager / load-time cause be removed instead (e.g. compute lazily on

template/AGENTS.md.jinja

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ driver enforces this by not passing the file; do not ask for it.
1717

1818
- Re-run the asserted evidence: stash the fix → confirm red; unstash → confirm
1919
green. Re-run the validator / scanners yourself. Trust re-runs, not claims.
20-
- Re-check that every `path:line` the patch cites resolves on the target branch;
21-
drop any finding that does not ground.
20+
- Re-check that every `path:line` the patch cites resolves on the **target source at
21+
`$PDCA_TARGET`** (the driver resolves it from the brief's target). Ground only there —
22+
do **not** search other checkouts on the machine; if `$PDCA_TARGET` is unset, ground
23+
against `patch.diff` alone. Drop any finding that does not ground.
2224
- Emit per item `PASS / FAIL / NEEDS-HUMAN` + a one-line rationale + a path:line.
2325
No free-form prose verdict.
2426
- You have **execute** access (run tests/validator, git stash/unstash) and **no
@@ -30,7 +32,8 @@ These are structurally undecidable from the artifacts — flag them, don't guess
3032

3133
- Validation fitness-to-purpose ("is this the right thing at all").
3234
- Symptom-vs-root-cause when the bug's mechanism is contested.
33-
- Upstream-isn't-ahead *semantic* match (does an open rewrite PR supersede this).
35+
- Superseded-by: does other open work *semantically* supersede this (e.g. an upstream
36+
rewrite PR, or another in-flight change)?
3437
- Scope-creep / Plan re-entry calls (diff exceeds the brief's scope but looks plausible).
3538
- Visual sign-off / manual-repro outcomes.
3639
- The project's enumerated human-only items (INTEGRATION.md §4). **TODO: list them.**

template/docs/fork-discipline.md.jinja

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Fork & contribution discipline
22

3+
{% if contribution_model != "fork" %}
4+
> **This project renders as `own-repo`** (this repo IS the contribution target, not a
5+
> fork). The agents do **not** cite this file — they resolve targeting from
6+
> `INTEGRATION.md` §2. It is kept as a reference for the fork model; ignore it unless
7+
> you switch `contribution_model` to `fork`.
8+
9+
{% endif %}
310
> The generic rules for contributing a fix upstream through a fork — the **Check
411
> closing / contribution** discipline (the counterpart to [`principles.md`](principles.md),
512
> which governs Plan-time solution design). These rules are project-agnostic; **your

template/src/pdca_harness/leaves.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ def _invoke(
6767
label: str = "",
6868
status=None,
6969
stream_json: bool = False,
70+
env: dict | None = None,
71+
extra_argv: list[str] | None = None,
7072
) -> None:
7173
"""Run the leaf's configured command in ``workdir``, feeding it ``prompt``.
7274
@@ -83,9 +85,10 @@ def _invoke(
8385
leaf is using right now. Ignored for non-claude families (e.g. a codex reviewer),
8486
which don't speak that format.
8587
"""
86-
argv = list(leaf.argv)
88+
argv = list(leaf.argv) + list(extra_argv or [])
89+
run_env = {**os.environ, **env} if env else None
8790
if leaf.interactive:
88-
subprocess.run(argv + [prompt], cwd=workdir)
91+
subprocess.run(argv + [prompt], cwd=workdir, env=run_env)
8992
return
9093
# Headless: feed the prompt on stdin (a trailing positional would be swallowed
9194
# by a variadic --allowedTools) and tick a heartbeat, since `claude -p` prints
@@ -95,7 +98,7 @@ def _invoke(
9598
argv += ["--output-format", "stream-json", "--verbose"]
9699
rc, _ = progress.run_with_heartbeat(
97100
argv, cwd=workdir, input_text=prompt, label=label, status=status,
98-
stream_json=use_stream)
101+
stream_json=use_stream, env=run_env)
99102
if rc != 0:
100103
raise subprocess.CalledProcessError(rc, argv)
101104

@@ -358,10 +361,33 @@ def reviewer_input_paths(d: Path) -> list[Path]:
358361
"you can). Emit NEEDS-HUMAN for the always-human items (validation "
359362
"fitness-to-purpose, contested root-cause, ambiguous scope) — each NEEDS-HUMAN "
360363
"row becomes a §6 item the human must clear. Do not omit a row; use N/A with a "
361-
"reason when an element does not apply."
364+
"reason when an element does not apply. "
365+
"Ground every cited path:line on the target source at $PDCA_TARGET (read-only); "
366+
"if $PDCA_TARGET is unset, ground against patch.diff alone — do NOT search other "
367+
"checkouts on the machine."
362368
)
363369

364370

371+
def _reviewer_target(d: Path, cfg: Config) -> Path | None:
372+
"""The local target checkout the reviewer grounds its citations on, or None (#75).
373+
374+
Single-sourced from the brief's "Repo + branch target" via the same resolution
375+
publish uses (``_checkout_path`` — configured ``[publisher.checkouts]`` or the
376+
sibling convention). Returned only if it exists on disk; the reviewer is told to
377+
ground against ``$PDCA_TARGET`` and not to wander into other checkouts. Best-effort:
378+
any failure (no target, unresolved) yields None and the reviewer falls back to the diff.
379+
"""
380+
from . import publish # lazy: publish imports leaves, avoid an import cycle
381+
try:
382+
repo_spec, _base, _slug = publish._resolve_target(d)
383+
if not repo_spec:
384+
return None
385+
p = publish._checkout_path(cfg, repo_spec)
386+
return p if p.exists() else None
387+
except Exception: # noqa: BLE001 — grounding access is best-effort, never fatal
388+
return None
389+
390+
365391
def run_review(d: Path, cfg: Config) -> None:
366392
inputs = reviewer_input_paths(d)
367393
assert (d / "build-notes.md") not in inputs, "independence contract violated"
@@ -385,12 +411,20 @@ def _run_review_sandboxed(d: Path, cfg: Config) -> None:
385411
src = d / name
386412
if src.exists():
387413
shutil.copy2(src, sandbox / name)
414+
# Ground citations on the brief's target checkout (#75): name it via $PDCA_TARGET
415+
# so the reviewer doesn't wander into unrelated checkouts, and grant read access
416+
# for the claude family (--add-dir). Independence holds — the target is the
417+
# upstream source, not build-notes.md.
418+
target = _reviewer_target(d, cfg)
419+
env = {"PDCA_TARGET": str(target)} if target else None
420+
extra_argv = ["--add-dir", str(target)] if target and cfg.reviewer.family == "claude" else None
388421
try:
389422
_invoke(
390423
cfg.reviewer, sandbox, _REVIEW_PROMPT,
391424
label=f"Check review {d.name}",
392425
status=lambda: progress.bundle_activity(sandbox, ("check-review.md",)),
393426
stream_json=True, # Tier 3 (no-op unless the reviewer family is claude)
427+
env=env, extra_argv=extra_argv,
394428
)
395429
except Exception as exc: # a failed reviewer (e.g. dropped connection) must
396430
_review_unavailable(d, f"reviewer leaf failed: {exc}") # not crash the cycle

template/tests/test_driver_slice.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,39 @@ def test_config_close_class(self) -> None:
224224
self.assertEqual(self.cfg.close_class("likely-close"), "")
225225

226226

227+
class ReviewerTargetAccess(unittest.TestCase):
228+
"""The reviewer grounds citations on the brief's target checkout via $PDCA_TARGET
229+
(issue #75) — single-sourced from the brief, so it doesn't wander into other
230+
checkouts on the machine."""
231+
232+
def setUp(self) -> None:
233+
self.tmp = Path(tempfile.mkdtemp())
234+
self.cfg = _stub_config(self.tmp)
235+
self.d = self.cfg.bundle("T")
236+
self.d.mkdir(parents=True)
237+
(self.d / "brief.md").write_text(
238+
"- **Slug:** x\n- **Repo + branch target:** org/myrepo @ main\n", encoding="utf-8")
239+
240+
def tearDown(self) -> None:
241+
shutil.rmtree(self.tmp, ignore_errors=True)
242+
243+
def test_resolves_target_checkout_when_present(self) -> None:
244+
self.cfg.repo_checkouts = {"org/myrepo": "target"} # → <root>/target
245+
self.assertIsNone(leaves._reviewer_target(self.d, self.cfg)) # not on disk yet
246+
(self.cfg.root / "target").mkdir()
247+
self.assertEqual(leaves._reviewer_target(self.d, self.cfg),
248+
(self.cfg.root / "target").resolve())
249+
250+
def test_no_target_when_brief_has_no_target(self) -> None:
251+
(self.d / "brief.md").write_text("- **Slug:** x\n", encoding="utf-8")
252+
self.assertIsNone(leaves._reviewer_target(self.d, self.cfg))
253+
254+
def test_review_prompt_grounds_on_pdca_target(self) -> None:
255+
# The prompt names $PDCA_TARGET and forbids wandering to other checkouts.
256+
self.assertIn("$PDCA_TARGET", leaves._REVIEW_PROMPT)
257+
self.assertIn("do NOT search other checkouts", leaves._REVIEW_PROMPT)
258+
259+
227260
class AdvisoryReviewResilience(unittest.TestCase):
228261
"""A failed/interrupted reviewer must degrade to a §6 NEEDS-HUMAN, never crash
229262
the deterministic spine (the review is advisory, not a gating artifact)."""

0 commit comments

Comments
 (0)