From 04db382a405a8aede87e75fbacdcb1a3db645f92 Mon Sep 17 00:00:00 2001
From: DataDave-Dev <153755137+DataDave-Dev@users.noreply.github.com>
Date: Wed, 1 Jul 2026 17:22:30 -0600
Subject: [PATCH 1/6] chore: bump __version__ to 0.4.0 to match pyproject
The 0.4.0 release bumped pyproject.toml but left __version__ at 0.3.0, so `becwright --version` and the packaged metadata disagreed. Align them.
---
src/becwright/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/becwright/__init__.py b/src/becwright/__init__.py
index 493f741..6a9beea 100644
--- a/src/becwright/__init__.py
+++ b/src/becwright/__init__.py
@@ -1 +1 @@
-__version__ = "0.3.0"
+__version__ = "0.4.0"
From 95bda2967b6b3a299da3e4382519ce0f3df26da2 Mon Sep 17 00:00:00 2001
From: DataDave-Dev <153755137+DataDave-Dev@users.noreply.github.com>
Date: Wed, 1 Jul 2026 17:22:41 -0600
Subject: [PATCH 2/6] feat: check --diff to check only files changed vs
a ref
Adds a third scope to `becwright check`, alongside staged (default) and --all:
--diff checks only the files a branch changed vs , using the
three-dot range (base...HEAD) so it matches exactly what a pull request shows
as 'Files changed'. Content is read from the working tree (in CI the checkout
already is the committed code).
An unknown base ref raises GitError -> exit 2 (a loud CI failure) instead of
silently passing on an empty file list, e.g. on a shallow clone without the
base ref. --all and --diff are mutually exclusive.
This is the engine half of running becwright as a required CI check: a local
hook can be skipped with 'git commit --no-verify', a required check cannot.
---
src/becwright/cli.py | 9 +++--
src/becwright/git.py | 30 +++++++++++++++-
src/becwright/report.py | 13 ++++---
tests/test_cli_and_git.py | 74 +++++++++++++++++++++++++++++++++++++++
4 files changed, 117 insertions(+), 9 deletions(-)
diff --git a/src/becwright/cli.py b/src/becwright/cli.py
index bd81ada..e036395 100644
--- a/src/becwright/cli.py
+++ b/src/becwright/cli.py
@@ -76,7 +76,7 @@ def _print_unknown_checks(unknown: list[tuple[str, str]]) -> None:
def _cmd_check(args: argparse.Namespace) -> int:
root = git.repo_root()
- rules, files, result = report.gather(root, all_files=args.all)
+ rules, files, result = report.gather(root, all_files=args.all, diff_base=args.diff)
unknown = _unknown_builtin_checks(rules, root)
if unknown:
@@ -784,7 +784,10 @@ def _build_parser() -> argparse.ArgumentParser:
sub = parser.add_subparsers(dest="command", required=True)
p_check = sub.add_parser("check", help="check the code against the rules")
- p_check.add_argument("--all", action="store_true", help="check the whole repo, not just staging")
+ scope = p_check.add_mutually_exclusive_group()
+ scope.add_argument("--all", action="store_true", help="check the whole repo, not just staging")
+ scope.add_argument("--diff", metavar="BASE",
+ help="check only files changed vs BASE ref (e.g. origin/main) — for CI/PR")
p_check.add_argument("--json", action="store_true", help="output results as JSON")
p_check.set_defaults(func=_cmd_check)
@@ -836,7 +839,7 @@ def main(argv: list[str] | None = None) -> int:
args = _build_parser().parse_args(argv)
try:
return args.func(args)
- except (git.NotAGitRepo, RulesError) as e:
+ except (git.NotAGitRepo, git.GitError, RulesError) as e:
print(_style(str(e), RED), file=sys.stderr)
return 2
diff --git a/src/becwright/git.py b/src/becwright/git.py
index cbea575..25e07d4 100644
--- a/src/becwright/git.py
+++ b/src/becwright/git.py
@@ -40,6 +40,10 @@ class NotAGitRepo(RuntimeError):
pass
+class GitError(RuntimeError):
+ pass
+
+
def repo_root(cwd: Path | None = None) -> Path:
res = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
@@ -50,7 +54,11 @@ def repo_root(cwd: Path | None = None) -> Path:
return Path(res.stdout.strip())
-def files_to_check(root: Path, *, all_files: bool) -> list[str]:
+def files_to_check(
+ root: Path, *, all_files: bool = False, diff_base: str | None = None
+) -> list[str]:
+ if diff_base:
+ return _files_changed_since(root, diff_base)
if all_files:
cmd = ["git", "ls-files"]
else:
@@ -59,6 +67,26 @@ def files_to_check(root: Path, *, all_files: bool) -> list[str]:
return [line for line in res.stdout.splitlines() if line.strip()]
+def _files_changed_since(root: Path, base: str) -> list[str]:
+ """Files added/copied/modified between `base` and HEAD, using the three-dot
+ range (`base...HEAD`) so it reports exactly what the branch introduced since it
+ diverged — the same set a pull request shows as "Files changed". Raises `GitError`
+ when `base` is unknown so a CI run fails loudly instead of silently passing on an
+ empty file list (e.g. a shallow clone without the base ref)."""
+ res = subprocess.run(
+ ["git", "diff", "--name-only", "--diff-filter=ACM", f"{base}...HEAD"],
+ cwd=root, capture_output=True, text=True,
+ )
+ if res.returncode != 0:
+ detail = res.stderr.strip() or f"unknown ref '{base}'"
+ raise GitError(
+ f"Could not diff against '{base}': {detail}\n"
+ "In CI, check out full history (actions/checkout with fetch-depth: 0) "
+ "and make sure the base branch is fetched."
+ )
+ return [line for line in res.stdout.splitlines() if line.strip()]
+
+
def _staged_blob(root: Path, path: str) -> bytes | None:
# `:0:` is the staged (index) version of the file, which is exactly
# what the commit will record — not the working-tree copy that may differ.
diff --git a/src/becwright/report.py b/src/becwright/report.py
index 8a05a3e..de1fbcb 100644
--- a/src/becwright/report.py
+++ b/src/becwright/report.py
@@ -7,16 +7,19 @@
from .rules import Rule, load_rules
-def gather(root: Path, *, all_files: bool) -> tuple[list[Rule], list[str], Result | None]:
+def gather(
+ root: Path, *, all_files: bool = False, diff_base: str | None = None
+) -> tuple[list[Rule], list[str], Result | None]:
"""Load rules, find the files to check and evaluate them. The result is None
when there is nothing to check (no rules or no files)."""
rules = load_rules(root / ".bec" / "rules.yaml")
- files = git.files_to_check(root, all_files=all_files)
+ files = git.files_to_check(root, all_files=all_files, diff_base=diff_base)
if not rules or not files:
return rules, files, None
- # `--all` inspects the working tree on purpose; the pre-commit path checks the
- # staged content, which is what the commit will actually record.
- if all_files:
+ # `--all` and `--diff` inspect the working tree (in CI the checkout already is
+ # the committed content); the pre-commit path checks the staged content, which
+ # is what the commit will actually record.
+ if all_files or diff_base:
return rules, files, evaluate(rules, files, root)
with git.staged_worktree(root, files) as staged_root:
return rules, files, evaluate(rules, files, staged_root)
diff --git a/tests/test_cli_and_git.py b/tests/test_cli_and_git.py
index 006a4d2..3d3515b 100644
--- a/tests/test_cli_and_git.py
+++ b/tests/test_cli_and_git.py
@@ -346,6 +346,80 @@ def test_demo_result_flags_both_violations(tmp_path):
assert res.had_blocking
+# --- diff mode (CI / PR): only the files a branch changed ---
+
+def _current_branch(root):
+ return subprocess.run(
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
+ cwd=root, capture_output=True, text=True,
+ ).stdout.strip()
+
+
+def _branch_with_change(tmp_path):
+ """Base commit with old.py, then a `feature` branch adding new.py. Returns the
+ base branch name to diff against."""
+ _init_repo(tmp_path)
+ (tmp_path / "old.py").write_text("x = 1\n", encoding="utf-8")
+ _git(tmp_path, "add", "-A")
+ _git(tmp_path, "commit", "-m", "base")
+ base = _current_branch(tmp_path)
+ _git(tmp_path, "checkout", "-b", "feature")
+ return base
+
+
+def test_files_to_check_diff_base_returns_only_changed(tmp_path):
+ base = _branch_with_change(tmp_path)
+ (tmp_path / "new.py").write_text("y = 2\n", encoding="utf-8")
+ _git(tmp_path, "add", "-A")
+ _git(tmp_path, "commit", "-m", "feature")
+ assert git.files_to_check(tmp_path, diff_base=base) == ["new.py"]
+
+
+def test_files_to_check_diff_base_unknown_ref_raises(tmp_path):
+ _init_repo(tmp_path)
+ (tmp_path / "a.py").write_text("x = 1\n", encoding="utf-8")
+ _git(tmp_path, "add", "-A")
+ _git(tmp_path, "commit", "-m", "c")
+ with pytest.raises(git.GitError):
+ git.files_to_check(tmp_path, diff_base="origin/does-not-exist")
+
+
+def test_check_diff_blocks_on_changed_file(tmp_path, monkeypatch, capsys):
+ base = _branch_with_change(tmp_path)
+ (tmp_path / ".bec").mkdir()
+ (tmp_path / ".bec" / "rules.yaml").write_text(_rules_yaml("debug_remnants"), encoding="utf-8")
+ (tmp_path / "bad.py").write_text("breakpoint()\n", encoding="utf-8")
+ _git(tmp_path, "add", "-A")
+ _git(tmp_path, "commit", "-m", "feature")
+ monkeypatch.chdir(tmp_path)
+ assert cli.main(["check", "--diff", base]) == 1
+ assert "Commit BLOCKED" in capsys.readouterr().out
+
+
+def test_check_diff_ignores_violation_outside_the_diff(tmp_path, monkeypatch, capsys):
+ # A pre-existing violation on the base must not fail CI: --diff only checks what
+ # the branch actually changed, so a clean change passes regardless of old debt.
+ _init_repo(tmp_path)
+ (tmp_path / ".bec").mkdir()
+ (tmp_path / ".bec" / "rules.yaml").write_text(_rules_yaml("debug_remnants"), encoding="utf-8")
+ (tmp_path / "legacy.py").write_text("breakpoint()\n", encoding="utf-8")
+ _git(tmp_path, "add", "-A")
+ _git(tmp_path, "commit", "-m", "base")
+ base = _current_branch(tmp_path)
+ _git(tmp_path, "checkout", "-b", "feature")
+ (tmp_path / "feature.py").write_text("z = 3\n", encoding="utf-8")
+ _git(tmp_path, "add", "-A")
+ _git(tmp_path, "commit", "-m", "feature")
+ monkeypatch.chdir(tmp_path)
+ assert cli.main(["check", "--diff", base]) == 0
+ assert "All good" in capsys.readouterr().out
+
+
+def test_check_all_and_diff_are_mutually_exclusive():
+ with pytest.raises(SystemExit):
+ cli.main(["check", "--all", "--diff", "main"])
+
+
# --- the mcp subcommand ---
def test_mcp_subcommand_without_extra(monkeypatch):
From 2d8a5283d90e6c682608cf5bf1bc0eab81d95fcd Mon Sep 17 00:00:00 2001
From: DataDave-Dev <153755137+DataDave-Dev@users.noreply.github.com>
Date: Wed, 1 Jul 2026 17:22:50 -0600
Subject: [PATCH 3/6] feat: official GitHub Action to run becwright on pull
requests
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
action.yml is a composite action that installs becwright, resolves the PR base
ref, and runs 'becwright check --diff ' — checking only the files the PR
changed. Making it a required check enforces the rules even when the local
pre-commit hook is bypassed with --no-verify: infrastructure, not a suggestion.
Pre-existing debt on the rest of the repo never fails the build, so it can be
adopted on a large codebase without a red wall. Inputs: base, version,
python-version, args (all optional).
.github/workflows/becwright.yml dogfoods the action on this repo's own PRs via
the local action with version: . (installs the checked-out becwright), which
both verifies the action end-to-end and guards this repo the way we ask others
to guard theirs.
---
.github/workflows/becwright.yml | 19 +++++++++
action.yml | 70 +++++++++++++++++++++++++++++++++
2 files changed, 89 insertions(+)
create mode 100644 .github/workflows/becwright.yml
create mode 100644 action.yml
diff --git a/.github/workflows/becwright.yml b/.github/workflows/becwright.yml
new file mode 100644
index 0000000..600bb01
--- /dev/null
+++ b/.github/workflows/becwright.yml
@@ -0,0 +1,19 @@
+# Dogfood the official becwright action on our own pull requests: it installs the
+# checked-out becwright (version: .) and checks only the files this PR changes
+# against the repo's own .bec/rules.yaml. This both verifies the action works and
+# guards this repo the same way we ask others to guard theirs.
+name: becwright
+
+on:
+ pull_request:
+
+jobs:
+ becwright:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # full history so the merge-base with the PR base exists
+ - uses: ./
+ with:
+ version: .
diff --git a/action.yml b/action.yml
new file mode 100644
index 0000000..8bc183b
--- /dev/null
+++ b/action.yml
@@ -0,0 +1,70 @@
+name: becwright
+description: >-
+ Enforce BECs (Bound Executable Constraints) on the files a pull request changes.
+ A commit hook can be skipped with `git commit --no-verify`; a required CI check
+ cannot — so this closes that gap and makes the rules infrastructure, not a suggestion.
+author: DataDave-Dev
+branding:
+ icon: shield
+ color: red
+
+inputs:
+ base:
+ description: >-
+ Git ref to diff against. Defaults to the PR base branch
+ (origin/$GITHUB_BASE_REF) on pull_request events, or the repository default
+ branch otherwise. Only files changed vs this ref are checked.
+ required: false
+ default: ''
+ version:
+ description: >-
+ pip requirement specifier used to install becwright — e.g. "becwright",
+ "becwright==0.4.0", or "." to install the checked-out repo (for dogfooding).
+ required: false
+ default: becwright
+ python-version:
+ description: Python version used to run becwright.
+ required: false
+ default: '3.x'
+ args:
+ description: Extra arguments appended to `becwright check` (e.g. "--json").
+ required: false
+ default: ''
+
+runs:
+ using: composite
+ steps:
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ inputs.python-version }}
+
+ - name: Install becwright
+ shell: bash
+ run: pip install "${{ inputs.version }}"
+
+ - name: Check changed files
+ shell: bash
+ env:
+ BEC_BASE: ${{ inputs.base }}
+ BEC_ARGS: ${{ inputs.args }}
+ GH_BASE_REF: ${{ github.base_ref }}
+ GH_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ run: |
+ set -euo pipefail
+ base="$BEC_BASE"
+ if [ -z "$base" ]; then
+ if [ -n "${GH_BASE_REF:-}" ]; then
+ base="origin/${GH_BASE_REF}"
+ else
+ base="origin/${GH_DEFAULT_BRANCH}"
+ fi
+ fi
+ # Make the base ref available even if the checkout was shallow. Harmless if
+ # it is already present; requires fetch-depth: 0 for the merge-base to exist.
+ branch="${GH_BASE_REF:-$GH_DEFAULT_BRANCH}"
+ if [ -n "$branch" ]; then
+ git fetch --no-tags --quiet origin "$branch" || true
+ fi
+ echo "becwright: checking files changed vs ${base}"
+ becwright check --diff "$base" $BEC_ARGS
From cc58177408cc9ba07b32cd918488d0cc117541c1 Mon Sep 17 00:00:00 2001
From: DataDave-Dev <153755137+DataDave-Dev@users.noreply.github.com>
Date: Wed, 1 Jul 2026 17:30:33 -0600
Subject: [PATCH 4/6] =?UTF-8?q?feat:=20becwright=20why=20+=20MCP=20list=5F?=
=?UTF-8?q?rules=20=E2=80=94=20the=20repo's=20queryable=20decision=20memor?=
=?UTF-8?q?y?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every rule already carries its intent, the reason behind it and rejected
alternatives (the BEC's Bound half), but so far they only surfaced when a commit
was blocked. Now they can be read on demand:
- 'becwright why' lists every rule with its intent; 'becwright why ' shows
the full decision record; '--json' emits it for programmatic use.
- The MCP server gains a 'list_rules' tool returning the same records, so an
agent can read the decisions it must not violate *before* writing code and
steer clear of a blocked commit instead of discovering the rule only when it
fires.
report.rule_record() is the shared serializer behind both. This turns the
.bec/rules.yaml catalog into queryable architectural memory: you don't hand the
model all the context, you hand it the decisions it can't break.
---
README.es.md | 11 ++++-
README.md | 11 ++++-
documentation/mcp.md | 7 +++
src/becwright/cli.py | 75 ++++++++++++++++++++++++++++++
src/becwright/mcp_server.py | 18 ++++++++
src/becwright/report.py | 18 ++++++++
tests/test_cli_and_git.py | 92 +++++++++++++++++++++++++++++++++++++
tests/test_mcp.py | 16 ++++++-
8 files changed, 242 insertions(+), 6 deletions(-)
diff --git a/README.es.md b/README.es.md
index afebf12..8ddcd74 100644
--- a/README.es.md
+++ b/README.es.md
@@ -202,6 +202,7 @@ Comandos disponibles:
| `becwright list` | Lista los checks incluidos |
| `becwright check` | Corre las reglas sobre los archivos en staging |
| `becwright check --diff ` | Corre las reglas solo sobre los archivos cambiados vs `` (para CI/PR) |
+| `becwright why [id]` | Muestra la intención + el por qué de las reglas — la memoria de decisiones del repo (`--json` para agentes) |
| `becwright search [texto]` | Lista BECs listas del catálogo incluido |
| `becwright add ` | Instala una BEC del catálogo en `.bec/rules.yaml` (sin conexión) |
| `becwright install` | Instala el hook `pre-commit` nativo |
@@ -295,8 +296,14 @@ Agrega un skill `becwright` y un comando `/becwright`. Ver
Para resultados estructurados, `becwright check --json` imprime un resumen
legible por máquina, y `becwright mcp` (instalá el extra `mcp`: `pipx install
"becwright[mcp]"`) levanta un servidor MCP — MCP es una forma estándar de que
-las herramientas de IA se conecten a habilidades extra — que expone `check` y
-`list_checks` a cualquier agente. Ver [`documentation/mcp.md`](documentation/mcp.md).
+las herramientas de IA se conecten a habilidades extra — que expone `check`,
+`list_checks` y `list_rules` a cualquier agente. Ver [`documentation/mcp.md`](documentation/mcp.md).
+
+Mejor aún, un agente puede leer las reglas *antes* de escribir código: `becwright
+why --json` le entrega las decisiones que no puede violar (la intención de cada
+regla y su razón), así las esquiva en vez de descubrir la regla recién cuando el
+commit se bloquea. El catálogo `.bec/rules.yaml` se vuelve la memoria de
+decisiones consultable del repo.
Una regla en `.bec/rules.yaml`:
diff --git a/README.md b/README.md
index f3acb97..77f33b9 100644
--- a/README.md
+++ b/README.md
@@ -194,6 +194,7 @@ Available commands:
| `becwright list` | List the built-in checks |
| `becwright check` | Runs the rules over the staged files |
| `becwright check --diff ` | Runs the rules over only the files changed vs `` (for CI/PR) |
+| `becwright why [id]` | Shows the intent + why behind the rules — the repo's decision memory (`--json` for agents) |
| `becwright search [query]` | Lists ready-made BECs from the built-in catalog |
| `becwright add ` | Installs a catalog BEC into `.bec/rules.yaml` (offline) |
| `becwright install` | Installs the native `pre-commit` hook |
@@ -287,8 +288,14 @@ It adds a `becwright` skill and a `/becwright` command. See
For structured results, `becwright check --json` prints a machine-readable
summary, and `becwright mcp` (install the `mcp` extra: `pipx install
"becwright[mcp]"`) runs an MCP server — MCP is a standard way for AI tools to
-plug in extra abilities — exposing `check` and `list_checks` to any agent. See
-[`documentation/mcp.md`](documentation/mcp.md).
+plug in extra abilities — exposing `check`, `list_checks` and `list_rules` to any
+agent. See [`documentation/mcp.md`](documentation/mcp.md).
+
+Better yet, an agent can read the rules *before* it writes code: `becwright why
+--json` hands it the decisions it must not violate (each rule's intent and the
+reason behind it), so it steers clear of a broken commit instead of discovering
+the rule only when the commit is blocked. The `.bec/rules.yaml` catalog becomes
+the repo's queryable decision memory.
A rule in `.bec/rules.yaml`:
diff --git a/documentation/mcp.md b/documentation/mcp.md
index e306227..b3840cd 100644
--- a/documentation/mcp.md
+++ b/documentation/mcp.md
@@ -53,10 +53,17 @@ pipx install "becwright[mcp]" # or: pip install "becwright[mcp]"
|---|---|---|
| `check` | `all_files` (bool), `path` (optional repo dir) | the same summary as `check --json` |
| `list_checks` | — | the built-in checks as `{name, description}` |
+| `list_rules` | `path` (optional repo dir) | the repo's rules as decision records (`id`, `severity`, `intent`, `why_it_matters`, `rejected_alternatives`, `paths`, `check`) |
| `preview_rule` | `check`, `paths`, `exclude` (optional), `all_files`, `path` | `{matched_files, passed, output, note}` — a dry-run without writing the rule |
| `propose_rules_from_claude_md` | `path` (optional repo dir) | `{rules, unmapped_hint}` — the rules becwright can derive from the repo's CLAUDE.md |
| `add_rule` | `id`, `check`, `paths`, `intent`, `why_it_matters`, `severity`, `exclude`, `confirm`, `path` | writes a rule to `.bec/rules.yaml` — preview unless `confirm=true` |
+**`list_rules`** is the decision memory: it returns every rule with its intent,
+the reason behind it and the check that enforces it, so an agent can read the
+decisions it must not violate *before* writing code — the same data `check`
+surfaces on failure, but available up front. It mirrors the CLI `becwright why
+--json`.
+
**`propose_rules_from_claude_md`** returns the rules becwright can derive
deterministically from the prose (each with the phrase that triggered it) — the
agent's *starting point*. **`preview_rule`** lets the agent *validate* a rule
diff --git a/src/becwright/cli.py b/src/becwright/cli.py
index e036395..258fc71 100644
--- a/src/becwright/cli.py
+++ b/src/becwright/cli.py
@@ -50,6 +50,76 @@ def _print_result(result: Result) -> None:
print()
+def _severity_label(rule, width: int = 0) -> str:
+ codes = {"blocking": (RED, BOLD), "advisory": (CYAN,)}.get(rule.severity, (YELLOW,))
+ return _style(rule.severity.ljust(width), *codes)
+
+
+def _one_line(text: str, limit: int = 72) -> str:
+ collapsed = " ".join(text.split())
+ return collapsed if len(collapsed) <= limit else collapsed[: limit - 1].rstrip() + "…"
+
+
+def _print_rules_overview(rules) -> None:
+ print(f"{_style('becwright why', BOLD)} "
+ f"{_style(f'— the decisions this repo enforces ({len(rules)} rule(s))', DIM)}\n")
+ width = max(len(r.id) for r in rules)
+ for r in rules:
+ intent = _one_line(r.intent) if r.intent else _style("(no intent recorded)", DIM)
+ print(f" {_style(r.id.ljust(width), GREEN)} {_severity_label(r, 8)} {intent}")
+ print(_style("\n Run `becwright why ` for the full record of one rule, "
+ "or add --json for agents.", DIM))
+
+
+def _print_rule_detail(rule) -> None:
+ print(f"{_style('becwright why', BOLD)} {_style(rule.id, GREEN)} ({_severity_label(rule)})\n")
+ if rule.intent:
+ print(f" {_style('Intent:', DIM)}")
+ print(f" {rule.intent}")
+ if rule.why_it_matters:
+ print(f" {_style('Why it matters:', DIM)}")
+ print(f" {rule.why_it_matters}")
+ if rule.rejected_alternatives:
+ print(f" {_style('Rejected alternatives:', DIM)}")
+ for alt in rule.rejected_alternatives:
+ print(f" - {alt}")
+ applies = "the commit message" if rule.target == "commit-msg" else (
+ ", ".join(rule.paths) or "(no paths)")
+ print(f" {_style('Applies to:', DIM)} {applies}")
+ if rule.exclude:
+ print(f" {_style('Excluding:', DIM)} {', '.join(rule.exclude)}")
+ print(f" {_style('Check:', DIM)} {rule.check}")
+
+
+def _cmd_why(args: argparse.Namespace) -> int:
+ root = git.repo_root()
+ rules = load_rules(root / ".bec" / "rules.yaml")
+ if args.rule_id:
+ rule = next((r for r in rules if r.id == args.rule_id), None)
+ if rule is None:
+ print(_style(f"No rule with id '{args.rule_id}' in .bec/rules.yaml.", RED),
+ file=sys.stderr)
+ if rules:
+ print(_style(f" Known ids: {', '.join(r.id for r in rules)}", DIM),
+ file=sys.stderr)
+ return 1
+ if args.json:
+ import json
+ print(json.dumps(report.rule_record(rule), indent=2))
+ return 0
+ _print_rule_detail(rule)
+ return 0
+ if args.json:
+ import json
+ print(json.dumps({"rules": [report.rule_record(r) for r in rules]}, indent=2))
+ return 0
+ if not rules:
+ print(_style("No .bec/rules.yaml with rules. Run `becwright init` to create some.", YELLOW))
+ return 0
+ _print_rules_overview(rules)
+ return 0
+
+
def _unknown_builtin_checks(rules, root: Path) -> list[tuple[str, str]]:
"""Rules whose `check` uses the `becwright run ` form with a that
is not a built-in check. Such a rule can never pass — the check exits with an
@@ -799,6 +869,11 @@ def _build_parser() -> argparse.ArgumentParser:
help="derive rules from the repo's CLAUDE.md (best-effort: maps prohibitions to enforceable checks)")
p_init.set_defaults(func=_cmd_init)
+ p_why = sub.add_parser("why", help="show the intent + why behind the rules (the repo's decision memory)")
+ p_why.add_argument("rule_id", nargs="?", help="rule id to explain (default: list every rule)")
+ p_why.add_argument("--json", action="store_true", help="output as JSON (for AI agents to consult before writing code)")
+ p_why.set_defaults(func=_cmd_why)
+
p_run = sub.add_parser("run", help="run a built-in check against files on stdin (used inside rules)")
p_run.add_argument("module", help="built-in check name (see `becwright list`)")
p_run.add_argument("args", nargs=argparse.REMAINDER, help="arguments forwarded to the check")
diff --git a/src/becwright/mcp_server.py b/src/becwright/mcp_server.py
index 503fa09..e9564a5 100644
--- a/src/becwright/mcp_server.py
+++ b/src/becwright/mcp_server.py
@@ -47,6 +47,24 @@ def list_checks() -> list[dict]:
]
+@mcp.tool()
+def list_rules(path: str | None = None) -> list[dict]:
+ """List the repo's rules as decision records — each with its intent, the reason
+ behind it, rejected alternatives and the check that enforces it.
+
+ Read these *before* writing code: they are the decisions this repo will not let
+ you violate, so you can steer clear of a blocked commit instead of discovering a
+ rule only when it fires. This is the queryable half of the same data `check`
+ surfaces on failure.
+
+ Args:
+ path: a directory inside the target git repo (defaults to the cwd).
+ """
+ from .rules import load_rules
+ root = git.repo_root(Path(path) if path else None)
+ return [report.rule_record(r) for r in load_rules(root / ".bec" / "rules.yaml")]
+
+
@mcp.tool()
def preview_rule(check: str, paths: list[str], exclude: list[str] | None = None,
all_files: bool = True, path: str | None = None) -> dict:
diff --git a/src/becwright/report.py b/src/becwright/report.py
index de1fbcb..35994f0 100644
--- a/src/becwright/report.py
+++ b/src/becwright/report.py
@@ -25,6 +25,24 @@ def gather(
return rules, files, evaluate(rules, files, staged_root)
+def rule_record(rule: Rule) -> dict:
+ """A rule's *Bound* half — its intent, reason and the decision behind it — as a
+ serializable record. Shared by `becwright why --json` and any agent that wants
+ the decisions it must not violate *before* writing code, not only when a commit
+ fails."""
+ return {
+ "id": rule.id,
+ "severity": rule.severity,
+ "target": rule.target,
+ "intent": rule.intent or None,
+ "why_it_matters": rule.why_it_matters or None,
+ "rejected_alternatives": list(rule.rejected_alternatives),
+ "paths": list(rule.paths),
+ "exclude": list(rule.exclude),
+ "check": rule.check,
+ }
+
+
def payload(rules: list[Rule], files: list[str], result: Result | None) -> dict:
"""Build a JSON-serializable summary shared by `check --json` and the MCP server."""
results = []
diff --git a/tests/test_cli_and_git.py b/tests/test_cli_and_git.py
index 3d3515b..14b2792 100644
--- a/tests/test_cli_and_git.py
+++ b/tests/test_cli_and_git.py
@@ -420,6 +420,98 @@ def test_check_all_and_diff_are_mutually_exclusive():
cli.main(["check", "--all", "--diff", "main"])
+# --- the why subcommand (decision memory) ---
+
+_WHY_RULES = """\
+rules:
+ - id: no-eval
+ intent: "Avoid eval and exec."
+ why_it_matters: "Arbitrary code execution is a security hole."
+ rejected_alternatives:
+ - "sandboxing eval"
+ paths: ["**/*.py"]
+ exclude: ["tests/**"]
+ check: "becwright run dangerous_eval"
+ severity: blocking
+ - id: conv
+ target: commit-msg
+ intent: "Use Conventional Commits."
+ check: 'becwright run require --pattern "^feat: "'
+ severity: warning
+"""
+
+
+def _write_why_rules(tmp_path):
+ (tmp_path / ".bec").mkdir()
+ (tmp_path / ".bec" / "rules.yaml").write_text(_WHY_RULES, encoding="utf-8")
+
+
+def test_why_lists_all_rules(tmp_path, monkeypatch, capsys):
+ _init_repo(tmp_path)
+ _write_why_rules(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ assert cli.main(["why"]) == 0
+ out = capsys.readouterr().out
+ assert "no-eval" in out and "conv" in out
+ assert "Avoid eval and exec." in out
+
+
+def test_why_detail_shows_full_record(tmp_path, monkeypatch, capsys):
+ _init_repo(tmp_path)
+ _write_why_rules(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ assert cli.main(["why", "no-eval"]) == 0
+ out = capsys.readouterr().out
+ assert "Intent:" in out and "Why it matters:" in out
+ assert "Rejected alternatives:" in out and "sandboxing eval" in out
+ assert "dangerous_eval" in out and "tests/**" in out
+
+
+def test_why_commit_msg_rule_applies_to_message(tmp_path, monkeypatch, capsys):
+ _init_repo(tmp_path)
+ _write_why_rules(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ assert cli.main(["why", "conv"]) == 0
+ assert "the commit message" in capsys.readouterr().out
+
+
+def test_why_unknown_id_returns_1_and_lists_ids(tmp_path, monkeypatch, capsys):
+ _init_repo(tmp_path)
+ _write_why_rules(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ assert cli.main(["why", "ghost"]) == 1
+ err = capsys.readouterr().err
+ assert "ghost" in err and "no-eval" in err
+
+
+def test_why_json_lists_all(tmp_path, monkeypatch, capsys):
+ import json
+ _init_repo(tmp_path)
+ _write_why_rules(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ assert cli.main(["why", "--json"]) == 0
+ data = json.loads(capsys.readouterr().out)
+ assert [r["id"] for r in data["rules"]] == ["no-eval", "conv"]
+ assert data["rules"][0]["why_it_matters"].startswith("Arbitrary code execution")
+
+
+def test_why_json_single_rule(tmp_path, monkeypatch, capsys):
+ import json
+ _init_repo(tmp_path)
+ _write_why_rules(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ assert cli.main(["why", "no-eval", "--json"]) == 0
+ rec = json.loads(capsys.readouterr().out)
+ assert rec["id"] == "no-eval" and rec["rejected_alternatives"] == ["sandboxing eval"]
+
+
+def test_why_no_rules_is_friendly(tmp_path, monkeypatch, capsys):
+ _init_repo(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ assert cli.main(["why"]) == 0
+ assert "No .bec/rules.yaml" in capsys.readouterr().out
+
+
# --- the mcp subcommand ---
def test_mcp_subcommand_without_extra(monkeypatch):
diff --git a/tests/test_mcp.py b/tests/test_mcp.py
index 77db082..f279283 100644
--- a/tests/test_mcp.py
+++ b/tests/test_mcp.py
@@ -37,8 +37,8 @@ def _repo(path):
def test_tools_registered():
tools = asyncio.run(mcp_server.mcp.list_tools())
assert {t.name for t in tools} == {
- "check", "list_checks", "preview_rule", "propose_rules_from_claude_md",
- "add_rule",
+ "check", "list_checks", "list_rules", "preview_rule",
+ "propose_rules_from_claude_md", "add_rule",
}
@@ -48,6 +48,18 @@ def test_list_checks_tool_returns_all_builtins():
assert names == sorted(names)
+def test_list_rules_tool_returns_decision_records(tmp_path):
+ _repo_with_rule(tmp_path)
+ records = mcp_server.list_rules(path=str(tmp_path))
+ assert [r["id"] for r in records] == ["no-bp"]
+ assert records[0]["severity"] == "blocking" and "check" in records[0]
+
+
+def test_list_rules_tool_empty_when_no_rules(tmp_path):
+ _repo(tmp_path)
+ assert mcp_server.list_rules(path=str(tmp_path)) == []
+
+
def test_check_tool_reports_block(tmp_path):
_repo_with_rule(tmp_path)
(tmp_path / "a.py").write_text("breakpoint()\n", encoding="utf-8")
From 70a6773470d9c71e9d0d638634841044024c9796 Mon Sep 17 00:00:00 2001
From: DataDave-Dev <153755137+DataDave-Dev@users.noreply.github.com>
Date: Wed, 1 Jul 2026 17:33:57 -0600
Subject: [PATCH 5/6] docs: position becwright as "the enforcement layer for AI
coding agents"
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Leads every one-liner with the category instead of the mechanism — a piece of
the stack, not a script. Consistent across the surfaces where the pitch appears:
both README heroes, the PyPI (pyproject) and npm descriptions, and the Claude
Code plugin (plugin.json + its README). The sign-vs-guard hook stays; the
positioning line now sits above it and the mechanism (runs the rules, blocks the
commit, any author) follows.
---
README.es.md | 7 +++++--
README.md | 7 +++++--
integrations/claude-code/.claude-plugin/plugin.json | 2 +-
integrations/claude-code/README.md | 5 +++--
npm/becwright/package.json | 2 +-
pyproject.toml | 2 +-
6 files changed, 16 insertions(+), 9 deletions(-)
diff --git a/README.es.md b/README.es.md
index 8ddcd74..929ee1f 100644
--- a/README.es.md
+++ b/README.es.md
@@ -10,8 +10,11 @@
[](https://www.npmjs.com/package/becwright)
[](https://pypi.org/project/becwright/)
-**Reglas que se ejecutan, no notas que se ignoran.**
-Tu `CLAUDE.md` es un *cartel*. becwright es el *guardia*.
+**La capa de enforcement para agentes de IA.**
+
+Reglas que se ejecutan, no notas que se ignoran. Tu `CLAUDE.md` es un *cartel*;
+becwright es el *guardia* — corre tus reglas sobre el código y frena el commit
+cuando una se rompe, sin importar qué modelo (o persona) lo escribió.
Determinista, no probabilístico · cualquier lenguaje · sin Python · frena el commit **y** lleva el *por qué*.
diff --git a/README.md b/README.md
index 77f33b9..46fcf3c 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,11 @@
[](https://www.npmjs.com/package/becwright)
[](https://pypi.org/project/becwright/)
-**Rules that run, not notes that get ignored.**
-Your `CLAUDE.md` is a *sign*. becwright is the *guard*.
+**The enforcement layer for AI coding agents.**
+
+Rules that run, not notes that get ignored. Your `CLAUDE.md` is a *sign*;
+becwright is the *guard* — it runs your rules against the code and blocks the
+commit when one breaks, no matter which model (or person) wrote it.
Deterministic, not probabilistic · any language · no Python required · blocks the commit **and** carries the *why*.
diff --git a/integrations/claude-code/.claude-plugin/plugin.json b/integrations/claude-code/.claude-plugin/plugin.json
index 0053007..fb51dc9 100644
--- a/integrations/claude-code/.claude-plugin/plugin.json
+++ b/integrations/claude-code/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "becwright",
- "description": "Deterministic, commit-blocking constraints (BECs) on your code. Install and use becwright from any session; the guaranteed safety net that complements CLAUDE.md. No Python required.",
+ "description": "The enforcement layer for AI coding agents — deterministic, commit-blocking constraints (BECs). Install and use becwright from any session; the guaranteed safety net that complements CLAUDE.md. No Python required.",
"version": "0.2.0",
"author": {
"name": "Alonso David De Leon Rodarte"
diff --git a/integrations/claude-code/README.md b/integrations/claude-code/README.md
index f3d76b5..4c98e48 100644
--- a/integrations/claude-code/README.md
+++ b/integrations/claude-code/README.md
@@ -1,7 +1,8 @@
# becwright — Claude Code plugin
-A Claude Code plugin so any agent session can install and drive becwright: the
-deterministic, commit-blocking safety net that complements `CLAUDE.md`.
+A Claude Code plugin so any agent session can install and drive becwright — the
+enforcement layer for AI coding agents: the deterministic, commit-blocking safety
+net that complements `CLAUDE.md`.
## Install
diff --git a/npm/becwright/package.json b/npm/becwright/package.json
index 2544be0..52ed3d3 100644
--- a/npm/becwright/package.json
+++ b/npm/becwright/package.json
@@ -1,7 +1,7 @@
{
"name": "becwright",
"version": "0.4.0",
- "description": "Deterministic, portable constraints (BECs) that block commits violating your rules — a safety net for AI-written and human-written code. No Python required.",
+ "description": "The enforcement layer for AI coding agents — deterministic, portable constraints (BECs) that block a commit when a rule breaks, for AI-written and human-written code. No Python required.",
"bin": {
"becwright": "bin/becwright.js"
},
diff --git a/pyproject.toml b/pyproject.toml
index cd4d351..85dfef2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "becwright"
version = "0.4.0"
-description = "Deterministic, portable constraints (BECs) that block commits violating your rules — a safety net for AI-written and human-written code."
+description = "The enforcement layer for AI coding agents — deterministic, portable constraints (BECs) that block a commit when a rule breaks, for AI-written and human-written code."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "Alonso David De Leon Rodarte" }]
From 6c90a6549636c56372ace0fa5c3a94f14869e76e Mon Sep 17 00:00:00 2001
From: DataDave-Dev <153755137+DataDave-Dev@users.noreply.github.com>
Date: Wed, 1 Jul 2026 17:35:54 -0600
Subject: [PATCH 6/6] docs: add the lean-context angle for AI agents (check
--json / why --json)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The honest version of 'give the AI only what it needs': a blocked commit
returns the one rule that broke, its why and the exact lines, so the agent fixes
that precisely instead of re-reading the whole style guide into context — and the
guarantee never depended on the model reading anything. Added to the AI-agents
section of both READMEs.
---
README.es.md | 8 ++++++++
README.md | 7 +++++++
2 files changed, 15 insertions(+)
diff --git a/README.es.md b/README.es.md
index 929ee1f..33f8042 100644
--- a/README.es.md
+++ b/README.es.md
@@ -308,6 +308,14 @@ regla y su razón), así las esquiva en vez de descubrir la regla recién cuando
commit se bloquea. El catálogo `.bec/rules.yaml` se vuelve la memoria de
decisiones consultable del repo.
+En ambos casos la señal se mantiene magra. Un commit bloqueado devuelve la única
+regla que se rompió, su *por qué* y las líneas exactas — el agente arregla justo
+eso en vez de releer la guía de estilo entera en el contexto. El consejo de
+siempre es "dale más contexto al modelo"; becwright lo da vuelta — le pasás la
+constraint puntual que rompió, verificada de forma determinista, no el reglamento
+completo. Menos tokens, loop más ajustado, y la garantía no depende de que el
+modelo haya leído nada.
+
Una regla en `.bec/rules.yaml`:
```yaml
diff --git a/README.md b/README.md
index 46fcf3c..a8bd694 100644
--- a/README.md
+++ b/README.md
@@ -300,6 +300,13 @@ reason behind it), so it steers clear of a broken commit instead of discovering
the rule only when the commit is blocked. The `.bec/rules.yaml` catalog becomes
the repo's queryable decision memory.
+Either way the signal stays lean. A blocked commit returns the one rule that
+broke, its *why*, and the exact lines — the agent fixes precisely that instead of
+re-reading the whole style guide into context. The usual advice is "give the
+model more context"; becwright inverts it — you hand it the specific constraint it
+broke, checked deterministically, not the entire rulebook. Fewer tokens, tighter
+loop, and the guarantee doesn't depend on the model having read anything at all.
+
A rule in `.bec/rules.yaml`:
```yaml