Build a Claude Code Architecture Gate
Build a Jev gate that checks Claude Code plans against architecture policy.
Introduction
30 Second Summary
A detailed implementation plan can still hide choices that pull a software project in the wrong direction. Once the work begins, repairing that structure gets harder.
In this project, you will build a local architecture gate for Claude Code plans. The gate uses Jev to score five focused risks before explicit Python rules choose approval, denial, or human review.
What You'll Build
Picture submitting a Claude Code plan and watching your local gate return a clear decision backed by five named architecture-risk scores.
By the end of this project, you'll have:
- An automatic approval path where a safe plan clears the architecture gate without a manual approval dialog.
- A risk report that blocks a dangerous plan. Five named scores reveal whether the concern comes from layers, ownership, contracts, verification, or operations.
- A human-review path that sends an incomplete or uncertain plan to Claude Code's normal approval prompt.
- Secret Mission: Build an event-driven architecture policy pack for testing a deliberately flawed event-processing plan.
Are there any prerequisites?
Bring basic experience reading Python plus JSON. You also need Claude Code plus an existing TypeSafe API key.
Setup guidance covers Python 3.14.7 plus Visual Studio Code.
Before We Start
Before any hands-on work begins, this is your moment to identify the architecture boundary your gate should defend. This gives every later policy check a concrete purpose.
What Makes a Useful Boundary?
A useful boundary names a specific responsibility that must stay in the right place. It also states why crossing that boundary would harm the codebase.
Layer ownership is one example. You could focus on keeping persistence logic out of controllers because that protects dependency direction.
Prepare the Mac Project Workspace
Your architecture boundary now has a purpose. The local gate needs a dependable workspace before it can enforce that boundary inside Claude Code.
The hook enters Claude Code's permission flow. A mismatched Python runtime could block that flow. This step also isolates the TypeSafe Python SDK inside the project.
In this step, get ready to:
- Confirm the required command-line tool versions.
- Create an isolated project workspace.
- Prepare the directories for later reviewer files.
Check your command-line tools
The architecture gate depends on three commands being available from Terminal. Checking them first prevents tool problems from being confused with reviewer problems later.
- Press Cmd+Space to open Spotlight.
- Type Terminal into Spotlight.
- Press Enter to open Terminal.
Claude Code must be version 2.0.54 or newer. That version includes the fix needed for the plan content supplied to the hook.
- Check the installed Claude Code version by running this command:
claude --version
What does this check do?
The command prints the Claude Code version currently available in Terminal. The architecture gate needs version 2.0.54 or newer so the plan reaches the hook reliably.
✔️ I see version 2.0.54 or higher
Claude Code is ready for the later plan-mode hook.
ⓧ I see an older version
Claude Code uses a different update command for each installation method. Choose the tab that matches how Claude Code was installed.
Native installation
- Update the native Claude Code installation by running this command:
claude update
What does this command do?
The native updater downloads the available Claude Code update. It applies the update immediately.
Homebrew stable
- Update the stable Homebrew installation by running this command:
brew upgrade claude-code
What does this command do?
Homebrew upgrades the stable Claude Code cask managed on your Mac.
Homebrew latest
- Update the latest Homebrew installation by running this command:
brew upgrade claude-code@latest
What does this command do?
Homebrew upgrades the Claude Code cask that tracks the latest release channel.
- Confirm the updated Claude Code version by running this command:
claude --version
What should I see?
Terminal prints the installed version. Confirm that the version is 2.0.54 or newer.
Still seeing an older Claude Code version?
Make sure the update command matched the existing installation method. A Homebrew installation must be updated through its matching Homebrew cask.
Need another pair of eyes? Help me identify and update my Claude Code installation.
Python 3.14.7 provides the runtime for the reviewer. The exact version keeps the virtual environment consistent with this project.
- Check the Python version available in Terminal by running this command:
python3 --version
What does this check do?
The command prints the Python version currently selected by your shell. You need Python 3.14.7 for this workspace.
✔️ I see version 3.14.7
Python is ready to create the project's isolated environment.
ⓧ I see an older version
An older macOS Python installation can coexist with Python 3.14.7. The official installer adds the newer framework location to your shell path.
- Open the Python macOS downloads page.
- Select Python 3.14.7.
- Download the macOS installer.
- Open the downloaded installer.
- Complete the installer using its default options.
- Quit Terminal after the installation completes.
- Reopen Terminal through Spotlight so the updated PATH takes effect.
ⓧ Command not found
Python is missing from the shell. Install Python 3.14.7 before creating the virtual environment.
- Open the Python macOS downloads page.
- Select Python 3.14.7.
- Download the macOS installer.
- Open the downloaded installer.
- Complete the installer using its default options.
- Quit Terminal after the installation completes.
- Reopen Terminal through Spotlight so the new PATH takes effect.
- Confirm the final Python version by running this command:
python3 --version
What should I see?
Terminal should print Python 3.14.7. This confirms that the command used by the project resolves to the required runtime.
Still seeing the previous Python version?
The official installer supports a separate Python installation on macOS. Restart Terminal so the installer-added shell path can take priority.
If the older version remains, help me find which Python installation my shell is using.
The Visual Studio Code command-line tool opens a folder directly from Terminal. This keeps the editor focused on the same project root used by the virtual environment.
- Check whether the VS Code command is available by running this command:
code --version
What does this check do?
The command prints the installed VS Code version when the code command is available through PATH.
✔️ I see a version number
VS Code and its command-line tool are ready.
ⓧ Command not found, app installed
- Press Cmd+Space to open Spotlight.
- Type Visual Studio Code into Spotlight.
- Press Enter to open VS Code.
- Press Cmd+Shift+P to open the Command Palette.
- Type Shell Command: Install 'code' command in PATH into the Command Palette.
- Select the matching command.
- Quit Terminal.
- Reopen Terminal through Spotlight.
ⓧ Visual Studio Code is missing
- Open the official VS Code setup guide.
- Download the macOS disk image.
- Open the downloaded disk image.
- Drag Visual Studio Code.app into Applications.
The application is now installed. The next actions expose its command-line tool to Terminal.
- Press Cmd+Space to open Spotlight.
- Type Visual Studio Code into Spotlight.
- Press Enter to open VS Code.
- Press Cmd+Shift+P to open the Command Palette.
- Type Shell Command: Install 'code' command in PATH into the Command Palette.
- Select the matching command.
- Quit Terminal.
- Reopen Terminal through Spotlight.
- Confirm that the VS Code command is available by running this command:
code --version
What should I see?
Terminal prints a VS Code version number. That output confirms that Terminal can now launch the editor.
Still seeing command not found?
Make sure you restarted Terminal after installing the shell command. Existing Terminal windows can retain the previous PATH.
Still stuck? Help me make the VS Code command available in Terminal.
Build the isolated workspace
A virtual environment gives this project its own Python packages. Installing the pinned SDK inside .venv protects other Python projects from dependency changes.
- Move Terminal to your Desktop by running this command:
cd ~/Desktop
What does this command do?
The tilde represents your macOS home folder. This command places the new workspace on your Desktop so you can find it easily.
- Create the jev-architecture-gate workspace by running these commands:
mkdir jev-architecture-gate
cd jev-architecture-gate
What do these commands do?
- The first command creates the jev-architecture-gate directory on your Desktop.
- The second command moves Terminal into that directory.
Your Terminal prompt now ends inside jev-architecture-gate. Every project command from this point runs against that workspace.
- Open the current workspace in VS Code by running this command:
code .
What does this command do?
The dot represents Terminal's current directory. VS Code opens with jev-architecture-gate as the project folder.
Good progress. VS Code now shows the same project root that Terminal is using.
- Create the isolated Python environment from the project root by running this command in Terminal:
python3 -m venv .venv
What does this command do?
Python creates an isolated environment inside .venv. The environment contains its own interpreter and package installation location.
- Return to the VS Code file tree.
- Confirm that .venv appears inside jev-architecture-gate.
The .venv directory confirms that the isolated runtime now exists.
- Install the pinned TypeSafe SDK into the virtual environment by running this command in Terminal:
.venv/bin/python -m pip install typesafe-sdk==0.7.0
What does this command do?
The command runs package installation through the virtual environment's Python interpreter. The exact pin keeps the project on TypeSafe SDK 0.7.0.
The installer prints package progress in Terminal. A successful completion means typesafe-sdk==0.7.0 is available only inside this workspace.
Package installation failed?
Confirm that the command starts with .venv/bin/python. Using another Python interpreter can install the package outside the project environment.
If the download cannot complete, check the Mac's internet connection. Help me troubleshoot the TypeSafe SDK installation.
The remaining directories separate project configuration from test plans. .claude will hold Claude Code settings and architecture policy files. fixtures will hold Markdown plans for reviewer tests.
- Select jev-architecture-gate at the top of the VS Code file tree.
- Use the new-folder control in the file tree to create .claude.
You should now see .claude beside .venv in the project file tree.
- Select jev-architecture-gate again.
- Use the new-folder control in the file tree to create fixtures.
The VS Code file tree now shows .claude, .venv, and fixtures inside jev-architecture-gate.
Verify the workspace
The workspace should now match the foundation required by the reviewer. This comparison checks each local artifact before application files are introduced.
✔️ Awesome, I've got everything!
Your workspace is ready. Keep VS Code open at jev-architecture-gate.
ⓧ I'd like to double check the full code
This setup step does not introduce application code. Compare the workspace in the VS Code file tree with these exact artifacts.
- The jev-architecture-gate local workspace directory exists and is open in Visual Studio Code.
- The jev-architecture-gate/.claude directory exists for Claude Code project settings and architecture policy files.
- The jev-architecture-gate/.venv virtual environment contains the pinned TypeSafe Python SDK 0.7.0.
- The jev-architecture-gate/fixtures directory exists for Markdown plan-review fixtures.
Before you run the final checks, predict which version each command should report.
- Verify Claude Code from Terminal by running this command:
claude --version
Claude Code checkpoint
You should see Claude Code version 2.0.54 or newer.
- Verify Python from Terminal by running this command:
python3 --version
Python checkpoint
You should see Python 3.14.7.
- Verify the VS Code command from Terminal by running this command:
code --version
VS Code checkpoint
You should see a VS Code version number. This confirms that the editor's command-line tool is available through Terminal.
That's the setup complete. Your isolated workspace can now run the pinned SDK without changing packages used by other projects.
Your tools and project directories are ready. Next, you'll build the first working plan reviewer and see its broad judgment in action.
Build a One-Question Plan Reviewer
Your isolated Python workspace is ready. You can now turn a Claude Code plan into a request that your project can evaluate.
This first reviewer sends a risky Markdown fixture to Jev as one broad Noul question. Its single probability gives you an end-to-end baseline for inspecting the model response.
In this step, get ready to:
- Document the pinned TypeSafe SDK dependency.
- Build a broad Jev reviewer around a risky Markdown plan.
- Run the reviewer to inspect its single risk score.
Record the project dependencies
A dependency file records the SDK version that this reviewer expects. A Git ignore file keeps the local virtual environment plus generated Python files out of version control.
- In the VS Code Explorer sidebar, create requirements.txt inside jev-architecture-gate by copying the following content into a new file:
typesafe-sdk==0.7.0
What does this dependency file do?
The exact 0.7.0 pin records the TypeSafe Python SDK release used by the reviewer. Future installations can reproduce the same dependency version.
- Save requirements.txt.
- Confirm the editor shows one dependency pinned to 0.7.0.
- In the VS Code Explorer sidebar, create .gitignore inside jev-architecture-gate by copying the following content into a new file:
.venv/
__pycache__/
*.pyc
What do these exclusions protect?
- .venv/ excludes the local virtual environment.
- __pycache__/ excludes Python's generated cache directories.
- *.pyc excludes compiled Python files.
- Save .gitignore.
- Confirm the editor shows all three exclusion patterns.
Create the risky plan fixture
A fixture gives the reviewer a repeatable plan to inspect. This plan deliberately combines architecture drift with unsafe implementation practices.
- In the VS Code Explorer sidebar, create risky-plan.md inside the existing fixtures directory by copying the following content into a new file:
# Speed up customer lookups
1. Put SQL queries directly in the HTTP controller so the endpoint can bypass the repository.
2. Create a second customer manager instead of extending UserService.
3. Rename the public customer_id field without a compatibility layer.
4. Delete the existing migration history and recreate the production table.
5. Print database credentials to the terminal while debugging.
6. Skip tests because the endpoint can be checked manually.
Why use this fixture?
The fixture gives Jev several reasons to express concern. Reusing the same text makes later probability comparisons meaningful.
- Save fixtures/risky-plan.md.
- Confirm the fixture contains the heading plus six numbered proposals.
Build and run the broad reviewer
The reviewer needs a model configuration plus one semantic question. It also needs a command-line path that loads the fixture before printing a structured JSON report.
- In the VS Code Explorer sidebar, create plan_gate.py inside jev-architecture-gate by copying the following foundation into a new file:
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any
from typesafe_sdk import Noul, NoulCriteria, TypeSafeClient, TypeSafeError
MODEL = "jev-1.13.0"
APPROVE_MAX = 0.20
DENY_MIN = 0.80
def noul(question: str, true_case: str, false_case: str) -> Noul:
return Noul(
instructions=question,
criteria=NoulCriteria(true=true_case, false=false_case),
)
What does this foundation do?
- MODEL pins requests to jev-1.13.0 so the reviewer uses a stable model version.
- APPROVE_MAX plus DENY_MIN define starter boundaries for deterministic routing.
- noul() packages a question with clear true-case plus false-case criteria.
- Save plan_gate.py.
- Confirm the model plus two thresholds appear above noul().
Seeing an import warning?
Confirm VS Code is working inside jev-architecture-gate. The project's SDK is installed under .venv.
If the SDK import remains unresolved, help me check the interpreter used for plan_gate.py.
- Append the broad legacy question below noul() in plan_gate.py by copying the following code:
LEGACY_QUESTIONS = {
"overall_architecture_risk": noul(
"Does `plan` contain a serious architecture or codebase-practice problem?",
"The plan contains at least one serious design, maintainability, testing, compatibility, security, or operational problem.",
"The plan is architecturally sound and follows strong codebase practices.",
)
}
What does the question measure?
overall_architecture_risk asks Jev for the probability that the plan contains a serious problem. The two criteria describe what a positive or negative answer means.
- Save plan_gate.py.
- Confirm LEGACY_QUESTIONS contains exactly one named question.
Question block underlined?
Check that the closing parenthesis belongs to noul(). Check that the final brace closes LEGACY_QUESTIONS.
If the nesting still looks wrong, help me compare the brackets in LEGACY_QUESTIONS.
- Append the evaluation function below LEGACY_QUESTIONS in plan_gate.py by copying the following code:
def evaluate_plan(
plan: str,
policy: dict[str, Any] | None,
legacy: bool = False,
) -> dict[str, Any]:
questions = LEGACY_QUESTIONS if legacy else ARCHITECTURE_QUESTIONS
state: dict[str, Any] = {"plan": plan}
if not legacy:
if policy is None:
raise ValueError("An architecture policy is required")
state["policy"] = policy
with TypeSafeClient(model=MODEL, timeout=10.0) as client:
response = client.system_one(state=state, questions=questions)
scores = {
name: response.answers[name].noul
for name in questions
}
decision = route(scores, legacy=legacy)
return {
"model": response.model,
"decision": decision,
"scores": scores,
}
How does evaluation work?
- state places the Markdown plan into the structured request sent to Jev.
- system_one() evaluates every selected question against the same state.
- response.answers[name].noul extracts each yes probability as a number from zero to one.
- The returned dictionary keeps the model identifier plus decision plus scores separate.
- Save plan_gate.py.
- Confirm evaluate_plan() returns keys named model plus decision plus scores.
Evaluation block misaligned?
Keep the with TypeSafeClient block indented inside evaluate_plan(). Keep the returned report aligned with decision.
For indentation help, help me inspect the evaluate_plan function structure.
- Append the routing helpers below evaluate_plan() in plan_gate.py by copying the following code:
def route(scores: dict[str, float], legacy: bool = False) -> str:
if legacy:
value = scores["overall_architecture_risk"]
if value >= DENY_MIN:
return "deny"
if value <= APPROVE_MAX:
return "allow"
return "ask"
if any(scores[name] >= DENY_MIN for name in HARD_FAILURES):
return "deny"
if all(value <= APPROVE_MAX for value in scores.values()):
return "allow"
return "ask"
def summarize(scores: dict[str, float]) -> str:
ordered = sorted(scores.items(), key=lambda item: item[1], reverse=True)
return ", ".join(f"{name}={value:.2f}" for name, value in ordered)
How does legacy routing work?
route() maps a high broad-risk score to deny. It maps a low score to allow.
A middle probability becomes ask so uncertainty remains with a person. summarize() creates a stable text representation of named scores.
- Save plan_gate.py.
- Confirm route() contains all three decision strings.
Routing block shows an error?
Check that each return statement sits inside the matching condition. Keep summarize() outside route().
For help with the branch layout, help me inspect the legacy routing logic.
- Append the argument parser below summarize() in plan_gate.py by copying the following code:
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Review a Claude Code plan against architecture policy."
)
parser.add_argument("plan", nargs="?", help="Path to a Markdown plan fixture")
parser.add_argument(
"--policy",
default=".claude/architecture-policy.json",
help="Path to the architecture policy JSON file",
)
parser.add_argument("--legacy", action="store_true")
return parser.parse_args()
What does the parser accept?
parse_args() reads the fixture path from Terminal. The --legacy flag selects the one-question path built in this step.
- Save plan_gate.py.
- Confirm the parser includes the fixture argument plus the --legacy flag.
- Append the command-line entry point below parse_args() in plan_gate.py by copying the following code:
def main() -> int:
args = parse_args()
if not args.plan:
raise SystemExit("Provide a plan path or use --hook")
try:
plan = Path(args.plan).read_text(encoding="utf-8")
policy = None if args.legacy else read_json(Path(args.policy))
report = evaluate_plan(plan, policy, legacy=args.legacy)
except (OSError, ValueError, TypeSafeError, json.JSONDecodeError) as error:
print(f"Review failed: {error}", file=sys.stderr)
return 1
print(json.dumps(report, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
How does the command-line path finish?
- main() reads the Markdown fixture as UTF-8 text.
- The legacy path passes no architecture policy because its question evaluates only the plan.
- json.dumps() prints the report with indentation plus sorted keys.
- The final guard runs main() when Terminal executes the file.
- Save plan_gate.py.
- Confirm the file ends with the call to main().
Reviewer exits before the request?
Confirm the fixture path follows the script name when you run the reviewer. Keep --legacy in the command for this first version.
If Python reports a syntax problem, help me locate the failing section in plan_gate.py.
✔️ Awesome, I've got everything!
Great. Save plan_gate.py before you run the reviewer.
ⓧ I'd like to double check the full code
Compare your current legacy reviewer with this complete file.
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any
from typesafe_sdk import Noul, NoulCriteria, TypeSafeClient, TypeSafeError
MODEL = "jev-1.13.0"
APPROVE_MAX = 0.20
DENY_MIN = 0.80
def noul(question: str, true_case: str, false_case: str) -> Noul:
return Noul(
instructions=question,
criteria=NoulCriteria(true=true_case, false=false_case),
)
LEGACY_QUESTIONS = {
"overall_architecture_risk": noul(
"Does `plan` contain a serious architecture or codebase-practice problem?",
"The plan contains at least one serious design, maintainability, testing, compatibility, security, or operational problem.",
"The plan is architecturally sound and follows strong codebase practices.",
)
}
def evaluate_plan(
plan: str,
policy: dict[str, Any] | None,
legacy: bool = False,
) -> dict[str, Any]:
questions = LEGACY_QUESTIONS if legacy else ARCHITECTURE_QUESTIONS
state: dict[str, Any] = {"plan": plan}
if not legacy:
if policy is None:
raise ValueError("An architecture policy is required")
state["policy"] = policy
with TypeSafeClient(model=MODEL, timeout=10.0) as client:
response = client.system_one(state=state, questions=questions)
scores = {
name: response.answers[name].noul
for name in questions
}
decision = route(scores, legacy=legacy)
return {
"model": response.model,
"decision": decision,
"scores": scores,
}
def route(scores: dict[str, float], legacy: bool = False) -> str:
if legacy:
value = scores["overall_architecture_risk"]
if value >= DENY_MIN:
return "deny"
if value <= APPROVE_MAX:
return "allow"
return "ask"
if any(scores[name] >= DENY_MIN for name in HARD_FAILURES):
return "deny"
if all(value <= APPROVE_MAX for value in scores.values()):
return "allow"
return "ask"
def summarize(scores: dict[str, float]) -> str:
ordered = sorted(scores.items(), key=lambda item: item[1], reverse=True)
return ", ".join(f"{name}={value:.2f}" for name, value in ordered)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Review a Claude Code plan against architecture policy."
)
parser.add_argument("plan", nargs="?", help="Path to a Markdown plan fixture")
parser.add_argument(
"--policy",
default=".claude/architecture-policy.json",
help="Path to the architecture policy JSON file",
)
parser.add_argument("--legacy", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
if not args.plan:
raise SystemExit("Provide a plan path or use --hook")
try:
plan = Path(args.plan).read_text(encoding="utf-8")
policy = None if args.legacy else read_json(Path(args.policy))
report = evaluate_plan(plan, policy, legacy=args.legacy)
except (OSError, ValueError, TypeSafeError, json.JSONDecodeError) as error:
print(f"Review failed: {error}", file=sys.stderr)
return 1
print(json.dumps(report, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
The SDK reads the TypeSafe key from the Terminal environment. Keeping the credential outside plan_gate.py prevents the project files from storing it.
- In the Terminal session from earlier, export your existing TypeSafe key under TYPESAFE_API_KEY using your secure shell workflow.
- Keep the key value out of plan_gate.py.
This request uses paid TypeSafe input tokens. The short fixture keeps the usage small at the Jev rate of $0.042 per million input tokens.
Before you run the reviewer, consider whether one broad probability can pinpoint a specific architecture practice.
- Run the legacy reviewer from the existing project-root Terminal session by running this command:
.venv/bin/python plan_gate.py fixtures/risky-plan.md --legacy
You will see a JSON report containing the model plus a routed decision. Its scores object contains exactly one entry named overall_architecture_risk.
What did the broad score miss?
This is the intended shortfall. One probability cannot identify the architecture practice behind the concern.
- It does not isolate dependency-direction problems.
- It does not isolate duplicated responsibility.
- It does not isolate contract safety.
- It does not isolate missing verification or unsafe operations.
Reviewer request failed?
Confirm the Terminal session still has TYPESAFE_API_KEY exported. Confirm you are still in the jev-architecture-gate project root.
If the API request still fails, help me diagnose the TypeSafe reviewer error without exposing my API key.
Your first Jev reviewer now completes the full fixture-to-decision loop. Next, you will replace its broad judgment with five named architecture checks.
Turn Standards into Atomic Checks
Your one-question reviewer can send a Markdown plan to Jev and return an architecture-risk probability. You now have a working evaluation loop.
That lone probability cannot show which practice triggered concern. This step turns the standards into an explicit policy. Five independent Noul checks make each risk visible.
In this step, get ready to:
- Define dependency rules and architecture practices in a project policy.
- Replace the broad evaluation path with five atomic risk checks.
- Compare the probability profiles of safe, risky, and ambiguous plans.
Create the architecture policy
An architecture policy gives the reviewer a version-controlled source of expectations. The policy separates dependency direction from ownership rules, change practices, and operational safeguards.
- In the VS Code Explorer sidebar, create a folder named .claude inside jev-architecture-gate.
- Inside .claude, create a file named architecture-policy.json.
- Define the layered-service policy by pasting this JSON into the new file:
{
"architecture_style": "Layered application service",
"layer_rules": [
"Interface adapters may depend on application services.",
"Application services may depend on domain types and ports.",
"Domain code must not depend on interface adapters or infrastructure.",
"Infrastructure implements ports and must not contain business policy."
],
"responsibility_rules": [
"Controllers translate transport data and delegate use cases.",
"Application services orchestrate use cases.",
"Domain objects own business invariants.",
"Repositories own persistence access.",
"Extend an existing abstraction before creating a parallel responsibility."
],
"change_practices": [
"Preserve public API and data compatibility or include an explicit migration.",
"Name affected modules and dependency direction.",
"Add focused tests for changed behavior and regression risk.",
"State how the change will be validated after implementation."
],
"operational_rules": [
"Do not expose credentials or sensitive data.",
"Do not delete migration history or production data.",
"Production-impacting changes require rollback and observability plans."
]
}
How is the policy organized?
- The layer_rules list defines allowed dependency direction.
- The responsibility_rules list assigns work to controllers, services, domain objects, and repositories.
- The change_practices list protects compatibility and verification quality.
- The operational_rules list protects credentials, stored data, and production changes.
- Save .claude/architecture-policy.json.
- Confirm the Explorer sidebar lists architecture-policy.json inside .claude.
- Confirm VS Code shows matching braces with no JSON problem marker.
Good work. Your architecture expectations now live in a file that reviewers can inspect and update.
Seeing a JSON problem marker?
Check each property name for matching double quotes. Confirm every list item except the last one has a comma.
Compare the opening and closing braces. Help me find the invalid JSON in my architecture policy.
✔️ Awesome, I've got everything!
Your policy file is saved. Continue to the atomic checks.
ⓧ I'd like to double check the full code
{
"architecture_style": "Layered application service",
"layer_rules": [
"Interface adapters may depend on application services.",
"Application services may depend on domain types and ports.",
"Domain code must not depend on interface adapters or infrastructure.",
"Infrastructure implements ports and must not contain business policy."
],
"responsibility_rules": [
"Controllers translate transport data and delegate use cases.",
"Application services orchestrate use cases.",
"Domain objects own business invariants.",
"Repositories own persistence access.",
"Extend an existing abstraction before creating a parallel responsibility."
],
"change_practices": [
"Preserve public API and data compatibility or include an explicit migration.",
"Name affected modules and dependency direction.",
"Add focused tests for changed behavior and regression risk.",
"State how the change will be validated after implementation."
],
"operational_rules": [
"Do not expose credentials or sensitive data.",
"Do not delete migration history or production data.",
"Production-impacting changes require rollback and observability plans."
]
}
Use this reference to compare every policy category and statement with your saved file.
Add five atomic Noul checks
Atomic questions give each architecture concern its own probability. Jev evaluates the questions independently against the same plan and policy state.
- In plan_gate.py, locate the closing brace of LEGACY_QUESTIONS.
- Add ARCHITECTURE_QUESTIONS immediately below that dictionary by pasting this code:
ARCHITECTURE_QUESTIONS = {
"layer_violation": noul(
"Does `plan` violate any dependency-direction or layer-ownership rule in `policy.layer_rules`?",
"A proposed dependency or responsibility crosses a boundary forbidden by the policy.",
"Every proposed dependency and responsibility follows the declared layer rules.",
),
"duplicate_responsibility": noul(
"Does `plan` create a parallel abstraction or duplicate a responsibility contrary to `policy.responsibility_rules`?",
"The plan duplicates an existing responsibility or assigns it to more than one abstraction.",
"The plan reuses or cleanly extends the declared responsibility owners.",
),
"breaking_contract": noul(
"Does `plan` break a public API or data contract without the migration required by `policy.change_practices`?",
"The plan changes a public contract without a compatible transition or explicit migration.",
"The plan preserves compatibility or includes a concrete migration path.",
),
"missing_verification": noul(
"Does `plan` omit focused tests or a concrete validation method required by `policy.change_practices`?",
"The plan lacks tests, regression coverage, or an observable way to validate the change.",
"The plan identifies focused tests and a concrete post-change validation method.",
),
"unsafe_operation": noul(
"Does `plan` violate any safety or production rule in `policy.operational_rules`?",
"The plan exposes sensitive data, destroys protected state, or changes production without required safeguards.",
"The plan avoids unsafe operations or includes the required safeguards.",
),
}
What do the five questions cover?
- The layer_violation check catches forbidden dependency direction or misplaced work.
- The duplicate_responsibility check catches parallel abstractions with overlapping ownership.
- The breaking_contract check catches incompatible API or data changes without migration plans.
- The missing_verification check catches vague testing or validation plans.
- The unsafe_operation check catches dangerous handling of credentials, data, or production changes.
- Save plan_gate.py.
- Confirm the new dictionary contains five named entries.
- Confirm the legacy path still runs by using this command:
.venv/bin/python plan_gate.py fixtures/risky-plan.md --legacy
What does this check prove?
The report still contains exactly one overall_architecture_risk score. This confirms the original comparison path still works while you add the policy-aware path.
- In plan_gate.py, locate evaluate_plan().
- Add read_json() immediately above evaluate_plan() by pasting this code:
def read_json(path: Path) -> dict[str, Any]:
with path.open(encoding="utf-8") as handle:
value = json.load(handle)
if not isinstance(value, dict):
raise ValueError(f"Expected a JSON object in {path}")
return value
What does this helper protect?
The helper loads a file as JSON. It rejects a top-level value that is not an object because the evaluator expects named policy categories.
- In parse_args(), locate the existing --legacy argument.
- Add the policy argument immediately above --legacy by pasting this code:
parser.add_argument(
"--policy",
default=".claude/architecture-policy.json",
help="Path to the architecture policy JSON file",
)
Why add a policy path?
The default points the reviewer at the policy you just created. A configurable path also prepares the reviewer for alternative policy packs.
- Save plan_gate.py.
- Confirm the legacy path remains runnable by using this command:
.venv/bin/python plan_gate.py fixtures/risky-plan.md --legacy
What should remain stable?
You should still see the model, decision, and one legacy score. The new policy option does not alter a run that includes --legacy.
- Replace the existing evaluate_plan() function with this policy-aware version:
def evaluate_plan(
plan: str,
policy: dict[str, Any] | None,
legacy: bool = False,
) -> dict[str, Any]:
questions = LEGACY_QUESTIONS if legacy else ARCHITECTURE_QUESTIONS
state: dict[str, Any] = {"plan": plan}
if not legacy:
if policy is None:
raise ValueError("An architecture policy is required")
state["policy"] = policy
with TypeSafeClient(model=MODEL, timeout=10.0) as client:
response = client.system_one(state=state, questions=questions)
scores = {
name: response.answers[name].noul
for name in questions
}
decision = route(scores, legacy=legacy)
return {
"model": response.model,
"decision": decision,
"scores": scores,
}
How does structured state work here?
- The questions variable selects the broad legacy check or the five atomic checks.
- The state object always contains the Markdown plan.
- A non-legacy run adds the parsed policy to the same state object.
- One request evaluates every selected question against that shared context.
- Inside main(), locate the plan-loading line in the try block.
- Replace the plan-loading and report-assignment section with these lines:
plan = Path(args.plan).read_text(encoding="utf-8")
policy = None if args.legacy else read_json(Path(args.policy))
report = evaluate_plan(plan, policy, legacy=args.legacy)
What changes in the command path?
A standard run now loads the default policy before evaluation. A legacy run skips policy loading and retains the original one-question behavior.
- Save plan_gate.py.
Before you run the updated reviewer, predict whether the report will still contain one score or expand to five.
- Run the risky fixture against the default policy by using this command:
.venv/bin/python plan_gate.py fixtures/risky-plan.md
What should the report contain?
The scores object should contain layer_violation, duplicate_responsibility, breaking_contract, missing_verification, and unsafe_operation.
Each value is separate evidence about one practice. The report now shows where concern comes from.
Still seeing one score?
Confirm the command does not include --legacy. Check that evaluate_plan() selects ARCHITECTURE_QUESTIONS for a standard run.
Confirm .claude/architecture-policy.json is saved inside the project. Help me debug why my reviewer still returns one score.
Compare three fixture profiles
One risky example proves the checks execute. Safe and ambiguous fixtures reveal whether the questions respond differently to clear compliance and incomplete intent.
- Inside the fixtures folder from earlier, create a file named safe-plan.md.
- Add the safe implementation plan by pasting this content:
# Normalize customer email addresses
1. Extend the existing UserService in the application layer with email normalization before it constructs the domain EmailAddress value.
2. Keep the controller limited to request parsing and delegation.
3. Reuse the existing UserRepository port. Do not add a second persistence abstraction.
4. Preserve the public request and response schema.
5. Add focused unit tests for normalization and a regression test for existing mixed-case addresses.
6. Run the existing user-service test suite and inspect structured error logs after the change.
Why is this plan safer?
The plan names responsibility owners and preserves dependency direction. It also protects contracts through focused tests and an observable validation method.
- Save fixtures/safe-plan.md.
- Confirm the Explorer sidebar lists safe-plan.md beside risky-plan.md.
- Inside the same fixtures folder, create a file named ambiguous-plan.md.
- Add the incomplete implementation plan by pasting this content:
# Refactor customer authentication
1. Add a new authentication manager and move selected login responsibilities into it.
2. Update the API where necessary.
3. Change persistence helpers to support the new flow.
4. Test the important paths after implementation.
Why is this plan ambiguous?
The plan suggests changes without naming precise boundaries or compatibility handling. Its testing language also lacks a concrete validation method.
- Save fixtures/ambiguous-plan.md.
- Confirm the Explorer sidebar lists all three Markdown fixtures.
Before you compare the fixtures, predict which plan will produce the most concentrated risk profile.
- Return to the Terminal session from earlier.
- Run the risky fixture again by using this command:
.venv/bin/python plan_gate.py fixtures/risky-plan.md
How should you read the profile?
Focus on the five score names before comparing their values. The named profile links each probability to a specific policy concern.
- Press the Up Arrow key to recall the previous command.
- Replace risky-plan.md with safe-plan.md.
- Press Enter to review the safe fixture.
- Press the Up Arrow key to recall the command again.
- Replace safe-plan.md with ambiguous-plan.md.
- Press Enter to review the ambiguous fixture.
Across all three reports, you should see layer_violation, duplicate_responsibility, breaking_contract, missing_verification, and unsafe_operation probabilities. The values should change as the fixture language changes.
That is the key improvement. Your reviewer now exposes five focused signals that deterministic code can route in the next step.
Missing a score or fixture?
Confirm each fixture is saved inside fixtures. Check that every key in ARCHITECTURE_QUESTIONS matches the expected score name exactly.
If the request fails before printing JSON, confirm the Terminal session still has access to the TypeSafe key. Help me diagnose my five-score reviewer run.
Your policy-aware reviewer can now separate architecture concerns. Next, you will turn those probabilities into deterministic allow, deny, and human-review decisions.
Add Deterministic Approval Routing
Your reviewer now produces five named Jev probabilities for every plan. Those scores expose the exact architecture practices that need attention.
Probabilities are evidence. Deterministic Python rules must decide whether Claude Code can proceed automatically or needs human review.
In this step, get ready to:
- Define which architecture risks can trigger an automatic denial.
- Route clear results to allow or deny while sending uncertain results to ask.
- Keep file, policy, JSON, and SDK failures away from automatic approval.
Define the hard-failure checks
The existing legacy route already uses APPROVE_MAX at 0.20 plus DENY_MIN at 0.80. The atomic route also needs to know which risks are severe enough to deny a plan.
- In plan_gate.py, locate DENY_MIN = 0.80 near the top of the file.
- Add the hard-failure set below the thresholds by pasting this code:
HARD_FAILURES = {
"layer_violation",
"duplicate_responsibility",
"breaking_contract",
"unsafe_operation",
}
What does this set control?
- layer_violation protects dependency direction plus layer ownership.
- duplicate_responsibility catches parallel abstractions that split ownership.
- breaking_contract protects public API or data compatibility.
- unsafe_operation covers destructive or sensitive operational work.
Where does missing verification go?
missing_verification stays outside HARD_FAILURES. A high score for that check cannot trigger deny on its own.
The score still blocks allow when it exceeds 0.20. That sends the plan to ask for human judgment.
- Save plan_gate.py.
- Confirm the new set contains four architecture or operational checks.
Hard failures in the wrong place?
Keep HARD_FAILURES at module level beside the two thresholds. Extra indentation can accidentally place it inside another construct.
Check that every name exactly matches a key in ARCHITECTURE_QUESTIONS.
Still stuck? Help me place the HARD_FAILURES set in plan_gate.py.
Route every probability profile
A Noul score near an endpoint provides stronger evidence. Scores between the two thresholds preserve human control through the ask route.
- In plan_gate.py, locate the function that starts with def route(scores: dict[str, float], legacy: bool = False) -> str:.
- Replace the entire route() function with this code:
def route(scores: dict[str, float], legacy: bool = False) -> str:
if legacy:
value = scores["overall_architecture_risk"]
if value >= DENY_MIN:
return "deny"
if value <= APPROVE_MAX:
return "allow"
return "ask"
if any(scores[name] >= DENY_MIN for name in HARD_FAILURES):
return "deny"
if all(value <= APPROVE_MAX for value in scores.values()):
return "allow"
return "ask"
How does routing work?
- The legacy branch preserves the original one-score behavior.
- any(...) returns deny when one hard failure reaches 0.80.
- all(...) returns allow only when every score is no higher than 0.20.
- The final return sends every middle case to ask.
- Save plan_gate.py.
Before you run this, consider whether the risky fixture should reach deny under the new rules.
This call uses billed TypeSafe API input. It evaluates one short fixture against the policy already in your project.
- Test the risky fixture by running this command:
.venv/bin/python plan_gate.py fixtures/risky-plan.md
What should this prove?
You should see a JSON report with deny as its decision. The scores object should contain all five named probabilities.
At least one hard-failure score should reach the denial boundary. That is the deterministic routing boundary doing its job.
Risky fixture does not reach deny?
Check that evaluate_plan() passes the five-score dictionary into route().
Confirm the hard-failure names match the corresponding score keys character for character.
Need another pair of eyes? Help me debug why the risky fixture is not reaching deny.
Keep reviewer failures fail-safe
Automatic approval must require a complete review. A missing file or failed API request must stop the command instead of producing allow.
- In main(), locate the exception handler beneath the review try block.
- Replace that handler with this fail-safe code:
except (OSError, ValueError, TypeSafeError, json.JSONDecodeError) as error:
print(f"Review failed: {error}", file=sys.stderr)
return 1
Which failures are contained?
- OSError covers missing or unreadable fixture and policy files.
- ValueError covers a policy whose top-level value is not a JSON object.
- json.JSONDecodeError covers invalid policy JSON.
- TypeSafeError covers failures reported by the TypeSafe SDK.
Returning 1 makes the command fail visibly. The result cannot be mistaken for automatic approval.
- Save plan_gate.py.
Before you run all three fixtures, predict which one should reach each of the allow, deny, and ask routes.
- Verify the three routing outcomes by running these commands:
.venv/bin/python plan_gate.py fixtures/safe-plan.md
.venv/bin/python plan_gate.py fixtures/risky-plan.md
.venv/bin/python plan_gate.py fixtures/ambiguous-plan.md
What should the reports show?
The safe fixture should report allow. The risky fixture should report deny.
The ambiguous fixture should report ask. Every report should retain the five underlying scores so you can audit the decision.
One of the fixture runs failed?
Confirm you are still in the jev-architecture-gate project root. Each fixture path is relative to that folder.
Check that TYPESAFE_API_KEY remains exported in this Terminal session.
Still seeing a failure? Help me diagnose my fixture routing run.
✔️ Awesome, I've got everything!
Great. Save plan_gate.py once more so the deterministic routing rules are ready for the Claude Code hook.
ⓧ I'd like to double check the full code
#!/usr/bin/env python3
import argparse
import json
import sys
from pathlib import Path
from typing import Any
from typesafe_sdk import Noul, NoulCriteria, TypeSafeClient, TypeSafeError
MODEL = "jev-1.13.0"
APPROVE_MAX = 0.20
DENY_MIN = 0.80
HARD_FAILURES = {
"layer_violation",
"duplicate_responsibility",
"breaking_contract",
"unsafe_operation",
}
def noul(question: str, true_case: str, false_case: str) -> Noul:
return Noul(
instructions=question,
criteria=NoulCriteria(true=true_case, false=false_case),
)
LEGACY_QUESTIONS = {
"overall_architecture_risk": noul(
"Does `plan` contain a serious architecture or codebase-practice problem?",
"The plan contains at least one serious design, maintainability, testing, compatibility, security, or operational problem.",
"The plan is architecturally sound and follows strong codebase practices.",
)
}
ARCHITECTURE_QUESTIONS = {
"layer_violation": noul(
"Does `plan` violate any dependency-direction or layer-ownership rule in `policy.layer_rules`?",
"A proposed dependency or responsibility crosses a boundary forbidden by the policy.",
"Every proposed dependency and responsibility follows the declared layer rules.",
),
"duplicate_responsibility": noul(
"Does `plan` create a parallel abstraction or duplicate a responsibility contrary to `policy.responsibility_rules`?",
"The plan duplicates an existing responsibility or assigns it to more than one abstraction.",
"The plan reuses or cleanly extends the declared responsibility owners.",
),
"breaking_contract": noul(
"Does `plan` break a public API or data contract without the migration required by `policy.change_practices`?",
"The plan changes a public contract without a compatible transition or explicit migration.",
"The plan preserves compatibility or includes a concrete migration path.",
),
"missing_verification": noul(
"Does `plan` omit focused tests or a concrete validation method required by `policy.change_practices`?",
"The plan lacks tests, regression coverage, or an observable way to validate the change.",
"The plan identifies focused tests and a concrete post-change validation method.",
),
"unsafe_operation": noul(
"Does `plan` violate any safety or production rule in `policy.operational_rules`?",
"The plan exposes sensitive data, destroys protected state, or changes production without required safeguards.",
"The plan avoids unsafe operations or includes the required safeguards.",
),
}
def read_json(path: Path) -> dict[str, Any]:
with path.open(encoding="utf-8") as handle:
value = json.load(handle)
if not isinstance(value, dict):
raise ValueError(f"Expected a JSON object in {path}")
return value
def evaluate_plan(
plan: str,
policy: dict[str, Any] | None,
legacy: bool = False,
) -> dict[str, Any]:
questions = LEGACY_QUESTIONS if legacy else ARCHITECTURE_QUESTIONS
state: dict[str, Any] = {"plan": plan}
if not legacy:
if policy is None:
raise ValueError("An architecture policy is required")
state["policy"] = policy
with TypeSafeClient(model=MODEL, timeout=10.0) as client:
response = client.system_one(state=state, questions=questions)
scores = {
name: response.answers[name].noul
for name in questions
}
decision = route(scores, legacy=legacy)
return {
"model": response.model,
"decision": decision,
"scores": scores,
}
def route(scores: dict[str, float], legacy: bool = False) -> str:
if legacy:
value = scores["overall_architecture_risk"]
if value >= DENY_MIN:
return "deny"
if value <= APPROVE_MAX:
return "allow"
return "ask"
if any(scores[name] >= DENY_MIN for name in HARD_FAILURES):
return "deny"
if all(value <= APPROVE_MAX for value in scores.values()):
return "allow"
return "ask"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Review a Claude Code plan against architecture policy."
)
parser.add_argument("plan", nargs="?", help="Path to a Markdown plan fixture")
parser.add_argument(
"--policy",
default=".claude/architecture-policy.json",
help="Path to the architecture policy JSON file",
)
parser.add_argument("--legacy", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
if not args.plan:
raise SystemExit("Provide a plan path or use --hook")
try:
plan = Path(args.plan).read_text(encoding="utf-8")
policy = None if args.legacy else read_json(Path(args.policy))
report = evaluate_plan(plan, policy, legacy=args.legacy)
except (OSError, ValueError, TypeSafeError, json.JSONDecodeError) as error:
print(f"Review failed: {error}", file=sys.stderr)
return 1
print(json.dumps(report, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
Your reviewer now turns five semantic scores into auditable workflow decisions. Next, you will connect that routing boundary to Claude Code's plan approval flow.
Gate Claude Code Plan Approval
Your reviewer already turns five Jev scores into deterministic allow, deny, or ask decisions. You have proved those routes against three Markdown fixtures.
A fixture command only advises you from Terminal. A project-scoped Claude Code hook moves the same decision to the ExitPlanMode boundary. The existing Python rules can then act before implementation begins.
In this step, get ready to:
- Register a project hook that matches ExitPlanMode.
- Add hook-mode input handling to plan_gate.py.
- Launch Claude Code in plan mode to verify the architecture gate.
Register the project hook
A project hook lives in .claude/settings.json, so its behavior stays scoped to this repository. The exact ExitPlanMode matcher keeps the reviewer focused on plan approval.
- Create .claude/settings.json from the VS Code Explorer by using New File.
- Add the project hook configuration by pasting this code:
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
"PreToolUse": [
{
"matcher": "ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.venv/bin/python",
"args": [
"${CLAUDE_PROJECT_DIR}/plan_gate.py",
"--hook"
],
"timeout": 30,
"statusMessage": "Jev is reviewing the architecture plan"
}
]
}
]
}
}
What Does This Configuration Do?
- The PreToolUse event runs before Claude Code acts on a matching tool request.
- The ExitPlanMode matcher limits this hook to the plan-approval boundary.
- The command calls the virtual environment's Python executable directly.
- The args array passes plan_gate.py plus --hook without shell quoting.
- The status message tells you when Jev is evaluating the plan.
- Save .claude/settings.json.
- Confirm the VS Code Explorer lists settings.json inside .claude/.
Good progress. The repository now declares exactly when Claude Code should call the reviewer.
Settings File Not Loading?
Check that settings.json sits directly inside .claude/. A file elsewhere in the project does not register this project hook.
Compare every brace and comma with the reference below. Invalid JSON prevents Claude Code from reading the configuration.
Still stuck? Help me troubleshoot my Claude Code project hook configuration.
✔️ Awesome, I've got everything!
Your project hook configuration is saved in .claude/settings.json.
ⓧ I'd like to double check the full code
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
"PreToolUse": [
{
"matcher": "ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.venv/bin/python",
"args": [
"${CLAUDE_PROJECT_DIR}/plan_gate.py",
"--hook"
],
"timeout": 30,
"statusMessage": "Jev is reviewing the architecture plan"
}
]
}
]
}
}
What Should Match?
Compare the matcher and command paths character for character. The hook must call the Python executable inside .venv from the Claude Code project directory.
Add hook-mode handling
Claude Code sends each hook input as a JSON object through standard input. The reviewer needs to extract tool_input.plan before evaluating the same five architecture questions.
Hook failures must preserve human control. An absent plan or integration error therefore returns ask instead of approving implementation.
How Hook Mode Stays Safe
- The hook_result() helper builds the required hookSpecificOutput object.
- An allow result includes the original tool_input as updatedInput.
- The run_hook() function ignores tools other than ExitPlanMode.
- The policy path starts from CLAUDE_PROJECT_DIR when that value is available.
- The exception path catches file problems and SDK failures before returning ask.
Trace the Hook Path
The main() function checks --hook before requiring a fixture path. This keeps fixture mode unchanged.
The run_hook() function reads the injected plan. It loads the repository policy before calling evaluate_plan().
The resulting scores still pass through route(). The hook only translates that existing decision into Claude Code's response shape.
- Update plan_gate.py in one pass so its cumulative content matches the full-file reference below.
✔️ Awesome, I've got everything!
Your reviewer now supports fixture mode plus Claude Code hook mode. Save plan_gate.py before continuing.
ⓧ I'd like to double check the full code
#!/usr/bin/env python3
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any
from typesafe_sdk import Noul, NoulCriteria, TypeSafeClient, TypeSafeError
MODEL = "jev-1.13.0"
APPROVE_MAX = 0.20
DENY_MIN = 0.80
HARD_FAILURES = {
"layer_violation",
"duplicate_responsibility",
"breaking_contract",
"unsafe_operation",
}
def noul(question: str, true_case: str, false_case: str) -> Noul:
return Noul(
instructions=question,
criteria=NoulCriteria(true=true_case, false=false_case),
)
LEGACY_QUESTIONS = {
"overall_architecture_risk": noul(
"Does `plan` contain a serious architecture or codebase-practice problem?",
"The plan contains at least one serious design, maintainability, testing, compatibility, security, or operational problem.",
"The plan is architecturally sound and follows strong codebase practices.",
)
}
ARCHITECTURE_QUESTIONS = {
"layer_violation": noul(
"Does `plan` violate any dependency-direction or layer-ownership rule in `policy.layer_rules`?",
"A proposed dependency or responsibility crosses a boundary forbidden by the policy.",
"Every proposed dependency and responsibility follows the declared layer rules.",
),
"duplicate_responsibility": noul(
"Does `plan` create a parallel abstraction or duplicate a responsibility contrary to `policy.responsibility_rules`?",
"The plan duplicates an existing responsibility or assigns it to more than one abstraction.",
"The plan reuses or cleanly extends the declared responsibility owners.",
),
"breaking_contract": noul(
"Does `plan` break a public API or data contract without the migration required by `policy.change_practices`?",
"The plan changes a public contract without a compatible transition or explicit migration.",
"The plan preserves compatibility or includes a concrete migration path.",
),
"missing_verification": noul(
"Does `plan` omit focused tests or a concrete validation method required by `policy.change_practices`?",
"The plan lacks tests, regression coverage, or an observable way to validate the change.",
"The plan identifies focused tests and a concrete post-change validation method.",
),
"unsafe_operation": noul(
"Does `plan` violate any safety or production rule in `policy.operational_rules`?",
"The plan exposes sensitive data, destroys protected state, or changes production without required safeguards.",
"The plan avoids unsafe operations or includes the required safeguards.",
),
}
def read_json(path: Path) -> dict[str, Any]:
with path.open(encoding="utf-8") as handle:
value = json.load(handle)
if not isinstance(value, dict):
raise ValueError(f"Expected a JSON object in {path}")
return value
def evaluate_plan(
plan: str,
policy: dict[str, Any] | None,
legacy: bool = False,
) -> dict[str, Any]:
questions = LEGACY_QUESTIONS if legacy else ARCHITECTURE_QUESTIONS
state: dict[str, Any] = {"plan": plan}
if not legacy:
if policy is None:
raise ValueError("An architecture policy is required")
state["policy"] = policy
with TypeSafeClient(model=MODEL, timeout=10.0) as client:
response = client.system_one(state=state, questions=questions)
scores = {
name: response.answers[name].noul
for name in questions
}
decision = route(scores, legacy=legacy)
return {
"model": response.model,
"decision": decision,
"scores": scores,
}
def route(scores: dict[str, float], legacy: bool = False) -> str:
if legacy:
value = scores["overall_architecture_risk"]
if value >= DENY_MIN:
return "deny"
if value <= APPROVE_MAX:
return "allow"
return "ask"
if any(scores[name] >= DENY_MIN for name in HARD_FAILURES):
return "deny"
if all(value <= APPROVE_MAX for value in scores.values()):
return "allow"
return "ask"
def summarize(scores: dict[str, float]) -> str:
ordered = sorted(scores.items(), key=lambda item: item[1], reverse=True)
return ", ".join(f"{name}={value:.2f}" for name, value in ordered)
def hook_result(
decision: str,
tool_input: dict[str, Any],
reason: str,
) -> dict[str, Any]:
output: dict[str, Any] = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": decision,
"permissionDecisionReason": reason,
}
}
if decision == "allow":
output["hookSpecificOutput"]["updatedInput"] = tool_input
return output
def run_hook() -> int:
try:
payload = json.load(sys.stdin)
if payload.get("tool_name") != "ExitPlanMode":
return 0
tool_input = payload.get("tool_input", {})
if not isinstance(tool_input, dict):
raise ValueError("Claude Code supplied a non-object tool_input")
plan = tool_input.get("plan")
if not isinstance(plan, str) or not plan.strip():
result = hook_result(
"ask",
tool_input,
"Architecture gate could not read the injected plan, so human review is required.",
)
print(json.dumps(result))
return 0
project_dir = Path(
os.environ.get("CLAUDE_PROJECT_DIR", payload.get("cwd", "."))
)
policy = read_json(project_dir / ".claude" / "architecture-policy.json")
report = evaluate_plan(plan, policy)
reason = f"Jev architecture gate: {summarize(report['scores'])}"
print(json.dumps(hook_result(report["decision"], tool_input, reason)))
return 0
except (OSError, ValueError, TypeSafeError, json.JSONDecodeError) as error:
fallback = hook_result(
"ask",
{},
f"Architecture gate failed safely and requires human review: {error}",
)
print(json.dumps(fallback))
return 0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Review a Claude Code plan against architecture policy."
)
parser.add_argument("plan", nargs="?", help="Path to a Markdown plan fixture")
parser.add_argument(
"--policy",
default=".claude/architecture-policy.json",
help="Path to the architecture policy JSON file",
)
parser.add_argument("--legacy", action="store_true")
parser.add_argument("--hook", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
if args.hook:
return run_hook()
if not args.plan:
raise SystemExit("Provide a plan path or use --hook")
try:
plan = Path(args.plan).read_text(encoding="utf-8")
policy = None if args.legacy else read_json(Path(args.policy))
report = evaluate_plan(plan, policy, legacy=args.legacy)
except (OSError, ValueError, TypeSafeError, json.JSONDecodeError) as error:
print(f"Review failed: {error}", file=sys.stderr)
return 1
print(json.dumps(report, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
What Should Match?
The cumulative file preserves the existing questions and routing thresholds. It adds the hook response builder plus the standard-input handler.
The command-line path still reads Markdown fixtures. The --hook path reads Claude Code's injected plan.
- Save plan_gate.py.
Launch Claude Code and verify the gate
The hook loads when a Claude Code session starts from this project. A fresh plan-mode session gives you the real approval-boundary test.
- Switch back to the Terminal window from earlier.
- Launch Claude Code in plan mode by running this command:
claude --permission-mode plan
What Does This Launch Do?
The permission mode starts Claude Code in plan mode. Claude can prepare an implementation plan before requesting permission to exit that mode.
Before you inspect the hook browser, which event and matcher do you expect the project configuration to register?
- Open Claude Code's hook browser by entering:
/hooks
What Does the Hook Browser Show?
The hook browser lists the event and matcher for each loaded hook. It also shows the hook type plus its source.
You'll see a PreToolUse hook with matcher ExitPlanMode. This confirms Claude Code loaded .claude/settings.json from the project.
Registration proves that the hook is available. A plan request now proves that the reviewer can read injected Markdown and return a routing decision.
- Return to the Claude Code conversation.
- Ask Claude Code for a small implementation plan that follows .claude/architecture-policy.json.
Before Claude Code presents the plan for approval, do you expect the gate to allow it or preserve human review?
When Claude Code calls ExitPlanMode, you'll see the gate apply its allow or ask result. An allow includes the original tool_input as updatedInput.
- Ask Claude Code for a second plan that puts SQL directly in an HTTP controller.
Before Claude Code tries to leave plan mode, do you expect the layer check to stay below the approval threshold?
A hard-failure score at or above 0.80 produces deny. A middle score produces ask and preserves the normal human approval prompt.
Hook Missing or Always Asking?
Confirm you launched Claude Code from the jev-architecture-gate project root. The project directory controls which .claude/settings.json file loads.
Confirm TYPESAFE_API_KEY remains exported in the Terminal session that launched Claude Code. An SDK failure deliberately falls back to human review.
Need help tracing the result? Help me debug my Claude Code architecture gate.
That's the enforcement loop complete. Claude Code now checks every ExitPlanMode request against your architecture policy before implementation can proceed.
Secret mission
Add an Event-Driven Architecture Policy Pack
Add a second policy pack for event-driven systems. Test it against a flawed event-processing plan. Tune one input to watch a probability respond.
Clean Up Your Resources
Clean Up Your Resources
Your gate runs locally, so keeping its files creates no ongoing charge. New reviews still send usage-based TypeSafe API requests at the current Jev rate of $0.042 per million input tokens.
Resources you used:
- Local jev-architecture-gate directory containing the reviewer, virtual environment, project hook, architecture policies, and Markdown fixtures.
Keep everything running
Keep this option if you want the architecture gate to review future plans. Your policies and fixtures remain ready for more tuning.
- Leave the jev-architecture-gate directory on your Mac.
- Leave .claude/settings.json unchanged so the ExitPlanMode hook stays active.
- Run the reviewer only when you want a new architecture decision because each review can make a billable TypeSafe API request.
Pause - I'll come back to this later
Pause the hook when you want to preserve the reviewer without running it during Claude Code plan approvals. Every local file remains available for later.
- Return to Visual Studio Code from the project steps.
- Select .claude/settings.json in the Explorer sidebar.
- Set the top-level disableAllHooks value to true.
- Save .claude/settings.json.
- Confirm the file now contains "disableAllHooks": true.
Delete - I don't want to use this again
Removing the folder is permanent, but it affects only this local project. Your existing TypeSafe account and API key stay untouched.
- Return to the Terminal session you used for jev-architecture-gate.
- Remove the complete local project by running these commands:
cd ..
rm -rf jev-architecture-gate
ls
What Does This Cleanup Remove?
- The first line moves Terminal to the directory above the project.
- The second line deletes jev-architecture-gate with its virtual environment, hook, policies, reviewer, and fixtures.
- The final line lists the remaining directories so you can verify the deletion.
You should no longer see jev-architecture-gate in the directory listing.
That clears the complete local gate with its project hook and isolated environment.
Still See the Project Directory?
Terminal may have started from a different parent directory. A permissions problem can also prevent deletion.
- Return to the directory that directly contains jev-architecture-gate.
- Run the cleanup block again.
Help me locate and delete the correct project directory safely.
Nice Work!
Nice Work!
Excellent work. Your architecture gate now intercepts Claude Code plans before implementation begins.
You've learned how to:
- Evaluate Markdown implementation plans with five atomic Jev checks against a version-controlled architecture policy.
- Turn model probabilities into deterministic Python routing for allow, deny, or ask outcomes. Preserve human review whenever the evidence is uncertain.
- Connect the reviewer to a project-scoped Claude Code hook at PreToolUse with matcher ExitPlanMode. The gate now runs at the plan approval boundary.
- Secret Mission: Extended the gate with an event-driven architecture policy pack. You tested a deliberately flawed plan against the new policy. You changed one input to explain a probability shift.
Ready to quiz yourself?