Skip to content

Commit dcb236d

Browse files
authored
Merge pull request #69 from eduralph/feat/67-delegated-gates
feat(gates): first-class delegated gates via a host runner
2 parents 2a1cf68 + f59c0a5 commit dcb236d

6 files changed

Lines changed: 145 additions & 3 deletions

File tree

copier.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,14 @@ leaves_mode:
152152
Stubs (offline; recommended to start): stub
153153
Real commands: command
154154
default: stub
155+
156+
# ----------------------------------------------------------------------------
157+
# Integration: gates (docs 04). A host that already single-sources its gates in its
158+
# own runner (e.g. `cargo xtask`, `make`, `just`) can DELEGATE to it: set a runner and
159+
# each gate names a bare sub-command, so PDCA orchestrates the host runner instead of
160+
# re-declaring the gates (issue #67). Leave blank to declare gate commands inline.
161+
# ----------------------------------------------------------------------------
162+
gates_runner:
163+
type: str
164+
help: "External gate runner to delegate to (e.g. 'cargo xtask'); blank = inline gate commands"
165+
default: ""

docs/05-check.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,28 @@ They become NEEDS-HUMAN items.
6666
> *new* failure is `[delta]` (your fix may have caused it). You'll see a `[delta]`
6767
> bite in [step 06](06-signoff.md).
6868
69+
### Delegating to a host runner
70+
71+
If your project already single-sources its gates in its own runner (`cargo xtask`,
72+
`make`, `just`, …), don't re-declare them in `pdca.toml`**delegate**. Set a runner
73+
and give each check a bare `subcmd`:
74+
75+
```toml
76+
[gates]
77+
runner = "cargo xtask"
78+
checks = [
79+
{ id = "C4-verify", tier = "C4", label = "fix verified red->green", subcmd = "verify", gating = true, scope = "bundle" },
80+
{ id = "T3-suite", tier = "T3", label = "runtime suite", subcmd = "test", gating = false, scope = "repo" },
81+
]
82+
```
83+
84+
PDCA runs `cargo xtask verify` / `cargo xtask test` and maps the results onto the
85+
5/5/1 — the host runner stays the single source of truth; PDCA only orchestrates it.
86+
A full `cmd` (e.g. `cmd = "cargo xtask ci"`) still works for wholesale delegation. A
87+
missing runner surfaces as a clear failing row (`runner '…' not found on PATH`), never
88+
a crash. Set it at render time with the `gates_runner` copier question, or later in
89+
`pdca.toml`.
90+
6991
## 2. Reviewer — the decorrelated second opinion
7092

7193
Next the `reviewer` leaf runs against `{patch.diff, test, brief.md,

template/pdca.toml.jinja

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,22 @@ argv = ["claude", "--agent", "publisher", "--permission-mode", "acceptEdits"]
192192
# full suite — the full suite mixes green tests with known-bug repros, so it is a
193193
# characterization, not a pass/fail signal. Cite each tier's rules back to the
194194
# project's normative ruleset (docs/INTEGRATION.md §4) so the gate is auditable.
195+
#
196+
# DELEGATED GATES (issue #67). If your project already single-sources its gates in its
197+
# own runner, point `runner` at it and give each check a bare `subcmd` instead of a full
198+
# `cmd`; PDCA runs `<runner> <subcmd>` and treats the host runner as the source of truth
199+
# (it never re-declares the gates). A missing runner surfaces as a clear failing row, not
200+
# a crash. A full `cmd` (e.g. `cmd = "cargo xtask ci"`) still works for wholesale delegation.
195201
[gates]
202+
{% if gates_runner %}
203+
# Delegated to the host runner — PDCA orchestrates it, the host owns the gate definitions.
204+
runner = "{{ gates_runner }}"
205+
checks = [
206+
# Map the host's named sub-gates onto the 5/5/1 tiers. Each runs `{{ gates_runner }} <subcmd>`.
207+
# { id = "C4-verify", tier = "C4", label = "fix verified red->green", subcmd = "verify", gating = true, scope = "bundle" },
208+
# { id = "T3-suite", tier = "T3", label = "runtime suite (baseline)", subcmd = "test", gating = false, scope = "repo" },
209+
]
210+
{% else %}
196211
checks = []
212+
{% endif %}
197213

template/src/pdca_harness/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ class Config:
8585
issue_trailer: str = "Fixes #{id}" # commit/PR trailer; "" → none enforced
8686
repo_checkouts: dict[str, str] = field(default_factory=dict) # repo_spec → local path
8787
gates_checks: list[dict] = field(default_factory=list)
88+
# Delegated gates (issue #67): a host runner that single-sources its own gates
89+
# (e.g. "cargo xtask"). A check's bare ``subcmd`` is run as ``<runner> <subcmd>``, so
90+
# PDCA orchestrates the host runner instead of re-declaring the gates. "" ⇒ inline only.
91+
gates_runner: str = ""
8892
# Target-aware gate selection (docs 04). A check may carry ``target`` (a label or
8993
# list); it runs iff its labels are a SUBSET of the bundle's label set. The bundle is
9094
# classified from its brief on two axes: a PRIMARY one (``gate_target_match``: label →
@@ -135,6 +139,7 @@ def load(cls, root: Path | None = None) -> "Config":
135139
leaves = data.get("leaves", {})
136140
gates = data.get("gates", {})
137141
gates_checks = list(gates.get("checks", []))
142+
gates_runner = gates.get("runner", "")
138143
# Additive target flags: label → {field, substring}. A bare string is shorthand
139144
# for the "Repo + branch target" field (so flags and the primary axis can share it).
140145
gate_target_flags = {
@@ -207,6 +212,7 @@ def leaf(name: str) -> LeafConfig:
207212
act=leaf("act"),
208213
author=data.get("project", {}).get("author", ""),
209214
gates_checks=gates_checks,
215+
gates_runner=gates_runner,
210216
lanes=lanes,
211217
close_dispositions=close_dispositions,
212218
)

template/src/pdca_harness/gates.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
from __future__ import annotations
3434

3535
import json
36+
import shlex
37+
import shutil
3638
import sys
3739
from pathlib import Path
3840

@@ -171,15 +173,42 @@ def _run_checks(cfg: Config, *, cwd: Path, bundle: Path | None, scopes: tuple[st
171173
f"(target={chk.get('target')}, bundle labels {set(labels)})",
172174
file=sys.stderr, flush=True)
173175
continue
174-
configured.append(_run_one(chk, cwd=cwd, bundle=bundle))
176+
configured.append(_run_one(chk, cwd=cwd, bundle=bundle, runner=cfg.gates_runner))
175177
# Overlay the configured gate results onto the complete 5/5/1 matrix.
176178
return _assemble_matrix(configured, stub=False)
177179

178180

179-
def _run_one(chk: dict, *, cwd: Path, bundle: Path | None) -> dict:
180-
cmd = chk.get("cmd", "")
181+
def _delegated_cmd(chk: dict, runner: str) -> tuple[str, str]:
182+
"""Resolve a check's command. A check may declare a bare ``subcmd`` (issue #67)
183+
delegated to the host's single-sourced ``[gates] runner`` (e.g. ``cargo xtask``),
184+
so PDCA orchestrates the host runner without re-declaring the gate; or a full ``cmd``
185+
(which may itself be ``cargo xtask ci`` — wholesale delegation). Returns
186+
``(cmd, error)``: a non-empty ``error`` is a misconfiguration to surface as a fail
187+
row (a ``subcmd`` with no runner, or a runner binary missing from PATH)."""
188+
subcmd = chk.get("subcmd", "")
189+
if not subcmd:
190+
return chk.get("cmd", ""), ""
191+
if not runner:
192+
return "", "check declares 'subcmd' but [gates] runner is unset"
193+
first = shlex.split(runner)[0] if runner.strip() else ""
194+
# A clear error beats a cryptic shell failure when the host runner isn't installed.
195+
if first and not first.startswith((".", "/")) and shutil.which(first) is None:
196+
return "", f"delegated runner '{first}' not found on PATH — install it or fix [gates].runner"
197+
return f"{runner} {subcmd}", ""
198+
199+
200+
def _run_one(chk: dict, *, cwd: Path, bundle: Path | None, runner: str = "") -> dict:
201+
cmd, cmd_error = _delegated_cmd(chk, runner)
181202
gating = bool(chk.get("gating", True))
182203
label = f"{chk.get('id', '')}: {chk.get('label', '')}".strip(": ")
204+
if cmd_error:
205+
# Misconfigured delegation — surface as a failing row with a fix hint, never crash.
206+
print(f" · gate {label}: {cmd_error}", file=sys.stderr, flush=True)
207+
return _row(
208+
f"{chk.get('tier', '?')} {chk.get('label', chk.get('id', ''))}",
209+
"fail", oracle=chk.get("subcmd", "") or cmd, rule_id=chk.get("id", ""),
210+
path_line=cmd_error[:120], gating=gating, element=chk.get("tier", ""),
211+
)
183212
env = {"PDCA_BUNDLE": str(bundle)} if bundle is not None else None
184213
# Stack mode (issue #54): when the brief names an existing PR's head to stack onto,
185214
# expose it as PDCA_BASE so the verify/repro gate establishes red→green on THAT branch

template/tests/test_driver_slice.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,64 @@ def test_working_tree_skips_bundle_scope(self) -> None:
312312
self.assertNotIn("b", {r["rule_id"] for r in result["rows"]})
313313

314314

315+
class DelegatedGates(unittest.TestCase):
316+
"""Delegated gates (issue #67): a host runner single-sources the gates; a check's
317+
bare `subcmd` runs as `<runner> <subcmd>`, so PDCA orchestrates without re-declaring."""
318+
319+
def setUp(self) -> None:
320+
self.tmp = Path(tempfile.mkdtemp())
321+
322+
def tearDown(self) -> None:
323+
shutil.rmtree(self.tmp, ignore_errors=True)
324+
325+
def _cfg(self, checks: list[dict], runner: str = "") -> Config:
326+
cfg = _stub_config(self.tmp)
327+
cfg.gates_checks = checks
328+
cfg.gates_runner = runner
329+
return cfg
330+
331+
def test_subcmd_resolved_against_runner(self) -> None:
332+
# `subcmd` is run as `<runner> <subcmd>`; the resolved command is the oracle.
333+
cfg = self._cfg(
334+
[{"id": "ci", "tier": "T1", "label": "host ci", "subcmd": "ok-step",
335+
"gating": True, "scope": "repo"}],
336+
runner="echo")
337+
result = gates.run_working_tree(cfg)
338+
row = next(r for r in result["rows"] if r["rule_id"] == "ci")
339+
self.assertEqual(row["result"], "pass") # `echo ok-step` exits 0
340+
self.assertEqual(row["oracle"], "echo ok-step") # runner prefixed
341+
342+
def test_missing_runner_is_a_clear_failing_row_not_a_crash(self) -> None:
343+
cfg = self._cfg(
344+
[{"id": "x", "tier": "T1", "label": "host ci", "subcmd": "build",
345+
"gating": True, "scope": "repo"}],
346+
runner="definitely-not-a-real-binary-zzz xtask")
347+
result = gates.run_working_tree(cfg) # must not raise
348+
row = next(r for r in result["rows"] if r["rule_id"] == "x")
349+
self.assertEqual(row["result"], "fail")
350+
self.assertIn("not found on PATH", row["path_line"])
351+
352+
def test_subcmd_without_runner_is_flagged(self) -> None:
353+
cfg = self._cfg(
354+
[{"id": "y", "tier": "T1", "label": "host ci", "subcmd": "build",
355+
"gating": True, "scope": "repo"}],
356+
runner="") # subcmd declared but no runner configured
357+
result = gates.run_working_tree(cfg)
358+
row = next(r for r in result["rows"] if r["rule_id"] == "y")
359+
self.assertEqual(row["result"], "fail")
360+
self.assertIn("runner is unset", row["path_line"])
361+
362+
def test_inline_cmd_unaffected_by_runner(self) -> None:
363+
# A full `cmd` still runs verbatim even when a runner is configured.
364+
cfg = self._cfg(
365+
[{"id": "z", "tier": "T1", "label": "inline", "cmd": "true",
366+
"gating": True, "scope": "repo"}],
367+
runner="echo")
368+
row = next(r for r in gates.run_working_tree(cfg)["rows"] if r["rule_id"] == "z")
369+
self.assertEqual(row["result"], "pass")
370+
self.assertEqual(row["oracle"], "true") # not prefixed with the runner
371+
372+
315373
class BuilderGuard(unittest.TestCase):
316374
"""The PreToolUse hook enforcing the builder's STOP discipline."""
317375

0 commit comments

Comments
 (0)