From eef910b8f57407027d118d526bf6d2637396c945 Mon Sep 17 00:00:00 2001 From: AnExiledDev <696222+AnExiledDev@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:46:47 +0000 Subject: [PATCH 1/6] refactor(auto-code-quality): replace Stop hooks with /cq skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3 Stop hooks (format-on-stop, lint-file, advisory-test-runner) caused race conditions with background agents, fired too frequently during orchestration pauses, and produced lint results as passive context that Claude couldn't act on. Replace them with: - /cq skill — Claude runs format, lint (auto-fix), and tests explicitly - quality-gate.py — lightweight Stop hook that prompts /cq when needed - task-tracker.py — tracks active background tasks to avoid conflicts The gate is background-task-aware and self-cleaning (deletes temp files on block to prevent loops). --- container/.devcontainer/AGENTS.md | 2 +- container/.devcontainer/CHANGELOG.md | 6 + container/.devcontainer/README.md | 17 +- .../.claude-plugin/marketplace.json | 2 +- .../.claude-plugin/plugin.json | 2 +- .../plugins/auto-code-quality/README.md | 118 +++- .../auto-code-quality/hooks/hooks.json | 32 +- .../scripts/advisory-test-runner.py | 357 ------------ .../scripts/format-on-stop.py | 304 ---------- .../auto-code-quality/scripts/lint-file.py | 543 ------------------ .../auto-code-quality/scripts/quality-gate.py | 104 ++++ .../auto-code-quality/scripts/task-tracker.py | 79 +++ .../auto-code-quality/skills/cq/SKILL.md | 117 ++++ 13 files changed, 436 insertions(+), 1247 deletions(-) delete mode 100644 container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/advisory-test-runner.py delete mode 100644 container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/format-on-stop.py delete mode 100644 container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/lint-file.py create mode 100644 container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/quality-gate.py create mode 100644 container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/task-tracker.py create mode 100644 container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/skills/cq/SKILL.md diff --git a/container/.devcontainer/AGENTS.md b/container/.devcontainer/AGENTS.md index 235619c..b938374 100644 --- a/container/.devcontainer/AGENTS.md +++ b/container/.devcontainer/AGENTS.md @@ -50,7 +50,7 @@ Declared in `settings.json` under `enabledPlugins`, auto-activated on start: - **agent-system** — 4 custom agents (architect, claude-guide, explorer, generalist) + built-in agent redirection - **skill-engine** — 2 coding knowledge packs (`/team`, `/agent-browser`) + auto-suggestion -- **auto-code-quality** — Auto-format + auto-lint + advisory test runner +- **auto-code-quality** — File tracking, syntax validation, `/cq` quality gate (format + lint + test on demand) - **session-context** — Git state injection, TODO harvesting, commit reminders - **workspace-scope-guard** — Blocks writes outside working directory - **dangerous-command-blocker** — Blocks destructive bash commands diff --git a/container/.devcontainer/CHANGELOG.md b/container/.devcontainer/CHANGELOG.md index 37bd8bc..3e0e0f1 100644 --- a/container/.devcontainer/CHANGELOG.md +++ b/container/.devcontainer/CHANGELOG.md @@ -63,6 +63,12 @@ - **Disabled prompt-snippets plugin** — `/ps` command no longer available. - **Stripped skill-suggester** — auto-suggestion now only covers `team` and `agent-browser` (was 25+ skills). +### Code Quality + +- **Replaced auto-format/lint/test Stop hooks with `/cq` skill** — the three Stop hooks (`format-on-stop.py`, `lint-file.py`, `advisory-test-runner.py`) that ran automatically on every stop are replaced by a single `/cq` skill that Claude invokes explicitly. Eliminates race conditions with background agents, stops firing during orchestration pauses, and lets Claude act on lint/test results instead of ignoring passive context. +- **New quality gate Stop hook** — lightweight `quality-gate.py` (~1ms) checks whether files were edited and no background tasks are running, then blocks the stop with a prompt to run `/cq`. Deletes temp files on block to prevent loops. +- **New task tracker hooks** — `task-tracker.py` handles `TaskCreated`/`TaskCompleted` events to maintain an active-task count. The quality gate skips blocking while tasks are running, preventing format/lint conflicts with background agents. + ### CI - **Canary pre-release publishing** — every push to `staging` that touches `container/` now auto-publishes a canary build to npm. Install with `npm i @coredirective/cf-container@canary` to try unreleased changes. Versions use the format `{version}-staging.{sha7}`. diff --git a/container/.devcontainer/README.md b/container/.devcontainer/README.md index 8e5df82..d419a91 100644 --- a/container/.devcontainer/README.md +++ b/container/.devcontainer/README.md @@ -218,6 +218,21 @@ claude --resume # Resume previous session | `codex` | OpenAI Codex CLI terminal coding agent | | `hermes` | Nous Research Hermes Agent CLI (run `hermes setup` on first use) | +### Windows Host Chrome CDP + +For Hermes, Vercel agent-browser, Codex, or Claude Code to control Chrome running on a Windows host, run this from an Administrator PowerShell at the repository root: + +```powershell +.\.devcontainer\scripts\start-hermes-chrome.ps1 +``` + +The devcontainer uses `HERMES_CDP_ENDPOINT=http://192.168.65.254:9223` for Docker Desktop. Verify the current host IPv4 from inside the container: + +```bash +CDP_HOST=$(getent ahostsv4 host.docker.internal | awk 'NR==1 {print $1}') +curl http://$CDP_HOST:9223/json/version +``` + ### Code Intelligence | Tool | Description | |------|-------------| @@ -335,7 +350,7 @@ CodeForge includes custom devcontainer features. Any feature can be disabled by ### auto-code-quality -Combined auto-formatter, auto-linter, and advisory test runner plugin at `plugins/devs-marketplace/plugins/auto-code-quality/`. Three-phase pipeline: collect edited files (PostToolUse), batch format + lint (Stop), and advisory test runner (Stop). Supports all languages from the former auto-formatter + auto-linter plugins. Replaces the separate `auto-formatter` and `auto-linter` plugins. +Code quality plugin at `plugins/devs-marketplace/plugins/auto-code-quality/`. Tracks edited files (PostToolUse), validates data file syntax instantly, tracks background tasks (TaskCreated/Completed), and gates stops with a lightweight check — if files were edited and no tasks are running, prompts Claude to run the `/cq` skill for formatting, linting (with auto-fix), and affected test execution. Supports Python, JS/TS, Go, Shell, Rust, Markdown, YAML, TOML, and Dockerfiles. ## Alias Management diff --git a/container/.devcontainer/plugins/devs-marketplace/.claude-plugin/marketplace.json b/container/.devcontainer/plugins/devs-marketplace/.claude-plugin/marketplace.json index b932810..d764705 100644 --- a/container/.devcontainer/plugins/devs-marketplace/.claude-plugin/marketplace.json +++ b/container/.devcontainer/plugins/devs-marketplace/.claude-plugin/marketplace.json @@ -84,7 +84,7 @@ }, { "name": "auto-code-quality", - "description": "Self-contained code quality: auto-format + auto-lint edited files (Ruff/Black, Biome, gofmt, shfmt, dprint, rustfmt, Pyright, ShellCheck, go vet, hadolint, clippy)", + "description": "Code quality with /cq skill: file tracking, syntax validation, background-task-aware quality gate, on-demand format + lint + test (Ruff, Biome, gofmt, shfmt, dprint, rustfmt, Pyright, ShellCheck, go vet, hadolint, clippy)", "version": "1.0.0", "source": "./plugins/auto-code-quality", "category": "development", diff --git a/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/.claude-plugin/plugin.json b/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/.claude-plugin/plugin.json index 59ecb44..7144c07 100644 --- a/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/.claude-plugin/plugin.json +++ b/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "auto-code-quality", - "description": "Self-contained code quality plugin: collects edited files, batch-formats at Stop (Ruff/Black for Python, gofmt for Go, Biome for JS/TS/CSS/JSON/GraphQL/HTML, shfmt for Shell, dprint for Markdown/YAML/TOML/Dockerfile, rustfmt for Rust), batch-lints at Stop (Pyright + Ruff for Python, Biome for JS/TS/CSS/GraphQL, ShellCheck for Shell, go vet for Go, hadolint for Dockerfile, clippy for Rust), and validates JSON/JSONC/YAML/TOML syntax on edit", + "description": "Code quality plugin: collects edited files on edit, validates JSON/JSONC/YAML/TOML syntax instantly, tracks background tasks, and gates stops with a /cq skill prompt — format, lint, and test on demand instead of automatically", "author": { "name": "AnExiledDev" } diff --git a/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/README.md b/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/README.md index 80b2d79..eb4f1d4 100644 --- a/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/README.md +++ b/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/README.md @@ -1,18 +1,20 @@ # auto-code-quality -Self-contained Claude Code plugin that automatically formats and lints edited files. Drop it into any Claude Code plugin marketplace and enable it — no other plugins required. +Claude Code plugin that tracks edited files and runs code quality checks on demand via the `/cq` skill. Drop it into any Claude Code plugin marketplace and enable it — no other plugins required. ## What It Does -Three-phase pipeline that runs transparently during your Claude Code session: +Two-phase pipeline with an explicit quality gate: -1. **Collect** (PostToolUse on Edit/Write) — Records which files Claude edits -2. **Format** (Stop hook) — Batch-formats all edited files when Claude finishes responding -3. **Lint** (Stop hook) — Batch-lints all edited files and surfaces warnings as context +1. **Track** (PostToolUse on Edit/Write) — Records which files Claude edits, validates data file syntax instantly +2. **Gate** (Stop hook) — Lightweight check: if files were edited and no background tasks are running, blocks the stop and prompts Claude to run `/cq` +3. **Quality** (`/cq` skill) — Claude formats, lints (with auto-fix), and runs affected tests on all edited files -Additionally validates JSON, JSONC, YAML, and TOML syntax immediately after each edit. +The `/cq` skill can also be invoked manually at any time during a session. -All phases are non-blocking. Missing tools are silently skipped. The plugin always exits cleanly — it will never interrupt Claude. +### Why a skill instead of automatic hooks? + +Previous versions ran formatters, linters, and test runners as Stop hooks. This caused issues with background agents (race conditions on file writes), fired too frequently during orchestration pauses, and produced lint results as passive context that was often ignored. The `/cq` skill runs explicitly — Claude can act on results, fix issues, and re-run checks. ## Required Tools @@ -21,7 +23,6 @@ Install the tools for the languages you work with. Everything is optional — th | Language | Formatter | Linter(s) | Install | |----------|-----------|-----------|---------| | Python | [ruff](https://docs.astral.sh/ruff/) | [pyright](https://github.com/microsoft/pyright), ruff check | `pip install ruff` / `npm i -g pyright` | -| Python (fallback) | [black](https://github.com/psf/black) | — | `pip install black` | | Go | gofmt (bundled with Go) | go vet (bundled with Go) | [Install Go](https://go.dev/dl/) | | JS/TS/CSS/GraphQL/HTML | [biome](https://biomejs.dev/) | biome lint | `npm i -D @biomejs/biome` or `npm i -g @biomejs/biome` | | Shell | [shfmt](https://github.com/mvdan/sh) | [shellcheck](https://github.com/koalaman/shellcheck) | `brew install shfmt shellcheck` | @@ -48,6 +49,28 @@ Biome is resolved in this order: 1. Project-local: walks up from the edited file looking for `node_modules/.bin/biome` 2. Global: checks PATH via `which biome` +## Usage + +### Automatic (quality gate) + +Just work normally. When Claude stops after editing files: + +1. The quality gate checks for edited files and active background tasks +2. If files were edited and no tasks are running, it blocks the stop +3. Claude runs `/cq` automatically — formats, lints, tests, fixes issues +4. Claude stops cleanly on the second attempt (temp files cleaned up) + +### Manual + +Type `/cq` at any point to run quality checks on all files edited so far in the session. + +### With background tasks + +The quality gate is background-task-aware: +- While tasks are running, the gate stays silent (no blocking) +- Once all tasks complete and Claude stops, the gate activates +- This prevents race conditions from formatting files that agents are still writing + ## Installation ### CodeForge DevContainer @@ -85,22 +108,39 @@ You edit a file (Edit/Write tool) │ ├─→ collect-edited-files.py Appends path to temp files └─→ syntax-validator.py Validates JSON/YAML/TOML syntax immediately - │ - │ ... Claude keeps working ... - │ + +Background task spawned (TaskCreated) + └─→ task-tracker.py Records task as active + +Background task done (TaskCompleted) + └─→ task-tracker.py Removes task from active list + Claude stops responding (Stop event) - │ - ├─→ format-on-stop.py Reads temp file, formats each file by extension - └─→ lint-file.py Reads temp file, lints each file, injects warnings + └─→ quality-gate.py Checks tasks + edited files + │ + ├─ Tasks active? → skip (exit 0) + ├─ No edits? → skip (exit 0) + └─ Edits found → block stop → Claude runs /cq + → /cq formats, lints, tests + → cleans up temp files + → Claude stops again → gate exits clean ``` ### Temp File Convention -Edited file paths are stored in session-scoped temp files: -- `/tmp/claude-cq-edited-{session_id}` — consumed by the formatter -- `/tmp/claude-cq-lint-{session_id}` — consumed by the linter +Session-scoped temp files in `/tmp/`: -Both are always cleaned up after processing (even on error). +| File | Purpose | Written by | Read by | +|------|---------|------------|---------| +| `claude-cq-edited-{session_id}` | Edited file paths (format + test) | collect-edited-files.py | quality-gate.py, /cq skill | +| `claude-cq-lint-{session_id}` | Edited file paths (lint) | collect-edited-files.py | /cq skill | +| `claude-active-tasks-{session_id}` | Active background task IDs | task-tracker.py | quality-gate.py | + +All temp files are cleaned up after processing (by the gate and/or the skill). + +### Loop Prevention + +The quality gate deletes the edited-files temp file when it blocks. On the second stop (after `/cq` runs), the temp file is gone — the gate exits clean. The `/cq` skill also cleans up temp files as a safety net. ### Timeouts @@ -108,20 +148,37 @@ Both are always cleaned up after processing (even on error). |------|---------| | File collection | 3s | | Syntax validation | 5s | -| Batch formatting | 15s total | -| Batch linting | 60s total | -| Individual tool | 10-12s each | +| Task tracking | 3s | +| Quality gate | 3s | + +The `/cq` skill has no timeout — it runs as a normal Claude conversation turn. + +## Disabling + +### Disable the entire plugin + +Remove from `enabledPlugins` in your settings. + +### Disable individual hooks + +Add the script name (without `.py`) to the `disabled` array in `~/.claude/disabled-hooks.json`: + +```json +{ + "disabled": ["quality-gate"] +} +``` + +Available hook names: `collect-edited-files`, `syntax-validator`, `task-tracker`, `quality-gate` ## Conflict Warning This plugin bundles functionality that may overlap with other plugins. If you're using any of the following, **disable them** before enabling this plugin to avoid duplicate processing: -- `auto-formatter` — formatting is included here -- `auto-linter` — linting is included here +- `auto-formatter` — formatting is included in `/cq` +- `auto-linter` — linting is included in `/cq` - `code-directive` `collect-edited-files.py` hook — file collection is included here -All pipelines use the `claude-cq-*` temp file prefix, so enabling both won't corrupt data — but files would be formatted and linted twice. - ## Plugin Structure ``` @@ -129,16 +186,19 @@ auto-code-quality/ ├── .claude-plugin/ │ └── plugin.json # Plugin metadata ├── hooks/ -│ └── hooks.json # Hook registrations (PostToolUse + Stop) +│ └── hooks.json # Hook registrations ├── scripts/ │ ├── collect-edited-files.py # File path collector (PostToolUse) │ ├── syntax-validator.py # JSON/YAML/TOML validator (PostToolUse) -│ ├── format-on-stop.py # Batch formatter (Stop) -│ └── lint-file.py # Batch linter (Stop) +│ ├── task-tracker.py # Background task counter (TaskCreated/Completed) +│ └── quality-gate.py # Stop gate — prompts /cq if needed (Stop) +├── skills/ +│ └── cq/ +│ └── SKILL.md # /cq skill definition └── README.md # This file ``` ## Requirements - Python 3.11+ (for `tomllib` support in syntax validation; older Python skips TOML) -- Claude Code with plugin hook support +- Claude Code with plugin hook support and skill support diff --git a/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/hooks/hooks.json b/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/hooks/hooks.json index b6ba889..aa72e8c 100644 --- a/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/hooks/hooks.json +++ b/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Self-contained code quality: file collection on edit, syntax validation, batch formatting + linting at stop", + "description": "Code quality: file collection on edit, syntax validation, task tracking, and /cq quality gate at stop", "hooks": { "PostToolUse": [ { @@ -18,23 +18,35 @@ ] } ], - "Stop": [ + "TaskCreated": [ { "hooks": [ { "type": "command", - "command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/format-on-stop.py", - "timeout": 15 - }, + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/task-tracker.py", + "timeout": 3 + } + ] + } + ], + "TaskCompleted": [ + { + "hooks": [ { "type": "command", - "command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/lint-file.py", - "timeout": 60 - }, + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/task-tracker.py", + "timeout": 3 + } + ] + } + ], + "Stop": [ + { + "hooks": [ { "type": "command", - "command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/advisory-test-runner.py", - "timeout": 20 + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/quality-gate.py", + "timeout": 3 } ] } diff --git a/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/advisory-test-runner.py b/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/advisory-test-runner.py deleted file mode 100644 index a96702b..0000000 --- a/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/advisory-test-runner.py +++ /dev/null @@ -1,357 +0,0 @@ -#!/usr/bin/env python3 -""" -Advisory test runner — Stop hook that injects test results as context. - -Reads the list of files edited this session (written by collect-edited-files.py), -maps them to affected test files, and runs only those tests. Skips entirely -if no files were edited. Results are returned as systemMessage (pass/timeout) or decision/reason -block (failure) so Claude acts on test failures before finishing. - -Reads hook input from stdin (JSON). Returns JSON on stdout. -Always exits 0. Failures use decision: "block" to prevent stopping. -""" - -import json -import os -import subprocess -import sys - -# Hook gate — check ~/.claude/disabled-hooks.json -_dh = os.path.join(os.path.expanduser("~"), ".claude", "disabled-hooks.json") -if os.path.exists(_dh): - with open(_dh) as _f: - if os.path.basename(__file__).replace(".py", "") in json.load(_f).get("disabled", []): - sys.exit(0) - -TIMEOUT_SECONDS = 15 - - -def get_edited_files(session_id: str) -> list[str]: - """Read the list of files edited this session. - - Relies on collect-edited-files.py writing paths to a temp file. - Returns deduplicated list of paths that still exist on disk. - """ - tmp_path = f"/tmp/claude-cq-edited-{session_id}" - try: - with open(tmp_path, "r") as f: - raw = f.read() - except OSError: - return [] - - seen: set[str] = set() - result: list[str] = [] - for line in raw.strip().splitlines(): - path = line.strip() - if path and path not in seen and os.path.isfile(path): - seen.add(path) - result.append(path) - return result - - -def detect_test_framework(cwd: str) -> tuple[str, list[str]]: - """Detect which test framework is available in the project. - - Returns: - Tuple of (framework_name, base_command) or ("", []) if none found. - """ - try: - entries = set(os.listdir(cwd)) - except OSError: - return ("", []) - - # --- Python: pytest --- - if "pytest.ini" in entries or "conftest.py" in entries: - return ("pytest", ["python3", "-m", "pytest", "--tb=short", "-q"]) - - for cfg_name in ("pyproject.toml", "setup.cfg", "tox.ini"): - cfg_path = os.path.join(cwd, cfg_name) - if os.path.isfile(cfg_path): - try: - with open(cfg_path, "r", encoding="utf-8") as f: - content = f.read() - if ( - "[tool.pytest" in content - or "[pytest]" in content - or "[tool:pytest]" in content - ): - return ("pytest", ["python3", "-m", "pytest", "--tb=short", "-q"]) - except OSError: - pass - - if "tests" in entries and os.path.isdir(os.path.join(cwd, "tests")): - return ("pytest", ["python3", "-m", "pytest", "--tb=short", "-q"]) - - for entry in entries: - if entry.startswith("test_") and entry.endswith(".py"): - return ("pytest", ["python3", "-m", "pytest", "--tb=short", "-q"]) - - # --- JavaScript: vitest --- - for name in entries: - if name.startswith("vitest.config"): - return ("vitest", ["npx", "vitest", "run", "--reporter=verbose"]) - - for vite_cfg in ("vite.config.ts", "vite.config.js"): - cfg_path = os.path.join(cwd, vite_cfg) - if os.path.isfile(cfg_path): - try: - with open(cfg_path, "r", encoding="utf-8") as f: - if "test" in f.read(): - return ( - "vitest", - ["npx", "vitest", "run", "--reporter=verbose"], - ) - except OSError: - pass - - # --- JavaScript: jest --- - for name in entries: - if name.startswith("jest.config"): - return ("jest", ["npx", "jest", "--verbose"]) - - pkg_json = os.path.join(cwd, "package.json") - if os.path.isfile(pkg_json): - try: - with open(pkg_json, "r", encoding="utf-8") as f: - pkg = json.loads(f.read()) - - if "jest" in pkg: - return ("jest", ["npx", "jest", "--verbose"]) - - dev_deps = pkg.get("devDependencies", {}) - deps = pkg.get("dependencies", {}) - - if "mocha" in dev_deps or "mocha" in deps: - return ("mocha", ["npx", "mocha", "--reporter", "spec"]) - - test_script = pkg.get("scripts", {}).get("test", "") - if test_script and "no test specified" not in test_script: - return ("npm-test", ["npm", "test"]) - except (OSError, json.JSONDecodeError): - pass - - # --- Go --- - if "go.mod" in entries: - return ("go", ["go", "test", "-count=1"]) - - # --- Rust --- - if "Cargo.toml" in entries: - return ("cargo", ["cargo", "test"]) - - return ("", []) - - -def resolve_pytest_tests(edited_files: list[str], cwd: str) -> tuple[list[str], bool]: - """Map edited Python files to their corresponding pytest test files. - - Returns: - (test_files, run_all) — if run_all is True, run the whole suite - (e.g. conftest.py was edited). - """ - test_files: list[str] = [] - - for path in edited_files: - if not path.endswith(".py"): - continue - - basename = os.path.basename(path) - - # conftest changes can affect anything — run full suite - if basename == "conftest.py": - return ([], True) - - # Already a test file — include directly - if basename.startswith("test_") or "/tests/" in path: - if os.path.isfile(path): - test_files.append(path) - continue - - # Map source → test via directory mirroring - # e.g. src/engine/db/sessions.py → tests/engine/db/test_sessions.py - # e.g. src/engine/api/routes/github.py → tests/engine/api/test_routes_github.py - rel = os.path.relpath(path, cwd) - parts = rel.split(os.sep) - - # Strip leading "src/" if present - if parts and parts[0] == "src": - parts = parts[1:] - - if not parts: - continue - - module = parts[-1] # e.g. "sessions.py" - module_name = module.removesuffix(".py") - parent_parts = parts[:-1] # e.g. ["engine", "db"] - - # Standard mapping: tests//test_.py - test_path = os.path.join(cwd, "tests", *parent_parts, f"test_{module_name}.py") - if os.path.isfile(test_path): - test_files.append(test_path) - continue - - # Routes mapping: src/engine/api/routes/github.py - # → tests/engine/api/test_routes_github.py - if len(parent_parts) >= 2 and parent_parts[-1] == "routes": - route_test = os.path.join( - cwd, - "tests", - *parent_parts[:-1], - f"test_routes_{module_name}.py", - ) - if os.path.isfile(route_test): - test_files.append(route_test) - - # Deduplicate while preserving order - seen: set[str] = set() - unique: list[str] = [] - for t in test_files: - if t not in seen: - seen.add(t) - unique.append(t) - - return (unique, False) - - -def resolve_affected_tests( - edited_files: list[str], cwd: str, framework: str -) -> tuple[list[str], bool]: - """Resolve edited files to framework-specific test arguments. - - Returns: - (extra_args, run_all) — extra_args to append to the base command. - If run_all is True, run the whole suite (no extra args needed). - If extra_args is empty and run_all is False, skip testing entirely. - """ - if framework == "pytest": - test_files, run_all = resolve_pytest_tests(edited_files, cwd) - return (test_files, run_all) - - if framework == "vitest": - # vitest --related does dep-graph analysis natively - source_files = [ - f - for f in edited_files - if not f.endswith( - (".md", ".json", ".yaml", ".yml", ".toml", ".txt", ".css") - ) - ] - if not source_files: - return ([], False) - return (["--related"] + source_files, False) - - if framework == "jest": - source_files = [ - f - for f in edited_files - if not f.endswith( - (".md", ".json", ".yaml", ".yml", ".toml", ".txt", ".css") - ) - ] - if not source_files: - return ([], False) - return (["--findRelatedTests"] + source_files, False) - - if framework == "go": - # Map edited .go files to their package directories - pkgs: set[str] = set() - for path in edited_files: - if path.endswith(".go"): - pkg_dir = os.path.dirname(path) - rel = os.path.relpath(pkg_dir, cwd) - pkgs.add(f"./{rel}") - if not pkgs: - return ([], False) - return (sorted(pkgs), False) - - # cargo, mocha, npm-test — no granular selection, run full suite - code_files = [ - f - for f in edited_files - if not f.endswith((".md", ".json", ".yaml", ".yml", ".toml", ".txt")) - ] - if not code_files: - return ([], False) - return ([], True) - - -def main(): - try: - input_data = json.load(sys.stdin) - except (json.JSONDecodeError, ValueError): - sys.exit(0) - - # Skip if another Stop hook is already blocking - if input_data.get("stop_hook_active"): - sys.exit(0) - - session_id = input_data.get("session_id", "") - if not session_id: - sys.exit(0) - - # No files edited this session — nothing to test - edited_files = get_edited_files(session_id) - if not edited_files: - sys.exit(0) - - cwd = os.getcwd() - framework, base_cmd = detect_test_framework(cwd) - - if not framework: - sys.exit(0) - - extra_args, run_all = resolve_affected_tests(edited_files, cwd, framework) - - # No affected tests and not a run-all situation — skip - if not extra_args and not run_all: - sys.exit(0) - - cmd = base_cmd + extra_args - - try: - result = subprocess.run( - cmd, - cwd=cwd, - capture_output=True, - text=True, - timeout=TIMEOUT_SECONDS, - ) - except subprocess.TimeoutExpired: - json.dump( - { - "systemMessage": f"[Tests] {framework} timed out after {TIMEOUT_SECONDS}s" - }, - sys.stdout, - ) - sys.exit(0) - except (FileNotFoundError, OSError): - sys.exit(0) - - output = (result.stdout + "\n" + result.stderr).strip() - - if result.returncode == 0: - json.dump( - {"systemMessage": f"[Tests] All tests passed ({framework})"}, - sys.stdout, - ) - sys.exit(0) - - # Tests failed — truncate to last 30 lines - if not output: - output = "(no test output)" - - lines = output.splitlines() - if len(lines) > 30: - output = "...(truncated)\n" + "\n".join(lines[-30:]) - - json.dump( - { - "decision": "block", - "reason": f"[Tests] Some tests FAILED ({framework}):\n{output}", - }, - sys.stdout, - ) - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/format-on-stop.py b/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/format-on-stop.py deleted file mode 100644 index 63d833c..0000000 --- a/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/format-on-stop.py +++ /dev/null @@ -1,304 +0,0 @@ -#!/usr/bin/env python3 -""" -Unified batch formatter — runs as a Stop hook. - -Reads file paths collected by collect-edited-files.py during the -conversation turn, deduplicates them, and formats each based on -extension: - .py / .pyi → Ruff format (fallback: Black) - .go → gofmt - .js/.jsx/.ts/.tsx/.mjs/.cjs/.mts/.cts → Biome check --write - .css/.json/.jsonc/.graphql/.gql → Biome check --write - .html/.vue/.svelte/.astro → Biome check --write - .sh/.bash/.zsh/.mksh/.bats → shfmt -w - .md/.markdown → dprint fmt - .yaml/.yml → dprint fmt - .toml → dprint fmt - Dockerfile / .dockerfile → dprint fmt - .rs → rustfmt - -Always cleans up the temp file. Always exits 0. -""" - -import json -import os -import subprocess -import sys -from pathlib import Path - -# Hook gate — check ~/.claude/disabled-hooks.json -_dh = os.path.join(os.path.expanduser("~"), ".claude", "disabled-hooks.json") -if os.path.exists(_dh): - with open(_dh) as _f: - if os.path.basename(__file__).replace(".py", "") in json.load(_f).get("disabled", []): - sys.exit(0) - -# ── Extension sets ────────────────────────────────────────────────── - -PYTHON_EXTS = {".py", ".pyi"} -GO_EXTS = {".go"} -BIOME_EXTS = { - ".js", - ".jsx", - ".ts", - ".tsx", - ".mjs", - ".cjs", - ".mts", - ".cts", - ".css", - ".json", - ".jsonc", - ".graphql", - ".gql", - ".html", - ".vue", - ".svelte", - ".astro", -} -SHELL_EXTS = {".sh", ".bash", ".zsh", ".mksh", ".bats"} -DPRINT_EXTS = {".md", ".markdown", ".yaml", ".yml", ".toml"} -RUST_EXTS = {".rs"} - -# ── Fallback paths ────────────────────────────────────────────────── - -BLACK_PATH_FALLBACK = "/usr/local/py-utils/bin/black" -GOFMT_PATH_FALLBACK = "/usr/local/go/bin/gofmt" -DPRINT_CONFIG = "/usr/local/share/dprint/dprint.json" - -# ── Tool resolution ───────────────────────────────────────────────── - - -def _resolve_tool(name: str, fallback: str = "") -> str | None: - """Find tool via PATH first, fall back to hardcoded path.""" - try: - result = subprocess.run(["which", name], capture_output=True, text=True) - if result.returncode == 0: - return result.stdout.strip() - except Exception: - pass - if fallback and os.path.exists(fallback): - return fallback - return None - - -def find_tool_upward(file_path: str, tool_name: str) -> str | None: - """Walk up from file directory looking for node_modules/.bin/.""" - current = Path(file_path).parent - for _ in range(20): - candidate = current / "node_modules" / ".bin" / tool_name - if candidate.is_file(): - return str(candidate) - parent = current.parent - if parent == current: - break - current = parent - return None - - -def find_global_tool(tool_name: str) -> str | None: - """Check if tool is available globally.""" - try: - result = subprocess.run( - ["which", tool_name], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return result.stdout.strip() - except Exception: - pass - return None - - -def find_biome(file_path: str) -> str | None: - """Find biome binary: project-local first, then global.""" - local = find_tool_upward(file_path, "biome") - if local: - return local - return find_global_tool("biome") - - -# ── Formatters ────────────────────────────────────────────────────── - - -def format_python(file_path: str) -> None: - """Format with Ruff (preferred) or Black (fallback).""" - ruff = _resolve_tool("ruff") - if ruff: - try: - subprocess.run( - [ruff, "format", "--quiet", file_path], - capture_output=True, - timeout=10, - ) - return - except (subprocess.TimeoutExpired, OSError): - pass - - # Fallback to Black - black = _resolve_tool("black", BLACK_PATH_FALLBACK) - if not black: - return - try: - subprocess.run( - [black, "--quiet", file_path], - capture_output=True, - timeout=10, - ) - except (subprocess.TimeoutExpired, OSError): - pass - - -def format_go(file_path: str) -> None: - """Format with gofmt.""" - gofmt = _resolve_tool("gofmt", GOFMT_PATH_FALLBACK) - if not gofmt: - return - try: - subprocess.run( - [gofmt, "-w", file_path], - capture_output=True, - timeout=10, - ) - except (subprocess.TimeoutExpired, OSError): - pass - - -def format_biome(file_path: str) -> None: - """Format with Biome in safe mode (no --unsafe).""" - biome = find_biome(file_path) - if not biome: - return - try: - subprocess.run( - [biome, "check", "--write", file_path], - capture_output=True, - timeout=12, - ) - except (subprocess.TimeoutExpired, OSError): - pass - - -def format_shell(file_path: str) -> None: - """Format with shfmt.""" - shfmt = _resolve_tool("shfmt") - if not shfmt: - return - try: - subprocess.run( - [shfmt, "-w", file_path], - capture_output=True, - timeout=10, - ) - except (subprocess.TimeoutExpired, OSError): - pass - - -def format_dprint(file_path: str) -> None: - """Format with dprint using the global config.""" - dprint = _resolve_tool("dprint") - if not dprint: - return - if not os.path.isfile(DPRINT_CONFIG): - return - try: - subprocess.run( - [dprint, "fmt", "--config", DPRINT_CONFIG, file_path], - capture_output=True, - timeout=10, - ) - except (subprocess.TimeoutExpired, OSError): - pass - - -def format_rust(file_path: str) -> None: - """Format with rustfmt (conditional — only if installed).""" - rustfmt = _resolve_tool("rustfmt") - if not rustfmt: - return - try: - subprocess.run( - [rustfmt, file_path], - capture_output=True, - timeout=10, - ) - except (subprocess.TimeoutExpired, OSError): - pass - - -# ── Dispatch ──────────────────────────────────────────────────────── - - -def format_file(file_path: str) -> None: - """Dispatch to the correct formatter based on extension / filename.""" - path = Path(file_path) - ext = path.suffix.lower() - name = path.name - - if ext in PYTHON_EXTS: - format_python(file_path) - elif ext in GO_EXTS: - format_go(file_path) - elif ext in BIOME_EXTS: - format_biome(file_path) - elif ext in SHELL_EXTS: - format_shell(file_path) - elif ext in DPRINT_EXTS: - format_dprint(file_path) - elif ext in RUST_EXTS: - format_rust(file_path) - elif name == "Dockerfile" or ext == ".dockerfile": - format_dprint(file_path) - - -# ── Main ──────────────────────────────────────────────────────────── - - -def main(): - try: - input_data = json.load(sys.stdin) - except (json.JSONDecodeError, ValueError): - sys.exit(0) - - # Prevent infinite loops if Stop hook triggers another stop - if input_data.get("stop_hook_active"): - sys.exit(0) - - session_id = input_data.get("session_id", "") - if not session_id: - sys.exit(0) - - tmp_path = f"/tmp/claude-cq-edited-{session_id}" - - try: - with open(tmp_path) as f: - raw_paths = f.read().splitlines() - except FileNotFoundError: - sys.exit(0) - except OSError: - sys.exit(0) - finally: - # Always clean up the temp file - try: - os.unlink(tmp_path) - except OSError: - pass - - # Deduplicate while preserving order, filter to existing files - seen: set[str] = set() - paths: list[str] = [] - for p in raw_paths: - p = p.strip() - if p and p not in seen and os.path.isfile(p): - seen.add(p) - paths.append(p) - - for path in paths: - format_file(path) - - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/lint-file.py b/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/lint-file.py deleted file mode 100644 index 905cfbc..0000000 --- a/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/lint-file.py +++ /dev/null @@ -1,543 +0,0 @@ -#!/usr/bin/env python3 -""" -Batch linter — runs as a Stop hook. - -Reads file paths collected by collect-edited-files.py during the -conversation turn, deduplicates them, and lints each based on -extension: - .py / .pyi → Pyright (type checking) + Ruff check (style/correctness) - .js/.jsx/.ts/… → Biome lint - .css/.graphql/… → Biome lint - .sh/.bash/.zsh/… → ShellCheck - .go → go vet - Dockerfile → hadolint - .rs → clippy (conditional) - -Outputs JSON with additionalContext containing lint warnings. -Always cleans up the temp file. Always exits 0. -""" - -import json -import os -import subprocess -import sys -from pathlib import Path - -# Hook gate — check ~/.claude/disabled-hooks.json -_dh = os.path.join(os.path.expanduser("~"), ".claude", "disabled-hooks.json") -if os.path.exists(_dh): - with open(_dh) as _f: - if os.path.basename(__file__).replace(".py", "") in json.load(_f).get("disabled", []): - sys.exit(0) - -# ── Extension sets ────────────────────────────────────────────────── - -PYTHON_EXTS = {".py", ".pyi"} -BIOME_EXTS = { - ".js", - ".jsx", - ".ts", - ".tsx", - ".mjs", - ".cjs", - ".mts", - ".cts", - ".css", - ".graphql", - ".gql", -} -SHELL_EXTS = {".sh", ".bash", ".zsh", ".mksh", ".bats"} -GO_EXTS = {".go"} -RUST_EXTS = {".rs"} - -SUBPROCESS_TIMEOUT = 10 - - -# ── Tool resolution ───────────────────────────────────────────────── - - -def _which(name: str) -> str | None: - """Check if a tool is available in PATH.""" - try: - result = subprocess.run(["which", name], capture_output=True, text=True) - if result.returncode == 0: - return result.stdout.strip() - except Exception: - pass - return None - - -def _find_tool_upward(file_path: str, tool_name: str) -> str | None: - """Walk up from file directory looking for node_modules/.bin/.""" - current = Path(file_path).parent - for _ in range(20): - candidate = current / "node_modules" / ".bin" / tool_name - if candidate.is_file(): - return str(candidate) - parent = current.parent - if parent == current: - break - current = parent - return None - - -def _find_biome(file_path: str) -> str | None: - """Find biome binary: project-local first, then global.""" - local = _find_tool_upward(file_path, "biome") - if local: - return local - return _which("biome") - - -# ── Diagnostic formatting ────────────────────────────────────────── - - -def _format_issues(filename: str, diagnostics: list[dict]) -> str: - """Format a list of {severity, line, message} dicts into display text.""" - if not diagnostics: - return "" - - issues = [] - for diag in diagnostics[:5]: - severity = diag.get("severity", "info") - message = diag.get("message", "") - line = diag.get("line", 0) - - if severity == "error": - icon = "\u2717" - elif severity == "warning": - icon = "!" - else: - icon = "\u2022" - - issues.append(f" {icon} Line {line}: {message}") - - total = len(diagnostics) - shown = min(5, total) - header = f" {filename}: {total} issue(s)" - if total > shown: - header += f" (showing first {shown})" - - return header + "\n" + "\n".join(issues) - - -# ── Linters ───────────────────────────────────────────────────────── - - -def lint_python_pyright(file_path: str) -> str: - """Run Pyright type checker on a Python file.""" - pyright = _which("pyright") - if not pyright: - return "" - - try: - result = subprocess.run( - [pyright, "--outputjson", file_path], - capture_output=True, - text=True, - timeout=SUBPROCESS_TIMEOUT, - ) - - try: - output = json.loads(result.stdout) - except json.JSONDecodeError: - return "" - - diagnostics = output.get("generalDiagnostics", []) - if not diagnostics: - return "" - - parsed = [ - { - "severity": d.get("severity", "info"), - "line": d.get("range", {}).get("start", {}).get("line", 0) + 1, - "message": d.get("message", ""), - } - for d in diagnostics - ] - return _format_issues(Path(file_path).name, parsed) - - except subprocess.TimeoutExpired: - return f" {Path(file_path).name}: pyright timed out" - except Exception: - return "" - - -def lint_python_ruff(file_path: str) -> str: - """Run Ruff linter on a Python file.""" - ruff = _which("ruff") - if not ruff: - return "" - - try: - result = subprocess.run( - [ruff, "check", "--output-format=json", "--no-fix", file_path], - capture_output=True, - text=True, - timeout=SUBPROCESS_TIMEOUT, - ) - - try: - issues = json.loads(result.stdout) - except json.JSONDecodeError: - return "" - - if not issues: - return "" - - parsed = [ - { - "severity": "warning", - "line": issue.get("location", {}).get("row", 0), - "message": f"[{issue.get('code', '?')}] {issue.get('message', '')}", - } - for issue in issues - ] - return _format_issues(Path(file_path).name, parsed) - - except subprocess.TimeoutExpired: - return f" {Path(file_path).name}: ruff timed out" - except Exception: - return "" - - -def lint_biome(file_path: str) -> str: - """Run Biome linter for JS/TS/CSS/GraphQL files.""" - biome = _find_biome(file_path) - if not biome: - return "" - - try: - result = subprocess.run( - [biome, "lint", "--reporter=json", file_path], - capture_output=True, - text=True, - timeout=SUBPROCESS_TIMEOUT, - ) - - try: - output = json.loads(result.stdout) - except json.JSONDecodeError: - return "" - - diagnostics = output.get("diagnostics", []) - if not diagnostics: - return "" - - parsed = [ - { - "severity": d.get("severity", "warning"), - "line": ( - d.get("location", {}).get("span", {}).get("start", {}) - if isinstance( - d.get("location", {}).get("span", {}).get("start"), int - ) - else 0 - ), - "message": d.get("description", d.get("message", "")), - } - for d in diagnostics - ] - return _format_issues(Path(file_path).name, parsed) - - except subprocess.TimeoutExpired: - return f" {Path(file_path).name}: biome lint timed out" - except Exception: - return "" - - -def lint_shellcheck(file_path: str) -> str: - """Run ShellCheck on a shell script.""" - shellcheck = _which("shellcheck") - if not shellcheck: - return "" - - try: - result = subprocess.run( - [shellcheck, "--format=json", file_path], - capture_output=True, - text=True, - timeout=SUBPROCESS_TIMEOUT, - ) - - try: - issues = json.loads(result.stdout) - except json.JSONDecodeError: - return "" - - if not issues: - return "" - - severity_map = { - "error": "error", - "warning": "warning", - "info": "info", - "style": "info", - } - parsed = [ - { - "severity": severity_map.get(issue.get("level", "info"), "info"), - "line": issue.get("line", 0), - "message": f"[SC{issue.get('code', '?')}] {issue.get('message', '')}", - } - for issue in issues - ] - return _format_issues(Path(file_path).name, parsed) - - except subprocess.TimeoutExpired: - return f" {Path(file_path).name}: shellcheck timed out" - except Exception: - return "" - - -def lint_go_vet(file_path: str) -> str: - """Run go vet on a Go file.""" - go = _which("go") - if not go: - return "" - - try: - result = subprocess.run( - [go, "vet", file_path], - capture_output=True, - text=True, - timeout=SUBPROCESS_TIMEOUT, - ) - - # go vet outputs to stderr - output = result.stderr.strip() - if not output: - return "" - - lines = output.splitlines() - parsed = [] - for line in lines: - # Format: file.go:LINE:COL: message - parts = line.split(":", 3) - if len(parts) >= 4: - try: - line_num = int(parts[1]) - except ValueError: - line_num = 0 - parsed.append( - { - "severity": "warning", - "line": line_num, - "message": parts[3].strip(), - } - ) - elif line.strip(): - parsed.append( - { - "severity": "warning", - "line": 0, - "message": line.strip(), - } - ) - - return _format_issues(Path(file_path).name, parsed) - - except subprocess.TimeoutExpired: - return f" {Path(file_path).name}: go vet timed out" - except Exception: - return "" - - -def lint_hadolint(file_path: str) -> str: - """Run hadolint on a Dockerfile.""" - hadolint = _which("hadolint") - if not hadolint: - return "" - - try: - result = subprocess.run( - [hadolint, "--format", "json", file_path], - capture_output=True, - text=True, - timeout=SUBPROCESS_TIMEOUT, - ) - - try: - issues = json.loads(result.stdout) - except json.JSONDecodeError: - return "" - - if not issues: - return "" - - severity_map = { - "error": "error", - "warning": "warning", - "info": "info", - "style": "info", - } - parsed = [ - { - "severity": severity_map.get(issue.get("level", "info"), "info"), - "line": issue.get("line", 0), - "message": f"[{issue.get('code', '?')}] {issue.get('message', '')}", - } - for issue in issues - ] - return _format_issues(Path(file_path).name, parsed) - - except subprocess.TimeoutExpired: - return f" {Path(file_path).name}: hadolint timed out" - except Exception: - return "" - - -def lint_clippy(file_path: str) -> str: - """Run clippy on a Rust file (conditional — only if cargo is in PATH).""" - cargo = _which("cargo") - if not cargo: - return "" - - try: - result = subprocess.run( - [cargo, "clippy", "--message-format=json", "--", "-W", "clippy::all"], - capture_output=True, - text=True, - timeout=SUBPROCESS_TIMEOUT, - cwd=str(Path(file_path).parent), - ) - - lines = result.stdout.strip().splitlines() - parsed = [] - target_name = Path(file_path).name - - for line in lines: - try: - msg = json.loads(line) - except json.JSONDecodeError: - continue - - if msg.get("reason") != "compiler-message": - continue - inner = msg.get("message", {}) - level = inner.get("level", "") - if level not in ("warning", "error"): - continue - - # Match diagnostics to the target file - spans = inner.get("spans", []) - line_num = 0 - for span in spans: - if span.get("is_primary") and target_name in span.get("file_name", ""): - line_num = span.get("line_start", 0) - break - - if line_num or not spans: - parsed.append( - { - "severity": level, - "line": line_num, - "message": inner.get("message", ""), - } - ) - - return _format_issues(Path(file_path).name, parsed) - - except subprocess.TimeoutExpired: - return f" {Path(file_path).name}: clippy timed out" - except Exception: - return "" - - -# ── Main ──────────────────────────────────────────────────────────── - - -def main(): - try: - input_data = json.load(sys.stdin) - except (json.JSONDecodeError, ValueError): - sys.exit(0) - - if input_data.get("stop_hook_active"): - sys.exit(0) - - session_id = input_data.get("session_id", "") - if not session_id: - sys.exit(0) - - tmp_path = f"/tmp/claude-cq-lint-{session_id}" - - try: - with open(tmp_path) as f: - raw_paths = f.read().splitlines() - except FileNotFoundError: - sys.exit(0) - except OSError: - sys.exit(0) - finally: - try: - os.unlink(tmp_path) - except OSError: - pass - - # Deduplicate, filter to existing files - seen: set[str] = set() - paths: list[str] = [] - for p in raw_paths: - p = p.strip() - if p and p not in seen and os.path.isfile(p): - seen.add(p) - paths.append(p) - - if not paths: - sys.exit(0) - - # Collect results grouped by linter - all_results: dict[str, list[str]] = {} - - for path in paths: - ext = Path(path).suffix.lower() - name = Path(path).name - - if ext in PYTHON_EXTS: - msg = lint_python_pyright(path) - if msg: - all_results.setdefault("Pyright", []).append(msg) - msg = lint_python_ruff(path) - if msg: - all_results.setdefault("Ruff", []).append(msg) - - elif ext in BIOME_EXTS: - msg = lint_biome(path) - if msg: - all_results.setdefault("Biome", []).append(msg) - - elif ext in SHELL_EXTS: - msg = lint_shellcheck(path) - if msg: - all_results.setdefault("ShellCheck", []).append(msg) - - elif ext in GO_EXTS: - msg = lint_go_vet(path) - if msg: - all_results.setdefault("go vet", []).append(msg) - - elif name == "Dockerfile" or ext == ".dockerfile": - msg = lint_hadolint(path) - if msg: - all_results.setdefault("hadolint", []).append(msg) - - elif ext in RUST_EXTS: - msg = lint_clippy(path) - if msg: - all_results.setdefault("clippy", []).append(msg) - - if all_results: - sections = [] - for linter_name, results in all_results.items(): - sections.append( - f"[Auto-linter] {linter_name} results:\n" + "\n".join(results) - ) - output = "\n\n".join(sections) - print(json.dumps({"additionalContext": output})) - - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/quality-gate.py b/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/quality-gate.py new file mode 100644 index 0000000..15b9e5d --- /dev/null +++ b/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/quality-gate.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +Quality gate — lightweight Stop hook. + +Checks whether files were edited this session and whether background +tasks are still running. If edits exist and no tasks are active, +blocks the stop and tells Claude to run /cq. + +Always exits 0 (no block) or outputs a block decision. +Runs in <10ms (just file reads). +""" + +import json +import os +import sys + +# Hook gate — check ~/.claude/disabled-hooks.json +_dh = os.path.join(os.path.expanduser("~"), ".claude", "disabled-hooks.json") +if os.path.exists(_dh): + with open(_dh) as _f: + if os.path.basename(__file__).replace(".py", "") in json.load(_f).get( + "disabled", [] + ): + sys.exit(0) + + +def main(): + try: + input_data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + sys.exit(0) + + # Skip if another stop hook is already active (prevents re-entry) + if input_data.get("stop_hook_active"): + sys.exit(0) + + session_id = input_data.get("session_id", "") + if not session_id: + sys.exit(0) + + # Skip if background tasks are still running + tasks_file = f"/tmp/claude-active-tasks-{session_id}" + try: + with open(tasks_file) as f: + entries = [line.strip() for line in f if line.strip()] + if entries: + sys.exit(0) + except FileNotFoundError: + pass # No tasks file means no active tasks + except OSError: + pass + + # Check if any files were edited this session + edited_file = f"/tmp/claude-cq-edited-{session_id}" + try: + with open(edited_file) as f: + raw_paths = f.read().splitlines() + except FileNotFoundError: + sys.exit(0) + except OSError: + sys.exit(0) + + # Deduplicate and filter to existing files + seen: set[str] = set() + paths: list[str] = [] + for p in raw_paths: + p = p.strip() + if p and p not in seen and os.path.isfile(p): + seen.add(p) + paths.append(p) + + if not paths: + # Clean up empty/stale temp file + try: + os.unlink(edited_file) + except OSError: + pass + sys.exit(0) + + # Block and tell Claude to run /cq + # Delete temp files so the NEXT stop (after /cq runs) exits clean + for prefix in ("claude-cq-edited", "claude-cq-lint"): + try: + os.unlink(f"/tmp/{prefix}-{session_id}") + except OSError: + pass + + file_list = "\n".join(f" - {p}" for p in paths) + json.dump( + { + "decision": "block", + "reason": ( + "Files were edited this session. Run /cq to format, lint, " + "and test before completing.\n\n" + f"Edited files:\n{file_list}" + ), + }, + sys.stdout, + ) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/task-tracker.py b/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/task-tracker.py new file mode 100644 index 0000000..a121600 --- /dev/null +++ b/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/scripts/task-tracker.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +""" +Task lifecycle tracker — handles TaskCreated and TaskCompleted hooks. + +Maintains a session-scoped file listing active background tasks. +The quality-gate Stop hook reads this file to skip blocking when +tasks are still running. + +Always exits 0. +""" + +import json +import os +import sys + +# Hook gate — check ~/.claude/disabled-hooks.json +_dh = os.path.join(os.path.expanduser("~"), ".claude", "disabled-hooks.json") +if os.path.exists(_dh): + with open(_dh) as _f: + if os.path.basename(__file__).replace(".py", "") in json.load(_f).get( + "disabled", [] + ): + sys.exit(0) + + +def main(): + try: + input_data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + sys.exit(0) + + session_id = input_data.get("session_id", "") + if not session_id: + sys.exit(0) + + event = input_data.get("hook_event_name", "") + task_id = input_data.get("task_id", "") or input_data.get("id", "") or "unknown" + tasks_file = f"/tmp/claude-active-tasks-{session_id}" + + if event == "TaskCreated": + try: + with open(tasks_file, "a") as f: + f.write(task_id + "\n") + except OSError: + pass + + elif event == "TaskCompleted": + try: + with open(tasks_file) as f: + lines = [line.strip() for line in f if line.strip()] + except FileNotFoundError: + sys.exit(0) + except OSError: + sys.exit(0) + + # Remove first occurrence of this task ID + try: + lines.remove(task_id) + except ValueError: + pass # Task ID not found — may have been cleaned up + + if lines: + try: + with open(tasks_file, "w") as f: + f.write("\n".join(lines) + "\n") + except OSError: + pass + else: + # No more active tasks — clean up + try: + os.unlink(tasks_file) + except OSError: + pass + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/skills/cq/SKILL.md b/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/skills/cq/SKILL.md new file mode 100644 index 0000000..b4e4dfd --- /dev/null +++ b/container/.devcontainer/plugins/devs-marketplace/plugins/auto-code-quality/skills/cq/SKILL.md @@ -0,0 +1,117 @@ +--- +name: cq +description: "Run code quality checks: format, lint (with auto-fix), and test all files edited this session. Use after editing code to ensure quality before committing." +allowed-tools: Bash Read Glob Grep +--- + +# Code Quality (`/cq`) + +Run formatting, linting, and tests on all files edited this session. + +## Step 1: Gather edited files + +Read the edited file lists: +- `/tmp/claude-cq-edited-{session_id}` — files to format and test +- `/tmp/claude-cq-lint-{session_id}` — files to lint + +Where `{session_id}` is your current session ID (from the `SESSION_ID` environment variable, or extract from the temp files in `/tmp/claude-cq-*`). + +If neither file exists, report "No files edited this session" and stop. + +Deduplicate paths and filter out files that no longer exist on disk. + +## Step 2: Format + +Format each file based on its extension. Skip any tool that isn't installed — don't error on missing tools. + +| Extension | Tool | Command | +|-----------|------|---------| +| `.py`, `.pyi` | ruff | `ruff format ` | +| `.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.cjs`, `.mts`, `.cts`, `.css`, `.json`, `.jsonc`, `.graphql`, `.gql`, `.html`, `.vue`, `.svelte`, `.astro` | biome | ` check --write ` | +| `.go` | gofmt | `gofmt -w ` | +| `.sh`, `.bash`, `.zsh`, `.mksh`, `.bats` | shfmt | `shfmt -w ` | +| `.md`, `.markdown`, `.yaml`, `.yml`, `.toml`, `Dockerfile`, `.dockerfile` | dprint | `dprint fmt --config /usr/local/share/dprint/dprint.json ` | +| `.rs` | rustfmt | `rustfmt ` | + +### Biome resolution + +Find biome in this order: +1. Walk up from the file's directory looking for `node_modules/.bin/biome` +2. Fall back to `which biome` + +If neither exists, skip biome-formatted files silently. + +### dprint config + +Only run dprint if `/usr/local/share/dprint/dprint.json` exists. Skip otherwise. + +## Step 3: Lint with auto-fix + +Run linters on each file. Apply safe auto-fixes where the tool supports it, then report remaining issues. + +| Extension | Tool | Command | Auto-fix? | +|-----------|------|---------|-----------| +| `.py`, `.pyi` | ruff | `ruff check --fix ` | Yes (safe fixes only) | +| `.py`, `.pyi` | pyright | `pyright ` | No (report only) | +| `.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.cjs`, `.mts`, `.cts`, `.css`, `.graphql`, `.gql` | biome | `biome lint ` | No (report remaining after format step) | +| `.sh`, `.bash`, `.zsh`, `.mksh`, `.bats` | shellcheck | `shellcheck ` | No (report only) | +| `.go` | go vet | `go vet ` | No (report only) | +| `Dockerfile`, `.dockerfile` | hadolint | `hadolint ` | No (report only) | +| `.rs` | clippy | `cargo clippy` | No (report only, no `--fix`) | + +Skip any tool that isn't installed. Don't treat missing tools as errors. + +## Step 4: Run affected tests + +Detect the project's test framework and run only tests affected by the edited files: + +1. **pytest** (Python) — look for `pytest.ini`, `conftest.py`, `[tool.pytest` in pyproject.toml, or a `tests/` directory. Map source files to test files: + - `src/foo/bar.py` → `tests/foo/test_bar.py` + - Test files (`test_*.py`) run directly + - `conftest.py` edits → run full suite + - Command: `python3 -m pytest --tb=short -q ` + +2. **vitest** (JS/TS) — look for `vitest.config.*`. Command: `npx vitest run --reporter=verbose --related ` + +3. **jest** (JS/TS) — look for `jest.config.*` or `"jest"` in package.json. Command: `npx jest --verbose --findRelatedTests ` + +4. **go test** — look for `go.mod`. Map edited `.go` files to package dirs. Command: `go test -count=1 ` + +5. **cargo test** (Rust) — look for `Cargo.toml`. Command: `cargo test` + +6. **npm test** — fallback if package.json has a `test` script. Command: `npm test` + +If no test framework is detected, skip testing and note it in the report. + +## Step 5: Fix remaining issues + +If linting or tests surfaced fixable issues: +- Review each error/warning +- Fix what you can directly (edit the source files) +- Re-run the specific linter or test to confirm the fix + +Don't loop more than once — fix, verify, move on. + +## Step 6: Clean up + +Delete the temp files: +- `/tmp/claude-cq-edited-{session_id}` +- `/tmp/claude-cq-lint-{session_id}` + +This ensures the quality gate Stop hook won't re-trigger on the next stop. + +## Step 7: Report + +Provide a concise summary: + +``` +## Code Quality Results + +**Formatted:** files () +**Lint:** +**Tests:** + +[Details of any remaining issues that need attention] +``` + +If everything is clean, keep it to one line: "All files formatted, lint clean, tests passed." From d9a62b5d39693ba2230b58570d9c2cd522a41ae1 Mon Sep 17 00:00:00 2001 From: AnExiledDev <696222+AnExiledDev@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:30:22 +0000 Subject: [PATCH 2/6] refactor(agent-system): replace test-verification hooks with /verify-tests skill The TaskCompleted, implementer Stop, refactorer PostToolUse, and test-writer Stop hooks each ran the project test suite automatically on agent/task lifecycle events, serializing 30-120s of verification work per event. Replace all four with a single on-demand /verify-tests skill that Claude invokes explicitly. Keeps test-running behaviour available without forcing it on every stop or completion, aligning with the same pattern used by /cq. --- container/.devcontainer/AGENTS.md | 2 +- container/.devcontainer/CHANGELOG.md | 1 + .../.claude-plugin/marketplace.json | 2 +- .../agent-system/.claude-plugin/plugin.json | 2 +- .../plugins/agent-system/README.md | 33 ++- .../plugins/agent-system/hooks/hooks.json | 13 +- .../scripts/task-completed-check.py | 173 ------------- .../scripts/verify-no-regression.py | 232 ------------------ .../agent-system/scripts/verify-tests-pass.py | 171 ------------- .../agent-system/skills/verify-tests/SKILL.md | 58 +++++ 10 files changed, 77 insertions(+), 610 deletions(-) delete mode 100644 container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/task-completed-check.py delete mode 100644 container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/verify-no-regression.py delete mode 100644 container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/verify-tests-pass.py create mode 100644 container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/skills/verify-tests/SKILL.md diff --git a/container/.devcontainer/AGENTS.md b/container/.devcontainer/AGENTS.md index b938374..b732fef 100644 --- a/container/.devcontainer/AGENTS.md +++ b/container/.devcontainer/AGENTS.md @@ -48,7 +48,7 @@ Declared in `settings.json` under `enabledPlugins`, auto-activated on start: ### Active -- **agent-system** — 4 custom agents (architect, claude-guide, explorer, generalist) + built-in agent redirection +- **agent-system** — 4 custom agents (architect, claude-guide, explorer, generalist) + built-in agent redirection + `/verify-tests` skill - **skill-engine** — 2 coding knowledge packs (`/team`, `/agent-browser`) + auto-suggestion - **auto-code-quality** — File tracking, syntax validation, `/cq` quality gate (format + lint + test on demand) - **session-context** — Git state injection, TODO harvesting, commit reminders diff --git a/container/.devcontainer/CHANGELOG.md b/container/.devcontainer/CHANGELOG.md index 3e0e0f1..f2da209 100644 --- a/container/.devcontainer/CHANGELOG.md +++ b/container/.devcontainer/CHANGELOG.md @@ -53,6 +53,7 @@ ### Agent System - **All agents and skills set to `effort: max`** — active agents (claude-guide, explorer, generalist), all 14 archived agents in `agent-system/agents/_archived/`, both active skill-engine skills (`team`, `agent-browser`), all 22 archived skills across `skill-engine` and `agent-system`, and all 10 skills in currently disabled plugins (`git-workflow`, `prompt-snippets`, `spec-workflow`, `ticket-workflow`) now declare `effort: max`. This ensures any plugin re-enable in the future yields max-effort runs without further edits. +- **Replaced automatic test hooks with `/verify-tests` skill** — removed `TaskCompleted` hook (`task-completed-check.py`), implementer Stop hook (`verify-no-regression.py`), refactorer PostToolUse hook (`verify-no-regression.py`), and test-writer Stop hook (`verify-tests-pass.py`). Test verification is now on-demand via `/verify-tests`, which detects the project's test framework and runs the suite with structured reporting. ### Plugin Cleanup diff --git a/container/.devcontainer/plugins/devs-marketplace/.claude-plugin/marketplace.json b/container/.devcontainer/plugins/devs-marketplace/.claude-plugin/marketplace.json index d764705..45cb78f 100644 --- a/container/.devcontainer/plugins/devs-marketplace/.claude-plugin/marketplace.json +++ b/container/.devcontainer/plugins/devs-marketplace/.claude-plugin/marketplace.json @@ -52,7 +52,7 @@ }, { "name": "agent-system", - "description": "4 custom agents with built-in agent redirection (15 archived for rewrite)", + "description": "4 custom agents with built-in agent redirection and /verify-tests skill (15 archived for rewrite)", "version": "1.0.0", "source": "./plugins/agent-system", "category": "development", diff --git a/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/.claude-plugin/plugin.json b/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/.claude-plugin/plugin.json index 8ddff18..f2d45b3 100644 --- a/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/.claude-plugin/plugin.json +++ b/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-system", - "description": "4 custom agents with built-in agent redirection (15 archived for rewrite)", + "description": "4 custom agents with built-in agent redirection and /verify-tests skill (15 archived for rewrite)", "author": { "name": "AnExiledDev" } diff --git a/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/README.md b/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/README.md index 4e2e11f..78312a9 100644 --- a/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/README.md +++ b/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/README.md @@ -1,10 +1,10 @@ # agent-system -Claude Code plugin that provides 4 custom agents with automatic built-in agent redirection, working directory injection, read-only bash enforcement, and team quality gates. 15 additional agents are archived in `agents/_archived/` pending rewrite. +Claude Code plugin that provides 4 custom agents with automatic built-in agent redirection, working directory injection, read-only bash enforcement, `/verify-tests` skill, and team quality gates. 15 additional agents are archived in `agents/_archived/` pending rewrite. ## What It Does -Replaces Claude Code's built-in agents with enhanced custom agents that carry domain-specific instructions, safety hooks, and tailored tool configurations. Also provides team orchestration quality gates. +Replaces Claude Code's built-in agents with enhanced custom agents that carry domain-specific instructions, safety hooks, and tailored tool configurations. Includes `/verify-tests` for on-demand test suite execution. ### Active Agents @@ -32,12 +32,18 @@ The following agents are preserved in `agents/_archived/` for future rewrite: bash-exec, debug-logs, dependency-analyst, documenter, git-archaeologist, implementer, investigator, migrator, perf-profiler, refactorer, researcher, security-auditor, spec-writer, statusline-config, test-writer -### Quality Gates +### Skills + +| Skill | Purpose | +|-------|---------| +| `/debug` | Structured log investigation and diagnosis | +| `/verify-tests` | Run project test suite, report results, fix failures | + +### Orchestration Hooks | Hook | Script | Purpose | |------|--------|---------| | TeammateIdle | `teammate-idle-check.py` | Prevents teammates from going idle with incomplete tasks | -| TaskCompleted | `task-completed-check.py` | Runs test suite before allowing task completion | ## How It Works @@ -55,14 +61,6 @@ Claude calls the Task tool (spawning a subagent) | +-> Subagent works... | - +-> TaskCompleted fires - | | - | +-> task-completed-check.py - | | - | +-> Detect test framework -> Run tests - | +-> Tests pass? -> Allow completion - | +-> Tests fail? -> Block, send feedback - | +-> TeammateIdle fires (team mode) | +-> teammate-idle-check.py @@ -86,7 +84,6 @@ Read-only agents (explorer, architect) have their Bash access restricted by `gua |--------|--------|--------| | redirect-builtin-agents.py | Allow (or rewrite) | Block with error | | guard-readonly-bash.py | Allow command | Block write operation | -| task-completed-check.py | Tests pass | Tests fail (block completion) | | teammate-idle-check.py | No incomplete tasks | Has incomplete tasks | ### Timeouts @@ -95,7 +92,6 @@ Read-only agents (explorer, architect) have their Bash access restricted by `gua |------|---------| | Agent redirection (PreToolUse) | 5s | | Teammate idle check | 10s | -| Task completed check | 60s | ## Installation @@ -157,14 +153,13 @@ agent-system/ +-- scripts/ | +-- guard-readonly-bash.py # Read-only bash enforcement | +-- redirect-builtin-agents.py # Built-in agent redirection -| +-- task-completed-check.py # Test suite quality gate | +-- teammate-idle-check.py # Incomplete task checker -| +-- verify-no-regression.py # Post-edit regression tests (dormant — agents archived) -| +-- verify-tests-pass.py # Test verification (dormant — agents archived) +-- skills/ | +-- _archived/ -| +-- debug/ -| +-- SKILL.md # Log investigation skill (archived) +| | +-- debug/ +| | +-- SKILL.md # Log investigation skill (archived) +| +-- verify-tests/ +| +-- SKILL.md # On-demand test suite runner +-- AGENT-REDIRECTION.md # Redirection mechanism docs +-- REVIEW-RUBRIC.md # Agent/skill quality rubric +-- README.md # This file diff --git a/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/hooks/hooks.json b/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/hooks/hooks.json index 876b379..97c321b 100644 --- a/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/hooks/hooks.json +++ b/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Agent redirection, subagent configuration, and team quality gate hooks", + "description": "Agent redirection and team orchestration hooks", "hooks": { "PreToolUse": [ { @@ -23,17 +23,6 @@ } ] } - ], - "TaskCompleted": [ - { - "hooks": [ - { - "type": "command", - "command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/task-completed-check.py", - "timeout": 60 - } - ] - } ] } } diff --git a/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/task-completed-check.py b/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/task-completed-check.py deleted file mode 100644 index 6c449be..0000000 --- a/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/task-completed-check.py +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env python3 -""" -TaskCompleted quality gate — runs project test suite before allowing completion. - -Detects the project's test framework and runs it. If tests fail, the task -stays open and the teammate receives feedback to fix the failures. - -Exit 0: Tests pass (or no test framework / runner not installed) -Exit 2: Tests fail (task stays open, feedback sent via stderr) -""" - -import json -import os -import subprocess -import sys - -# Hook gate — check .codeforge/config/disabled-hooks.json -_dh = os.path.join(os.getcwd(), ".codeforge", "config", "disabled-hooks.json") -if os.path.exists(_dh): - with open(_dh) as _f: - if os.path.basename(__file__).replace(".py", "") in json.load(_f).get("disabled", []): - sys.exit(0) - -TIMEOUT_SECONDS = 60 - - -def detect_test_framework(cwd: str) -> tuple[str, list[str]]: - """Detect which test framework is available in the project. - - Checks for: pytest, vitest, jest, mocha, go test, cargo test. - Falls back to npm test if a test script is defined. - - Returns: - Tuple of (framework_name, command_list) or ("", []) if none found. - """ - try: - entries = set(os.listdir(cwd)) - except OSError: - return ("", []) - - # Python: pytest - if "pytest.ini" in entries or "conftest.py" in entries: - return ("pytest", ["python3", "-m", "pytest", "--tb=short", "-q"]) - - for cfg_name in ("pyproject.toml", "setup.cfg", "tox.ini"): - cfg_path = os.path.join(cwd, cfg_name) - if os.path.isfile(cfg_path): - try: - with open(cfg_path, "r", encoding="utf-8") as f: - content = f.read() - if any( - marker in content - for marker in ("[tool.pytest", "[pytest]", "[tool:pytest]") - ): - return ("pytest", ["python3", "-m", "pytest", "--tb=short", "-q"]) - except OSError: - pass - - if "tests" in entries and os.path.isdir(os.path.join(cwd, "tests")): - return ("pytest", ["python3", "-m", "pytest", "--tb=short", "-q"]) - - for entry in entries: - if entry.startswith("test_") and entry.endswith(".py"): - return ("pytest", ["python3", "-m", "pytest", "--tb=short", "-q"]) - - # JavaScript: vitest - for name in entries: - if name.startswith("vitest.config"): - return ("vitest", ["npx", "vitest", "run", "--reporter=verbose"]) - - for vite_cfg in ("vite.config.ts", "vite.config.js"): - cfg_path = os.path.join(cwd, vite_cfg) - if os.path.isfile(cfg_path): - try: - with open(cfg_path, "r", encoding="utf-8") as f: - if "test" in f.read(): - return ( - "vitest", - ["npx", "vitest", "run", "--reporter=verbose"], - ) - except OSError: - pass - - # JavaScript: jest - for name in entries: - if name.startswith("jest.config"): - return ("jest", ["npx", "jest", "--verbose"]) - - pkg_json = os.path.join(cwd, "package.json") - if os.path.isfile(pkg_json): - try: - with open(pkg_json, "r", encoding="utf-8") as f: - pkg = json.loads(f.read()) - - if "jest" in pkg: - return ("jest", ["npx", "jest", "--verbose"]) - - dev_deps = pkg.get("devDependencies", {}) - deps = pkg.get("dependencies", {}) - - if "mocha" in dev_deps or "mocha" in deps: - return ("mocha", ["npx", "mocha", "--reporter", "spec"]) - - test_script = pkg.get("scripts", {}).get("test", "") - if test_script and "no test specified" not in test_script: - return ("npm-test", ["npm", "test"]) - except (OSError, json.JSONDecodeError): - pass - - # Go - if "go.mod" in entries: - return ("go", ["go", "test", "./...", "-count=1"]) - - # Rust - if "Cargo.toml" in entries: - return ("cargo", ["cargo", "test"]) - - return ("", []) - - -def main(): - try: - json.load(sys.stdin) - except (json.JSONDecodeError, ValueError): - pass - - cwd = os.getcwd() - framework, cmd = detect_test_framework(cwd) - - if not framework: - sys.exit(0) - - try: - result = subprocess.run( - cmd, - cwd=cwd, - capture_output=True, - text=True, - timeout=TIMEOUT_SECONDS, - ) - except subprocess.TimeoutExpired: - # Timeout is not a definitive failure — allow completion but warn - print( - f"Tests timed out ({framework}, {TIMEOUT_SECONDS}s). " - f"Task completion allowed — verify tests manually.", - file=sys.stderr, - ) - sys.exit(0) - except FileNotFoundError: - sys.exit(0) - except OSError: - sys.exit(0) - - if result.returncode == 0: - sys.exit(0) - - output = (result.stdout + "\n" + result.stderr).strip() - if not output: - output = "(no test output)" - - lines = output.splitlines() - if len(lines) > 50: - output = "...(truncated)\n" + "\n".join(lines[-50:]) - - print( - f"Tests failed ({framework}). Fix failures before marking task complete:\n{output}", - file=sys.stderr, - ) - sys.exit(2) - - -if __name__ == "__main__": - main() diff --git a/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/verify-no-regression.py b/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/verify-no-regression.py deleted file mode 100644 index c2fd877..0000000 --- a/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/verify-no-regression.py +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env python3 -""" -Verify no regression - PostToolUse hook for refactorer agent. - -After each Edit operation, runs the project test suite to ensure -the refactoring didn't break anything. Includes debounce to avoid -running tests too frequently during rapid edits. - -Reads hook input from stdin (JSON). Returns JSON on stdout. -Non-blocking on detection failures: always exits 0 if no framework found. - -Exit 0: Tests pass, no framework found, debounced, or timeout -Exit 2: Tests fail (forces agent to fix regression before continuing) -""" - -import json -import os -import subprocess -import sys -import time - -# Hook gate — check .codeforge/config/disabled-hooks.json -_dh = os.path.join(os.getcwd(), ".codeforge", "config", "disabled-hooks.json") -if os.path.exists(_dh): - with open(_dh) as _f: - if os.path.basename(__file__).replace(".py", "") in json.load(_f).get("disabled", []): - sys.exit(0) - -DEBOUNCE_SECONDS = 10 - - -def detect_test_framework(cwd: str) -> tuple[str, list[str]]: - """Detect which test framework is available in the project. - - Checks for: pytest, vitest, jest, mocha, go test, cargo test. - Falls back to npm test if a test script is defined. - - Returns: - Tuple of (framework_name, command_list) or ("", []) if none found. - """ - try: - entries = set(os.listdir(cwd)) - except OSError: - return ("", []) - - # --- Python: pytest --- - if "pytest.ini" in entries or "conftest.py" in entries: - return ("pytest", ["python3", "-m", "pytest", "--tb=short", "-q"]) - - for cfg_name in ("pyproject.toml", "setup.cfg", "tox.ini"): - cfg_path = os.path.join(cwd, cfg_name) - if os.path.isfile(cfg_path): - try: - with open(cfg_path, "r", encoding="utf-8") as f: - content = f.read() - if ( - "[tool.pytest" in content - or "[pytest]" in content - or "[tool:pytest]" in content - ): - return ("pytest", ["python3", "-m", "pytest", "--tb=short", "-q"]) - except OSError: - pass - - if "tests" in entries and os.path.isdir(os.path.join(cwd, "tests")): - return ("pytest", ["python3", "-m", "pytest", "--tb=short", "-q"]) - - for entry in entries: - if entry.startswith("test_") and entry.endswith(".py"): - return ("pytest", ["python3", "-m", "pytest", "--tb=short", "-q"]) - - # --- JavaScript: vitest --- - for name in entries: - if name.startswith("vitest.config"): - return ("vitest", ["npx", "vitest", "run", "--reporter=verbose"]) - - for vite_cfg in ("vite.config.ts", "vite.config.js"): - cfg_path = os.path.join(cwd, vite_cfg) - if os.path.isfile(cfg_path): - try: - with open(cfg_path, "r", encoding="utf-8") as f: - if "test" in f.read(): - return ( - "vitest", - ["npx", "vitest", "run", "--reporter=verbose"], - ) - except OSError: - pass - - # --- JavaScript: jest --- - for name in entries: - if name.startswith("jest.config"): - return ("jest", ["npx", "jest", "--verbose"]) - - pkg_json = os.path.join(cwd, "package.json") - if os.path.isfile(pkg_json): - try: - with open(pkg_json, "r", encoding="utf-8") as f: - pkg = json.loads(f.read()) - - if "jest" in pkg: - return ("jest", ["npx", "jest", "--verbose"]) - - dev_deps = pkg.get("devDependencies", {}) - deps = pkg.get("dependencies", {}) - - if "mocha" in dev_deps or "mocha" in deps: - return ("mocha", ["npx", "mocha", "--reporter", "spec"]) - - test_script = pkg.get("scripts", {}).get("test", "") - if test_script and "no test specified" not in test_script: - return ("npm-test", ["npm", "test"]) - except (OSError, json.JSONDecodeError): - pass - - # --- Go --- - if "go.mod" in entries: - return ("go", ["go", "test", "./...", "-count=1"]) - - # --- Rust --- - if "Cargo.toml" in entries: - return ("cargo", ["cargo", "test"]) - - return ("", []) - - -def should_debounce(session_id: str) -> bool: - """Check if we should skip this run due to recent execution. - - Uses a timestamp file in /tmp to throttle test runs during rapid edits. - Returns True if the last run was less than DEBOUNCE_SECONDS ago. - """ - stamp_path = f"/tmp/claude-regression-{session_id}" - now = time.time() - - try: - with open(stamp_path, "r") as f: - last_run = float(f.read().strip()) - if now - last_run < DEBOUNCE_SECONDS: - return True - except (OSError, ValueError): - pass - - try: - with open(stamp_path, "w") as f: - f.write(str(now)) - except OSError: - pass - - return False - - -def main(): - try: - input_data = json.load(sys.stdin) - except (json.JSONDecodeError, ValueError): - sys.exit(0) - - tool_input = input_data.get("tool_input", {}) - file_path = tool_input.get("file_path", "") - - if not file_path: - sys.exit(0) - - # Debounce: skip if tests ran recently in this session - session_id = input_data.get("session_id", "default") - if should_debounce(session_id): - sys.exit(0) - - cwd = os.getcwd() - framework, cmd = detect_test_framework(cwd) - - if not framework: - sys.exit(0) - - try: - result = subprocess.run( - cmd, - cwd=cwd, - capture_output=True, - text=True, - timeout=60, - ) - except subprocess.TimeoutExpired: - # Timeout is non-critical for PostToolUse — don't block the agent - json.dump( - { - "hookSpecificOutput": { - "hookEventName": "PostToolUse", - "additionalContext": f"[Tests] {framework} timed out after 60s — skipping regression check.", - } - }, - sys.stdout, - ) - sys.exit(0) - except FileNotFoundError: - sys.exit(0) - except OSError: - sys.exit(0) - - output = (result.stdout + "\n" + result.stderr).strip() - if not output: - output = "(no test output)" - - # Truncate to last 50 lines - lines = output.splitlines() - if len(lines) > 50: - output = "...(truncated)\n" + "\n".join(lines[-50:]) - - if result.returncode != 0: - edited = os.path.basename(file_path) - print( - f"Regression detected after editing {edited} " - f"({framework}). Fix the failing tests before continuing:\n{output}", - file=sys.stderr, - ) - sys.exit(2) - - json.dump( - { - "hookSpecificOutput": { - "hookEventName": "PostToolUse", - "additionalContext": f"[Tests] No regression ({framework}): all tests passed", - } - }, - sys.stdout, - ) - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/verify-tests-pass.py b/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/verify-tests-pass.py deleted file mode 100644 index cb4ba86..0000000 --- a/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/verify-tests-pass.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env python3 -""" -Verify tests pass - Stop hook for test-writer agent. - -Detects the project's test framework and runs the test suite to verify -that tests written by the agent actually pass. - -Reads hook input from stdin (JSON). Returns JSON on stdout. -Non-blocking on detection failures: always exits 0 if no framework found. - -Exit 0: Tests pass (or no test framework / runner not installed) -Exit 2: Tests fail (blocks agent from completing with broken tests) -""" - -import json -import os -import subprocess -import sys - -# Hook gate — check .codeforge/config/disabled-hooks.json -_dh = os.path.join(os.getcwd(), ".codeforge", "config", "disabled-hooks.json") -if os.path.exists(_dh): - with open(_dh) as _f: - if os.path.basename(__file__).replace(".py", "") in json.load(_f).get("disabled", []): - sys.exit(0) - - -def detect_test_framework(cwd: str) -> tuple[str, list[str]]: - """Detect which test framework is available in the project. - - Checks for: pytest, vitest, jest, mocha, go test, cargo test. - Falls back to npm test if a test script is defined. - - Returns: - Tuple of (framework_name, command_list) or ("", []) if none found. - """ - try: - entries = set(os.listdir(cwd)) - except OSError: - return ("", []) - - # --- Python: pytest --- - if "pytest.ini" in entries or "conftest.py" in entries: - return ("pytest", ["python3", "-m", "pytest", "--tb=short", "-q"]) - - for cfg_name in ("pyproject.toml", "setup.cfg", "tox.ini"): - cfg_path = os.path.join(cwd, cfg_name) - if os.path.isfile(cfg_path): - try: - with open(cfg_path, "r", encoding="utf-8") as f: - content = f.read() - if ( - "[tool.pytest" in content - or "[pytest]" in content - or "[tool:pytest]" in content - ): - return ("pytest", ["python3", "-m", "pytest", "--tb=short", "-q"]) - except OSError: - pass - - if "tests" in entries and os.path.isdir(os.path.join(cwd, "tests")): - return ("pytest", ["python3", "-m", "pytest", "--tb=short", "-q"]) - - for entry in entries: - if entry.startswith("test_") and entry.endswith(".py"): - return ("pytest", ["python3", "-m", "pytest", "--tb=short", "-q"]) - - # --- JavaScript: vitest --- - for name in entries: - if name.startswith("vitest.config"): - return ("vitest", ["npx", "vitest", "run", "--reporter=verbose"]) - - for vite_cfg in ("vite.config.ts", "vite.config.js"): - cfg_path = os.path.join(cwd, vite_cfg) - if os.path.isfile(cfg_path): - try: - with open(cfg_path, "r", encoding="utf-8") as f: - if "test" in f.read(): - return ( - "vitest", - ["npx", "vitest", "run", "--reporter=verbose"], - ) - except OSError: - pass - - # --- JavaScript: jest --- - for name in entries: - if name.startswith("jest.config"): - return ("jest", ["npx", "jest", "--verbose"]) - - pkg_json = os.path.join(cwd, "package.json") - if os.path.isfile(pkg_json): - try: - with open(pkg_json, "r", encoding="utf-8") as f: - pkg = json.loads(f.read()) - - if "jest" in pkg: - return ("jest", ["npx", "jest", "--verbose"]) - - dev_deps = pkg.get("devDependencies", {}) - deps = pkg.get("dependencies", {}) - - # mocha - if "mocha" in dev_deps or "mocha" in deps: - return ("mocha", ["npx", "mocha", "--reporter", "spec"]) - - # Generic npm test script (skip default placeholder) - test_script = pkg.get("scripts", {}).get("test", "") - if test_script and "no test specified" not in test_script: - return ("npm-test", ["npm", "test"]) - except (OSError, json.JSONDecodeError): - pass - - # --- Go --- - if "go.mod" in entries: - return ("go", ["go", "test", "./...", "-count=1"]) - - # --- Rust --- - if "Cargo.toml" in entries: - return ("cargo", ["cargo", "test"]) - - return ("", []) - - -def main(): - try: - input_data = json.load(sys.stdin) - except (json.JSONDecodeError, ValueError): - sys.exit(0) - - cwd = os.getcwd() - framework, cmd = detect_test_framework(cwd) - - if not framework: - sys.exit(0) - - try: - result = subprocess.run( - cmd, - cwd=cwd, - capture_output=True, - text=True, - timeout=60, - ) - except subprocess.TimeoutExpired: - print(f"Tests timed out ({framework})", file=sys.stderr) - sys.exit(2) - except FileNotFoundError: - # Test runner not installed — non-critical - sys.exit(0) - except OSError: - sys.exit(0) - - output = (result.stdout + "\n" + result.stderr).strip() - if not output: - output = "(no test output)" - - # Truncate to last 50 lines - lines = output.splitlines() - if len(lines) > 50: - output = "...(truncated)\n" + "\n".join(lines[-50:]) - - if result.returncode != 0: - print(f"Tests failed ({framework}):\n{output}", file=sys.stderr) - sys.exit(2) - - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/skills/verify-tests/SKILL.md b/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/skills/verify-tests/SKILL.md new file mode 100644 index 0000000..b2b4919 --- /dev/null +++ b/container/.devcontainer/plugins/devs-marketplace/plugins/agent-system/skills/verify-tests/SKILL.md @@ -0,0 +1,58 @@ +--- +name: verify-tests +description: "Run the project test suite and report results. Use after agent work completes or before committing to verify nothing is broken." +argument-hint: "[test files, directory, or framework hint]" +allowed-tools: Bash Read Glob Grep +--- + +# /verify-tests + +Run the project test suite, report results, and optionally fix failures. + +## Step 1: Detect Test Framework + +Check the project for test infrastructure. Use the first match: + +| Indicator | Command | +|-----------|---------| +| `pytest.ini`, `conftest.py`, or `pyproject.toml` with `[tool.pytest` | `python3 -m pytest --tb=short -q` | +| `vitest.config.*` | `npx vitest run --reporter=verbose` | +| `jest.config.*` or `package.json` with `"jest"` | `npx jest --verbose` | +| `package.json` with `"mocha"` | `npx mocha --reporter spec` | +| `go.mod` | `go test ./... -count=1` | +| `Cargo.toml` | `cargo test` | +| `package.json` with `"test"` script | `npm test` | + +If `$ARGUMENTS` specifies files or a framework, use those instead of auto-detection. + +If no test framework is detected, report: "No test framework detected in this project." + +## Step 2: Run Tests + +Execute the detected command. If specific files were passed via `$ARGUMENTS`, scope the run to those files. + +## Step 3: Report Results + +Format output as: + +``` +## Test Results +**Framework:** +**Result:** passed | N failed, M passed +**Duration:**