From 98f3373af333fced1af3b087104b541f18db0e05 Mon Sep 17 00:00:00 2001 From: pkuwkl Date: Thu, 23 Jul 2026 17:25:22 +0800 Subject: [PATCH 1/3] feat(harness): add contexts rule, commit-msg + anti-bypass hooks, harness doc Bootstrap the agent-and-contributor harness layer. Part of #134. - .claude/rules/contexts-authoring.md: a path-scoped Claude Code rule (paths: contexts/**) that surfaces the contexts authoring standard just-in-time. It points at the canonical write-contexts.md instead of duplicating it. Codex has no per-file path-scoped instruction rule, so no parallel file is added; the asymmetry is documented, not worked around. - commit-msg git hook (scripts/hooks/commit_msg_check.py + a commit-msg stage in .pre-commit-config.yaml): rejects a non-English (CJK) or non-Conventional-Commit subject line. Tool-agnostic, so it fires for a human, Claude, or Codex alike because git itself invokes it. - anti-bypass agent hook (scripts/hooks/pre_tool_use_no_bypass.py, shared by .claude/settings.json and .codex/hooks.json): a PreToolUse deny when a Bash git command carries --no-verify, which would skip the commit-msg / pre-commit / pre-push checks. This is the one guarantee a git hook cannot self-enforce, so it lives at the agent layer. - contexts/dev/harness-engineering.md: documents the enforcement layers (CI / git hooks / agent controls), the Claude-vs-Codex mechanism map, the content-alignment-vs-behavior-alignment principle, and a canonical-source table. Registered in contexts/dev/README.md. - Focused tests under tests/hooks/ for both hook scripts. verify.sh passes: ruff format + ruff check + basedpyright + import-linter + pytest --cov (420 passed, 85.66% coverage). Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/rules/contexts-authoring.md | 17 +++++ .claude/settings.json | 16 +++++ .codex/hooks.json | 17 +++++ .pre-commit-config.yaml | 5 ++ contexts/dev/README.md | 1 + contexts/dev/harness-engineering.md | 89 +++++++++++++++++++++++++ scripts/hooks/commit_msg_check.py | 83 +++++++++++++++++++++++ scripts/hooks/pre_tool_use_no_bypass.py | 68 +++++++++++++++++++ scripts/pre-commit-setup.sh | 1 + tests/hooks/__init__.py | 0 tests/hooks/_load.py | 17 +++++ tests/hooks/test_commit_msg_check.py | 44 ++++++++++++ tests/hooks/test_no_bypass_guard.py | 40 +++++++++++ 13 files changed, 398 insertions(+) create mode 100644 .claude/rules/contexts-authoring.md create mode 100644 .claude/settings.json create mode 100644 .codex/hooks.json create mode 100644 contexts/dev/harness-engineering.md create mode 100755 scripts/hooks/commit_msg_check.py create mode 100755 scripts/hooks/pre_tool_use_no_bypass.py create mode 100644 tests/hooks/__init__.py create mode 100644 tests/hooks/_load.py create mode 100644 tests/hooks/test_commit_msg_check.py create mode 100644 tests/hooks/test_no_bypass_guard.py diff --git a/.claude/rules/contexts-authoring.md b/.claude/rules/contexts-authoring.md new file mode 100644 index 0000000..a281976 --- /dev/null +++ b/.claude/rules/contexts-authoring.md @@ -0,0 +1,17 @@ +--- +paths: + - "contexts/**/*.md" +--- + +# Contexts authoring standard + +You are editing a page under `contexts/`. Follow the QuantMind contexts authoring standard. `tests/test_contexts.py` (run inside `scripts/verify.sh`) enforces the structural rules below, so a page that ignores them fails the build — getting them right up front avoids a red verify. + +Load-bearing rules: + +- Open every `contexts/**/*.md` with `## Quick Summary`, then `## Contents`, in that order, both within the first 80 lines. +- The `## Contents` links must exactly match the page's `##` section headings (GitHub-style anchors: lowercase, punctuation stripped, spaces and hyphens collapsed to one hyphen). +- Register the page in its index and keep index links resolving: a design page in `contexts/design/README.md`, a dev route in `contexts/dev/README.md`. +- Do not hard-wrap prose to a fixed width. Keep each paragraph and list item on one physical line; tables, fenced code, and mermaid keep their own line structure. + +Canonical source (read it before a non-trivial contexts change): `.claude/skills/quantmind-dev/references/write-contexts.md`. Run `bash scripts/verify.sh` before pushing a contexts change. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..6964d5a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PROJECT_DIR}/scripts/hooks/pre_tool_use_no_bypass.py\"", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..f012e68 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 \"$(git rev-parse --show-toplevel)/scripts/hooks/pre_tool_use_no_bypass.py\"", + "timeout": 10, + "statusMessage": "Checking for --no-verify bypass" + } + ] + } + ] + } +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 486aa30..3e004f0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,6 +18,11 @@ repos: - repo: local hooks: + - id: commit-msg-format + name: Enforce English Conventional Commit subject + entry: python3 scripts/hooks/commit_msg_check.py + language: system + stages: [commit-msg] # Runs against the commit message file. - id: verify name: Run scripts/verify.sh (deterministic verification harness) entry: bash scripts/verify.sh diff --git a/contexts/dev/README.md b/contexts/dev/README.md index 932e25f..2341b9f 100644 --- a/contexts/dev/README.md +++ b/contexts/dev/README.md @@ -21,6 +21,7 @@ Use this index when extending or maintaining the repository. Follow the linked r | Package responsibilities and coding rules | [`AGENTS.md`](../../AGENTS.md) or [`CLAUDE.md`](../../CLAUDE.md) | | Component-development workflow | [`quantmind-dev` component workflow](../../.agents/skills/quantmind-dev/references/develop-components.md) | | Issue and pull-request labels | [Repository label guidance](labels.md) | +| Enforcement layers, hooks, rules, and Claude/Codex alignment | [Harness engineering](harness-engineering.md) | | Issue and pull-request body formatting | [GitHub writing style](github-writing.md) | | Public operation and source catalog | [`docs/README.md`](../../docs/README.md) | | Public operation naming | [Operation naming rules](../design/operations/naming.md) | diff --git a/contexts/dev/harness-engineering.md b/contexts/dev/harness-engineering.md new file mode 100644 index 0000000..80822f6 --- /dev/null +++ b/contexts/dev/harness-engineering.md @@ -0,0 +1,89 @@ +# Harness engineering + +## Quick Summary + +- **Purpose**: Explain how QuantMind keeps contributors and coding agents on the same rules, which enforcement layer catches whom, and how the same rule is expressed for both Claude Code and Codex without maintaining two copies of its content. +- **Read when**: Adding or changing a hook, a rule, a CI gate, or any `AGENTS.md` / `CLAUDE.md` / skill guidance; or deciding whether a new rule should be advisory (a rule) or a hard guarantee (a hook). +- **Load next**: For contexts-page rules, `.claude/skills/quantmind-dev/references/write-contexts.md`; for commit and PR rules, the `quantmind-dev` skill references. +- **Status**: Current, except the `CLAUDE.md` = `@AGENTS.md` single-source change, which is planned (it needs a matching `tests/test_contexts.py` update and lands separately). + +## Contents + +- [Enforcement Layers](#enforcement-layers) +- [Mechanism Map Across Claude and Codex](#mechanism-map-across-claude-and-codex) +- [Align Content First, Behavior Only for Guarantees](#align-content-first-behavior-only-for-guarantees) +- [What This Repo Enforces](#what-this-repo-enforces) +- [References](#references) + +## Enforcement Layers + +Enforcement is layered by *who it can catch*, not by tool. A public library has external contributors who never installed our local hooks and may commit from a raw terminal, so the only universal floor is server-side CI. + +```mermaid +flowchart LR + A["Contributor or agent"] --> B["Local git hooks"] + B --> C["CI required check (server-side)"] + A -. "agent only" .-> D["Agent PreToolUse guard"] +``` + +| Layer | Mechanism | Catches | Role | +|---|---|---|---| +| CI (server-side) | `.github/workflows/ci.yml` running `scripts/verify.sh` as a required check | Everyone, unskippable | The floor. The only guarantee for external contributors. | +| Local git hooks | `.pre-commit-config.yaml` (`commit-msg`, pre-commit, pre-push) | Whoever ran `pre-commit install`; agents that shell out to `git` | Fast local feedback; not relied on for external contributors. | +| Agent controls | `.claude/rules/`, `.claude/settings.json` hooks, `.codex/hooks.json` | Only agents running in the repo | Legibility and process guards for Claude Code / Codex; never the floor. | + +The consequence for design: put the *guarantee* for anything that must hold for every contributor in CI. Local git hooks and agent controls are developer-experience layers on top, valuable but never the sole line of defense. + +## Mechanism Map Across Claude and Codex + +The two agents expose similar capabilities under names that do **not** line up. The word "rules" in particular means different things on each side, so map by *purpose*, not by name. + +| Purpose | Claude Code | Codex | +|---|---|---| +| Always-on project instructions | `CLAUDE.md` | `AGENTS.md` | +| Path- or area-scoped instructions | `.claude/rules/*.md` with a `paths:` glob | Nested `AGENTS.md` in the subdirectory (directory-scoped, coarser) | +| Command allow / deny / ask | `permissions` in settings | `.codex/rules/*.rules` (Starlark `prefix_rule`) | +| Lifecycle enforcement / dynamic gating | Hooks (`.claude/settings.json`) | Hooks (`.codex/hooks.json`), behind `[features] hooks` + per-user `/hooks` trust approval | + +Two asymmetries this repo lives with: + +- **Codex has no per-file path-scoped instruction rule.** Its closest match to `.claude/rules/` is a nested `AGENTS.md`, loaded by directory proximity rather than by glob on the edited file. We do not hard-add a parallel Codex file for every Claude rule; instead the rule's content lives in one canonical place and the Claude rule points at it (see the next section). +- **Codex "rules" are an execution-policy allowlist**, the analogue of Claude Code's `permissions`, not of `.claude/rules/`. Do not confuse the two when reading either config. + +## Align Content First, Behavior Only for Guarantees + +Alignment has two levels. **Content alignment** means both agents get the *same rule from one source*; **behavior alignment** means the *trigger and mechanics are identical* on both. Content alignment is almost always enough and costs nothing; behavior alignment costs a shared hook script plus, on Codex, a feature flag and a trust prompt. Reach for behavior alignment only when a rule must be a hard, every-time guarantee. + +- **Advisory guidance** (how to write a contexts page) uses each platform's native rule mechanism, both pointing at one canonical source. A declarative rule is loaded once when a matching file is opened, so it adds no per-edit subprocess and does not re-inject when several files are edited. +- **Hard guarantees** (do not bypass verification) use a shared hook script invoked from both `.claude/settings.json` and `.codex/hooks.json`, so the behavior is identical for both agents. + +Canonical sources — each rule's content lives in exactly one file: + +| Rule | Single source | Claude route | Codex route | +|---|---|---|---| +| Contexts authoring standard | `.claude/skills/quantmind-dev/references/write-contexts.md` | `.claude/rules/contexts-authoring.md` (`paths: contexts/**`) | Read via the skill reference; no parallel file added | +| Commit message convention | `.claude/skills/quantmind-dev/references/commit.md` | `commit-msg` git hook (fires when the agent shells out to `git`) | Same `commit-msg` git hook | +| No verification bypass | `scripts/hooks/pre_tool_use_no_bypass.py` | `.claude/settings.json` PreToolUse | `.codex/hooks.json` PreToolUse | + +Known duplication to resolve later: `write-contexts.md` (and the other skill references) exist as identical copies under both `.claude/skills/` and `.agents/skills/`; a single-source consolidation is future work. + +## What This Repo Enforces + +Each rule is enforced at the layer that fits it, and hooks are kept to mechanical checks only — never semantic judgement. + +| Rule | Mechanism | Layer | +|---|---|---| +| Correctness (ruff, basedpyright, import-linter, pytest) | `scripts/verify.sh` | CI required check + pre-push git hook | +| Contexts page structure (Quick Summary / Contents / anchors / index links) | `tests/test_contexts.py` inside `verify.sh` | CI + local | +| English Conventional Commit subject | `scripts/hooks/commit_msg_check.py` | `commit-msg` git hook (tool-agnostic: humans, Claude, Codex) | +| No `--no-verify` bypass of the hooks above | `scripts/hooks/pre_tool_use_no_bypass.py` | Agent PreToolUse hook | + +The anti-bypass hook is the one guarantee that cannot live in a git hook: `--no-verify` is by definition the flag that turns git hooks off, and CI only notices after the fact. A PreToolUse `deny` stops an agent from skipping the checks at the source. It is a pure mechanical check (a `git` command carrying `--no-verify`) and shared by both agents through one script. Commit-*message* format, by contrast, is a git hook rather than an agent hook, so it also catches a human committing from a terminal. + +Planned next: enforce the commit convention for external contributors at the CI floor (a PR-title lint), since local git hooks only fire for contributors who ran `pre-commit install`. + +## References + +- Claude Code best practices — mechanism selection, context economy, hooks as deterministic guarantees: +- Claude Code path-scoped rules (`.claude/rules/`): +- Codex hooks (lifecycle events, deny / context injection, `[features] hooks`, `/hooks` trust): diff --git a/scripts/hooks/commit_msg_check.py b/scripts/hooks/commit_msg_check.py new file mode 100755 index 0000000..0973862 --- /dev/null +++ b/scripts/hooks/commit_msg_check.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""commit-msg hook: enforce an English, Conventional Commit subject line. + +Invoked by pre-commit's ``commit-msg`` stage with the path to the commit +message file as ``sys.argv[1]``. The commit is blocked (non-zero exit) when +the subject line is not a Conventional Commit or contains CJK characters. + +Stdlib-only and network-free, so it runs identically for a human, for Claude +Code, and for Codex (each spawns git, which invokes this hook). The accepted +type set mirrors ``.claude/skills/quantmind-dev/references/commit.md``; update +that canonical reference and this list together. +""" + +import re +import sys +from pathlib import Path + +# Types mirror commit.md. Keep the two in sync (commit.md is the source). +_TYPES = ("feat", "fix", "refactor", "docs", "test", "chore") +_CONVENTIONAL = re.compile(rf"^(?:{'|'.join(_TYPES)})(?:\([^)]+\))?!?: .+") + +# Git-generated subjects that are not authored Conventional Commits. +_EXEMPT_PREFIXES = ("Merge ", "Revert ", "fixup! ", "squash! ", "amend! ") + +# CJK ranges: symbols/punctuation, hiragana/katakana, CJK ideographs, and the +# full-width/half-width forms block. A subject with any of these is not English. +_CJK = re.compile(r"[ -〿぀-ヿ㐀-䶿一-鿿＀-￯]") + + +def subject_of(message: str) -> str: + """Return the first non-empty, non-comment line of a commit message.""" + for line in message.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#"): + return stripped + return "" + + +def check_subject(subject: str) -> list[str]: + """Return a list of rule violations for ``subject`` (empty means valid).""" + if not subject or subject.startswith(_EXEMPT_PREFIXES): + return [] + errors: list[str] = [] + if _CJK.search(subject): + errors.append( + "contains non-English (CJK) characters; write it in English" + ) + if not _CONVENTIONAL.match(subject): + errors.append( + "is not a Conventional Commit; use " + "`(): ` with type in " + f"{{{', '.join(_TYPES)}}}" + ) + return errors + + +def main() -> int: + """Validate the commit message file git passes; return an exit code.""" + if len(sys.argv) < 2: + # No message file: nothing to check, do not block. + return 0 + try: + message = Path(sys.argv[1]).read_text(encoding="utf-8") + except OSError: + return 0 + subject = subject_of(message) + errors = check_subject(subject) + if not errors: + return 0 + print("Commit message rejected. Subject line:", file=sys.stderr) + print(f" {subject!r}", file=sys.stderr) + for err in errors: + print(f" - {err}", file=sys.stderr) + print( + "See .claude/skills/quantmind-dev/references/commit.md. " + "Fix the message and commit again; do not bypass with --no-verify.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hooks/pre_tool_use_no_bypass.py b/scripts/hooks/pre_tool_use_no_bypass.py new file mode 100755 index 0000000..54faab9 --- /dev/null +++ b/scripts/hooks/pre_tool_use_no_bypass.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""PreToolUse guard: stop an agent from bypassing the repository's git hooks. + +Shared by Claude Code (``.claude/settings.json``) and Codex +(``.codex/hooks.json``); both invoke this same script and speak the same +stdin-JSON / stdout-JSON hook protocol. The guard reads the tool event on +stdin and, for a Bash ``git`` command that carries ``--no-verify`` (which +would skip the ``commit-msg`` / ``pre-commit`` / ``pre-push`` checks), returns +a ``deny`` decision. Everything else passes through untouched. + +Design: mechanical check only, never semantic judgement. It fails open — any +parse error yields no decision — so a malformed event can never wedge the +agent. This is the one guarantee git hooks cannot self-enforce (``--no-verify`` +is, by definition, the flag that turns them off), so it lives at the agent +layer instead. +""" + +import json +import re +import sys + +_GIT = re.compile(r"\bgit\b") +_NO_VERIFY = re.compile(r"(?:^|\s)--no-verify(?:\s|$)") + +_REASON = ( + "Blocked: this command uses --no-verify, which skips the repository's " + "commit-msg / pre-commit / pre-push checks. Do not bypass verification. " + "Fix the underlying issue (formatting, lint, types, tests, commit " + "message) and run the command again without --no-verify. If bypassing is " + "genuinely required, ask the user to authorize it explicitly for this " + "one command." +) + + +def evaluate(payload: dict) -> dict | None: + """Return a deny decision for a --no-verify git command, else ``None``.""" + if payload.get("tool_name") != "Bash": + return None + command = (payload.get("tool_input") or {}).get("command", "") + if not isinstance(command, str): + return None + if _GIT.search(command) and _NO_VERIFY.search(command): + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": _REASON, + } + } + return None + + +def main() -> int: + """Read the tool event from stdin; print a deny decision if warranted.""" + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + return 0 # fail open + if not isinstance(payload, dict): + return 0 + decision = evaluate(payload) + if decision is not None: + print(json.dumps(decision)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/pre-commit-setup.sh b/scripts/pre-commit-setup.sh index 7b72e58..6d9140c 100644 --- a/scripts/pre-commit-setup.sh +++ b/scripts/pre-commit-setup.sh @@ -8,6 +8,7 @@ pip install pre-commit echo "Installing Git hooks..." pre-commit install +pre-commit install --hook-type commit-msg pre-commit install --hook-type pre-push echo "Pre-commit hooks successfully installed!" diff --git a/tests/hooks/__init__.py b/tests/hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/hooks/_load.py b/tests/hooks/_load.py new file mode 100644 index 0000000..fcaab1d --- /dev/null +++ b/tests/hooks/_load.py @@ -0,0 +1,17 @@ +"""Load a script under ``scripts/hooks/`` as an importable module for tests.""" + +import importlib.util +from pathlib import Path +from types import ModuleType + +_HOOKS_DIR = Path(__file__).resolve().parents[2] / "scripts" / "hooks" + + +def load_hook(name: str) -> ModuleType: + """Import ``scripts/hooks/.py`` and return the loaded module.""" + path = _HOOKS_DIR / f"{name}.py" + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module diff --git a/tests/hooks/test_commit_msg_check.py b/tests/hooks/test_commit_msg_check.py new file mode 100644 index 0000000..8b30791 --- /dev/null +++ b/tests/hooks/test_commit_msg_check.py @@ -0,0 +1,44 @@ +import unittest + +from tests.hooks._load import load_hook + +cm = load_hook("commit_msg_check") + + +class SubjectExtractionTests(unittest.TestCase): + def test_skips_blank_and_comment_lines(self): + message = "\n# Please enter the commit message\nfeat: add x\n" + self.assertEqual(cm.subject_of(message), "feat: add x") + + def test_empty_message_yields_empty_subject(self): + self.assertEqual(cm.subject_of("\n\n# only comments\n"), "") + + +class CheckSubjectTests(unittest.TestCase): + def test_accepts_conventional_with_scope(self): + self.assertEqual(cm.check_subject("feat(mind): add retriever"), []) + + def test_accepts_conventional_without_scope(self): + self.assertEqual(cm.check_subject("chore: bump deps"), []) + + def test_rejects_non_conventional_subject(self): + errors = cm.check_subject("added a thing") + self.assertTrue(any("Conventional Commit" in e for e in errors)) + + def test_rejects_cjk_subject(self): + errors = cm.check_subject("feat: 增加功能") + self.assertTrue(any("CJK" in e for e in errors)) + + def test_rejects_unknown_type(self): + self.assertTrue(cm.check_subject("wip(core): halfway")) + + def test_exempts_merge_and_revert(self): + self.assertEqual(cm.check_subject("Merge branch 'main' into feat"), []) + self.assertEqual(cm.check_subject('Revert "feat: x"'), []) + + def test_empty_subject_is_not_flagged(self): + self.assertEqual(cm.check_subject(""), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/hooks/test_no_bypass_guard.py b/tests/hooks/test_no_bypass_guard.py new file mode 100644 index 0000000..0190706 --- /dev/null +++ b/tests/hooks/test_no_bypass_guard.py @@ -0,0 +1,40 @@ +import unittest + +from tests.hooks._load import load_hook + +guard = load_hook("pre_tool_use_no_bypass") + + +class EvaluateTests(unittest.TestCase): + def _command(self, command: str): + return guard.evaluate( + {"tool_name": "Bash", "tool_input": {"command": command}} + ) + + def test_denies_commit_no_verify(self): + decision = self._command("git commit --no-verify -m 'feat: x'") + self.assertIsNotNone(decision) + self.assertEqual( + decision["hookSpecificOutput"]["permissionDecision"], "deny" + ) + + def test_denies_push_no_verify(self): + self.assertIsNotNone(self._command("git push --no-verify origin main")) + + def test_allows_normal_commit(self): + self.assertIsNone(self._command("git commit -m 'feat: x'")) + + def test_allows_no_verify_without_git(self): + self.assertIsNone(self._command("some-linter --no-verify src/")) + + def test_ignores_non_bash_tools(self): + self.assertIsNone( + guard.evaluate({"tool_name": "Edit", "tool_input": {}}) + ) + + def test_ignores_missing_command(self): + self.assertIsNone(guard.evaluate({"tool_name": "Bash"})) + + +if __name__ == "__main__": + unittest.main() From f0225672b0c53eb8e0ff083d115c0fc6a2407969 Mon Sep 17 00:00:00 2001 From: pkuwkl Date: Thu, 23 Jul 2026 17:34:05 +0800 Subject: [PATCH 2/3] fix(harness): parse commands with shlex in the anti-bypass guard The guard matched a naive substring, so any Bash command that merely mentioned the flag (an echo, a grep, a commit message, a JSON test payload on the command line) was denied even though it ran no git invocation. This surfaced live while building #136. Tokenize the command with shlex, split into simple commands on shell operators, strip leading VAR=value and prefix words (env / command / sudo / ...), and deny only when --no-verify is a real argument token of a real git invocation. A flag mentioned inside a quoted string stays in one token and passes. Fails open on unbalanced quotes. Residual gap (shell indirection such as $VAR / eval) is documented, not resolved. Adds tests for the previously-false-positive cases and updates the mechanical -check description in contexts/dev/harness-engineering.md. verify.sh passes (426 passed, 85.66% coverage). Confirmed live: a command echoing the literal is no longer blocked. Co-Authored-By: Claude Opus 4.8 (1M context) --- contexts/dev/harness-engineering.md | 2 +- scripts/hooks/pre_tool_use_no_bypass.py | 87 +++++++++++++++++++++---- tests/hooks/test_no_bypass_guard.py | 24 +++++++ 3 files changed, 98 insertions(+), 15 deletions(-) diff --git a/contexts/dev/harness-engineering.md b/contexts/dev/harness-engineering.md index 80822f6..a1feab6 100644 --- a/contexts/dev/harness-engineering.md +++ b/contexts/dev/harness-engineering.md @@ -78,7 +78,7 @@ Each rule is enforced at the layer that fits it, and hooks are kept to mechanica | English Conventional Commit subject | `scripts/hooks/commit_msg_check.py` | `commit-msg` git hook (tool-agnostic: humans, Claude, Codex) | | No `--no-verify` bypass of the hooks above | `scripts/hooks/pre_tool_use_no_bypass.py` | Agent PreToolUse hook | -The anti-bypass hook is the one guarantee that cannot live in a git hook: `--no-verify` is by definition the flag that turns git hooks off, and CI only notices after the fact. A PreToolUse `deny` stops an agent from skipping the checks at the source. It is a pure mechanical check (a `git` command carrying `--no-verify`) and shared by both agents through one script. Commit-*message* format, by contrast, is a git hook rather than an agent hook, so it also catches a human committing from a terminal. +The anti-bypass hook is the one guarantee that cannot live in a git hook: `--no-verify` is by definition the flag that turns git hooks off, and CI only notices after the fact. A PreToolUse `deny` stops an agent from skipping the checks at the source. It is a mechanical check only: the command is tokenized with `shlex` and split into simple commands, so it denies only when `--no-verify` is a real argument token of a real `git` invocation — a command that merely mentions the flag inside a quoted string (an `echo`, a `grep`, a commit message) keeps it inside one token and passes. The residual gap is shell indirection (`$VAR` / `eval`), which a mechanical guard does not resolve. Commit-*message* format, by contrast, is a git hook rather than an agent hook, so it also catches a human committing from a terminal. Planned next: enforce the commit convention for external contributors at the CI floor (a PR-title lint), since local git hooks only fire for contributors who ran `pre-commit install`. diff --git a/scripts/hooks/pre_tool_use_no_bypass.py b/scripts/hooks/pre_tool_use_no_bypass.py index 54faab9..56f5792 100755 --- a/scripts/hooks/pre_tool_use_no_bypass.py +++ b/scripts/hooks/pre_tool_use_no_bypass.py @@ -4,9 +4,18 @@ Shared by Claude Code (``.claude/settings.json``) and Codex (``.codex/hooks.json``); both invoke this same script and speak the same stdin-JSON / stdout-JSON hook protocol. The guard reads the tool event on -stdin and, for a Bash ``git`` command that carries ``--no-verify`` (which -would skip the ``commit-msg`` / ``pre-commit`` / ``pre-push`` checks), returns -a ``deny`` decision. Everything else passes through untouched. +stdin and, for a Bash command that actually runs ``git ... --no-verify`` +(which would skip the ``commit-msg`` / ``pre-commit`` / ``pre-push`` checks), +returns a ``deny`` decision. Everything else passes through untouched. + +Matching is precise, not a substring scan. The command is tokenized with +``shlex`` and split into simple commands on shell operators, so ``--no-verify`` +only triggers a deny when it is a real argument token of a real ``git`` +invocation. A command that merely *mentions* the flag inside a quoted string +— an ``echo``, a ``grep`` pattern, a commit message, a JSON payload — keeps it +inside a single token and passes. Residual gap: shell indirection such as +``$VAR`` expansion or ``eval`` is not resolved (a deliberately obfuscated +bypass is out of scope for a mechanical guard). Design: mechanical check only, never semantic judgement. It fails open — any parse error yields no decision — so a malformed event can never wedge the @@ -16,30 +25,80 @@ """ import json -import re +import shlex import sys -_GIT = re.compile(r"\bgit\b") -_NO_VERIFY = re.compile(r"(?:^|\s)--no-verify(?:\s|$)") +# Words that may precede the real executable in a simple command. +_PREFIX_WORDS = frozenset( + {"command", "env", "sudo", "nice", "nohup", "time", "builtin", "exec"} +) +# Bare operator tokens shlex(punctuation_chars=True) emits between commands. +_OPERATOR_CHARS = frozenset("();<>|&") + + +def _is_operator(token: str) -> bool: + """Return True if ``token`` is a run of shell operator characters.""" + return bool(token) and all(ch in _OPERATOR_CHARS for ch in token) + + +def _is_assignment(token: str) -> bool: + """Return True if ``token`` is a leading ``VAR=value`` assignment.""" + eq = token.find("=") + return eq > 0 and not token.startswith("-") and token[:eq].isidentifier() + + +def _git_argvs(command: str) -> list[list[str]]: + """Return the argv of every ``git`` simple-command inside ``command``. + + Raises ``ValueError`` on unbalanced quotes so the caller can fail open. + """ + lexer = shlex.shlex(command, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + tokens = list(lexer) + + simple: list[str] = [] + simples: list[list[str]] = [simple] + for token in tokens: + if _is_operator(token): + simple = [] + simples.append(simple) + else: + simple.append(token) + + git_argvs: list[list[str]] = [] + for cmd in simples: + start = 0 + while start < len(cmd) and ( + _is_assignment(cmd[start]) or cmd[start] in _PREFIX_WORDS + ): + start += 1 + if start < len(cmd) and cmd[start] == "git": + git_argvs.append(cmd[start:]) + return git_argvs + _REASON = ( - "Blocked: this command uses --no-verify, which skips the repository's " - "commit-msg / pre-commit / pre-push checks. Do not bypass verification. " - "Fix the underlying issue (formatting, lint, types, tests, commit " - "message) and run the command again without --no-verify. If bypassing is " - "genuinely required, ask the user to authorize it explicitly for this " + "Blocked: this command runs git with --no-verify, which skips the " + "repository's commit-msg / pre-commit / pre-push checks. Do not bypass " + "verification. Fix the underlying issue (formatting, lint, types, tests, " + "commit message) and run the command again without that flag. If bypassing " + "is genuinely required, ask the user to authorize it explicitly for this " "one command." ) def evaluate(payload: dict) -> dict | None: - """Return a deny decision for a --no-verify git command, else ``None``.""" + """Return a deny decision for a real ``git --no-verify`` command, else None.""" if payload.get("tool_name") != "Bash": return None command = (payload.get("tool_input") or {}).get("command", "") - if not isinstance(command, str): + if not isinstance(command, str) or not command: return None - if _GIT.search(command) and _NO_VERIFY.search(command): + try: + git_argvs = _git_argvs(command) + except ValueError: + return None # unbalanced quotes etc — fail open + if any("--no-verify" in argv for argv in git_argvs): return { "hookSpecificOutput": { "hookEventName": "PreToolUse", diff --git a/tests/hooks/test_no_bypass_guard.py b/tests/hooks/test_no_bypass_guard.py index 0190706..e41cc1d 100644 --- a/tests/hooks/test_no_bypass_guard.py +++ b/tests/hooks/test_no_bypass_guard.py @@ -21,12 +21,36 @@ def test_denies_commit_no_verify(self): def test_denies_push_no_verify(self): self.assertIsNotNone(self._command("git push --no-verify origin main")) + def test_denies_with_env_assignment_prefix(self): + self.assertIsNotNone(self._command("FOO=1 git commit --no-verify -m x")) + + def test_denies_across_shell_operator(self): + self.assertIsNotNone(self._command("git commit --no-verify && echo ok")) + def test_allows_normal_commit(self): self.assertIsNone(self._command("git commit -m 'feat: x'")) def test_allows_no_verify_without_git(self): self.assertIsNone(self._command("some-linter --no-verify src/")) + def test_allows_flag_mentioned_in_quoted_echo(self): + # The literal is data inside a single-quoted argument, not a git call. + self.assertIsNone( + self._command("echo 'run git commit --no-verify to skip'") + ) + + def test_allows_flag_inside_commit_message(self): + # The flag text lives inside the quoted -m message, not as an argv flag. + self.assertIsNone( + self._command('git commit -m "docs: explain --no-verify risks"') + ) + + def test_allows_grep_for_the_flag(self): + self.assertIsNone(self._command("grep -- --no-verify .githooks/*")) + + def test_fails_open_on_unbalanced_quotes(self): + self.assertIsNone(self._command("git commit --no-verify -m 'oops")) + def test_ignores_non_bash_tools(self): self.assertIsNone( guard.evaluate({"tool_name": "Edit", "tool_input": {}}) From a2be9979ef7a99c002af941e0775b26b844107e5 Mon Sep 17 00:00:00 2001 From: pkuwkl Date: Thu, 23 Jul 2026 17:45:05 +0800 Subject: [PATCH 3/3] feat(harness): add CONTEXT_MAP and make CLAUDE.md a single-source @AGENTS.md import - contexts/CONTEXT_MAP.md: the navigation index for contexts/ (a directory map plus task-to-page routes). AGENTS.md points here as the first read, and it is registered in contexts/README.md. - AGENTS.md becomes the single source of repository instructions; its intro, entry-point pointer, and skills-section wording are updated accordingly. - CLAUDE.md is reduced to `@AGENTS.md` (plus a human-only HTML comment), so the two guides can no longer drift; Claude Code expands the import at load. - tests/test_contexts.py: a `_guide_text` helper inlines `@path` imports when it checks a guide, so the literal-content assertions still hold for CLAUDE.md. verify.sh passes (426 passed, 85.66% coverage). Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 12 +-- CLAUDE.md | 158 ++-------------------------------------- contexts/CONTEXT_MAP.md | 54 ++++++++++++++ contexts/README.md | 2 +- tests/test_contexts.py | 24 +++++- 5 files changed, 90 insertions(+), 160 deletions(-) create mode 100644 contexts/CONTEXT_MAP.md diff --git a/AGENTS.md b/AGENTS.md index 4174eec..f21ec5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,12 @@ # QuantMind — Agent Instructions -Guidance for coding agents contributing to this repository. Keep this file -aligned with `CLAUDE.md` (same core rules); update both in the same change. +Guidance for coding agents contributing to this repository. This file is the +single source of repository instructions; `CLAUDE.md` imports it verbatim, so +edit rules here, not there. -Use [`contexts/README.md`](contexts/README.md) as the repository information -entry point for either development or library-usage work. +Start at [`contexts/CONTEXT_MAP.md`](contexts/CONTEXT_MAP.md), the navigation +index for `contexts/`. [`contexts/README.md`](contexts/README.md) is the +routing entry point for development or library-usage work. ## Progressive Context Loading @@ -142,7 +144,7 @@ A new feature ships with a unit test **and** a focused example: For commit, pull-request, or component-implementation tasks, load the `quantmind-dev` skill and follow the matching reference: -- `.agents/skills/quantmind-dev/SKILL.md` (this toolchain) +- `.agents/skills/quantmind-dev/SKILL.md` (Codex and other AGENTS.md-based tools) - `.claude/skills/quantmind-dev/SKILL.md` (Claude Code) The two copies are identical; when changing the skill, update both in the diff --git a/CLAUDE.md b/CLAUDE.md index f301747..fa6683c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,150 +1,8 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working in -this repository. Keep this file aligned with `AGENTS.md` (same core rules); -update both in the same change. - -Use [`contexts/README.md`](contexts/README.md) as the repository information -entry point for either development or library-usage work. - -## Progressive Context Loading - -Pages under `contexts/` are agent-facing references designed for progressive -disclosure: - -1. Read lines 1-80 first. The preview contains `Quick Summary` and `Contents` - sections that explain the page's purpose, authority, and scope. -2. Use that preview to decide whether the page applies. Do not preload sibling - pages or follow unrelated links. -3. When a page applies, read the entire page before changing code, contracts, - or repository guidance. The preview routes work; it does not replace the - detailed contract. -4. Follow directly linked canonical sources only as the task requires. Avoid - deep reference chains and duplicate guidance in working context. - -## What This Is - -QuantMind is a knowledge extraction and retrieval library for quantitative -finance, built **on top of** the OpenAI Agents SDK. It is a domain library, -not an agent framework: runtime, tracing, tool scaffolding, and multi-agent -handoff all come from `openai-agents`. - -## Module Map - -| Module | Role | -|--------|------| -| `quantmind/knowledge/` | Pydantic data standard (`FlattenKnowledge` / `TreeKnowledge` / `GraphKnowledge`) — dependency leaf | -| `quantmind/library/` | Local persistence and semantic retrieval for canonical knowledge — depends only on `knowledge` | -| `quantmind/configs/` | Operation cfg + typed input models or unions (`BaseFlowCfg`, `NewsWindow`, `PaperInput`) — depends only on `knowledge` | -| `quantmind/preprocess/` | Deterministic fetch / format / clean / time utilities — depends only on `utils` | -| `quantmind/rag/` | Opinionated LlamaIndex document chunking and retrieval — depends only on `preprocess` | -| `quantmind/flows/` | Apex layer: public library operations (`paper_flow`, `collect_news`, `batch_run`) | -| `quantmind/magic.py` | `resolve_magic_input`: natural language → `(input, cfg)` | -| `quantmind/mind/` | Pure-agentic reasoning layer — memory + agentic (reasoning-based) retrieval where an LLM decides; mechanical retrieval (similarity / BM25) lives in `rag` / `library` | -| `quantmind/utils/` | Logger only — keep it that way | - -The pre-migration agent runtime was removed and archived on the -`archive/agent-runtime-final` branch. Reference it for history; never -resurrect it into master. - -## Setup and Verification - -```bash -uv venv && source .venv/bin/activate -uv pip install -e ".[dev]" -bash scripts/verify.sh # deterministic required verification -``` - -`scripts/verify.sh` runs five fast-fail steps (`ruff format --check`, -`ruff check`, `basedpyright`, `lint-imports`, `pytest --cov`) and must remain -network-free. `.github/workflows/ci.yml` is the required deterministic CI -workflow. Public-network integrations have separate bounded smoke tests; -`.github/workflows/e2e.yml` owns their scheduled, manual, and path-filtered -component jobs. Run each applicable smoke test when changing that component -and before publishing. External service availability must not block changes -outside that component. `docs/README.md` is the single catalog of component -commands; do not enumerate them in this file. - -To extend live verification, add a component-specific -`scripts/verify__e2e.py`, add a named job to the existing `e2e.yml`, -extend its precise PR path filter, and add one catalog row. When a second live -job is added, use GitHub-native per-job change detection so PRs run only the -affected component jobs. Do not add another E2E workflow, a generic runner or -registry, or a base E2E class. Do not bypass pre-commit / pre-push hooks unless -the user explicitly authorizes it — fix the underlying issue instead. - -## Architecture Constraints (stable) - -1. **Library, not framework** — use functions for self-contained stateless - transformations and small service classes that bind, at construction, the - immutable `cfg`/policy/dependency that must stay constant across calls; the - runtime operand is passed per call. Binding `cfg` for batch reproducibility - alone justifies a class (`PaperFlow(cfg).build(input)`, - `AgenticRetriever(cfg).retrieve(structure, q)`), and the cfg *type* may select the - shape/strategy (typed dispatch, not a class hierarchy). Keep canonical values - free of runtime service state; use `Protocol` over ABC, with no - framework-style class hierarchies, plugin registries, hook discovery, or CLI. -2. **RAG data plane, not framework** — use LlamaIndex directly inside - `quantmind.rag`; keep upstream types private and do not add retriever, - vector-store, provider, or backend registries. -3. **Do not rebuild the agent runtime** — use `openai-agents` directly; no - QuantMind-side facades over `from agents import ...`. -4. **Schema models vs runtime evidence** — user/LLM inputs and configs use - extra-forbid Pydantic models; knowledge adds `frozen=True`; deterministic - fetch, preprocessing, and collection values use frozen dataclasses when - they do not need validation or JSON Schema (`Fetched`, `NewsBatch`). -5. **Import boundaries are contracts** — `import-linter` (configured in - `pyproject.toml`) pins the dependency graph; never work around a failing - contract. -6. **Absolute imports** across module boundaries. -7. **No meaningless wrappers** — a method must add logic, abstraction, or a - side effect beyond the call it wraps; otherwise inline it. -8. **Name public operations by intent** — follow - `contexts/design/operations/naming.md`; use stage verbs, and reserve - `pipeline` for deliberate multi-stage composition. `flow` as a verb or - `*_flow` function name is banned; `Flow` as a noun on a document handle - (`PaperFlow`) is allowed. -9. **Pipelines produce self-contained artifacts** — a `flows` pipeline is pure - processing (`input → artifact`) and returns a value usable *and storable* - without a store; it does not bind a `library`, persist, or retrieve. - `library` only dumps and loads (`put(artifact)` / `open_*`, round-tripping to - an identical value); `mind` only retrieves, returning evidence **values** - (content included) with any locator as optional provenance. A self-contained - artifact carries its own text (and any embeddings) plus the minimal - provenance metadata (`as_of` + a light source ref) needed to persist and - time-query it standalone — never a reference refilled from a store. Keep that - provenance metadata out of the artifact's `id` / `content_hash` (identity - stays reproducible); share it via a light provenance base, not full - `BaseKnowledge`. Accept modest redundancy to keep artifacts self-contained. - Half-finished intermediates stay component seams, not public flows. See - `contexts/design/operations/orchestration.md`. - -## Tests and Examples - -A new feature ships with a unit test **and** a focused example: - -- Tests: `tests//`, subclass `unittest.TestCase`, mock external - services, cover success and failure paths. -- Examples: `examples//`, one simple usage per file. -- Public operations and sources: update the catalog in `docs/README.md` and - follow the `quantmind-dev` component checklist. - -## Communication - -- Commit messages: English, Conventional Commits. -- PR titles, PR bodies, and issue bodies: English. -- GitHub Issue, Pull Request, Discussion, and comment body formatting follows - the [GitHub writing style](contexts/dev/github-writing.md); never hard-wrap - remote prose at 80 columns or another fixed width. -- Code comments and docstrings: English, Google style. - -## Development Workflows - -For commit, pull-request, or component-implementation tasks, load the -`quantmind-dev` skill and follow the matching reference: - -- `.claude/skills/quantmind-dev/SKILL.md` (Claude Code) -- `.agents/skills/quantmind-dev/SKILL.md` (other agent toolchains) - -The two copies are identical; when changing the skill, update both in the -same change. + + +@AGENTS.md diff --git a/contexts/CONTEXT_MAP.md b/contexts/CONTEXT_MAP.md new file mode 100644 index 0000000..37c48d6 --- /dev/null +++ b/contexts/CONTEXT_MAP.md @@ -0,0 +1,54 @@ +# Context Map + +## Quick Summary + +- **Purpose**: The navigation index for `contexts/`. Before reading or writing anything under `contexts/`, start here to find the one page a task needs. +- **Read when**: Beginning any repository, design, or library-usage task, or adding a new `contexts/` page (register it in the map below and in its area index). +- **Load next**: Pick the single route in [Where to Start](#where-to-start); then open only that page and follow its `Load next` line. +- **Status**: Current. `AGENTS.md` (imported by `CLAUDE.md`) points here as the first read for `contexts/` work. + +## Contents + +- [Directory Map](#directory-map) +- [Where to Start](#where-to-start) + +## Directory Map + +``` +contexts/ +├── CONTEXT_MAP.md ← you are here: the navigation index +├── README.md ← routing entry point (dev / usage / design) +├── design/ ← accepted design decisions and planned behavior +│ ├── README.md ← design index +│ ├── flow/ +│ │ ├── paper.md ← source-first Paper Flow V1 +│ │ └── news.md ← news collection design and behavior +│ ├── knowledge/paper.md ← Paper source and artifact models +│ ├── library/local.md ← LocalKnowledgeLibrary storage and retrieval +│ ├── mind/retrieval.md ← page-preserving structure tree + agentic retrieval +│ ├── operations/ +│ │ ├── naming.md ← public operation naming rules +│ │ └── orchestration.md ← pipelines vs components (altitude) +│ ├── preprocess/pdf.md ← page-aware ParsedDocument +│ ├── rag/document.md ← deterministic chunking + BM25 +│ └── utils/ +│ ├── structured_output.md ← structured-output contract +│ └── usage.md ← per-run token / time / step usage +├── dev/ ← contributor and agent development rules +│ ├── README.md ← dev rule index +│ ├── labels.md ← issue / PR label taxonomy +│ ├── github-writing.md ← GitHub prose style (no hard-wrap) +│ └── harness-engineering.md ← enforcement layers, hooks, rules, alignment +└── usage/ + └── README.md ← using QuantMind as a library +``` + +## Where to Start + +| I want to | Open | +|---|---| +| Understand the whole `contexts/` layout | the [Directory Map](#directory-map) above | +| Develop, fix, test, or review code | [`dev/README.md`](dev/README.md) | +| Use QuantMind as a library | [`usage/README.md`](usage/README.md) | +| Read or change a design decision | [`design/README.md`](design/README.md) | +| Add a hook, rule, or CI gate | [`dev/harness-engineering.md`](dev/harness-engineering.md) | diff --git a/contexts/README.md b/contexts/README.md index 7a0ca2d..39db7cb 100644 --- a/contexts/README.md +++ b/contexts/README.md @@ -14,7 +14,7 @@ ## Routes -This directory is the public repository context system for coding agents and maintainers. Start with the index that matches the work you are doing: +This directory is the public repository context system for coding agents and maintainers. For a full page-by-page navigation index, see [`CONTEXT_MAP.md`](CONTEXT_MAP.md). Start with the index that matches the work you are doing: - [Develop QuantMind](dev/README.md) for architecture, contribution, testing, and verification guidance. - [Use QuantMind](usage/README.md) for public operations, examples, and usage documentation. diff --git a/tests/test_contexts.py b/tests/test_contexts.py index 0a2dbf0..4da59b4 100644 --- a/tests/test_contexts.py +++ b/tests/test_contexts.py @@ -3,6 +3,22 @@ from pathlib import Path +def _guide_text(repo_root: Path, rel_path: str) -> str: + """Read a guide file, inlining any ``@path`` import lines. + + Claude Code's memory-import syntax lets ``CLAUDE.md`` delegate to + ``AGENTS.md`` with a single ``@AGENTS.md`` line. Expanding it here checks a + guide by its effective content, so the single-source layout still satisfies + the literal-content assertions below. + """ + text = (repo_root / rel_path).read_text(encoding="utf-8") + + def _expand(match: "re.Match[str]") -> str: + return (repo_root / match.group(1)).read_text(encoding="utf-8") + + return re.sub(r"(?m)^@([^\s`]+)\s*$", _expand, text) + + class TestContextEntryPoints(unittest.TestCase): def test_context_pages_support_progressive_disclosure(self) -> None: repo_root = Path(__file__).resolve().parents[1] @@ -37,7 +53,7 @@ def test_agent_guides_define_progressive_context_loading(self) -> None: repo_root = Path(__file__).resolve().parents[1] for guide_path in ("AGENTS.md", "CLAUDE.md"): - guide = (repo_root / guide_path).read_text(encoding="utf-8") + guide = _guide_text(repo_root, guide_path) with self.subTest(guide=guide_path): self.assertIn("## Progressive Context Loading", guide) self.assertIn("Read lines 1-80 first.", guide) @@ -97,7 +113,7 @@ def test_required_files_link_to_context_entry_point(self) -> None: with self.subTest(source=source_path): self.assertIn( f"]({target})", - source.read_text(encoding="utf-8"), + _guide_text(repo_root, source_path), f"{source_path} must link to contexts/README.md", ) self.assertTrue( @@ -122,7 +138,7 @@ def test_label_guide_has_required_routes(self) -> None: with self.subTest(source=source_path): self.assertIn( f"]({target})", - source.read_text(encoding="utf-8"), + _guide_text(repo_root, source_path), f"{source_path} must route to the canonical label guide", ) self.assertTrue( @@ -194,7 +210,7 @@ def test_github_writing_guide_has_required_routes(self) -> None: with self.subTest(source=source_path): self.assertIn( f"]({target})", - source.read_text(encoding="utf-8"), + _guide_text(repo_root, source_path), f"{source_path} must route to the GitHub writing guide", ) self.assertTrue(