quor 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. quor/__init__.py +1 -0
  2. quor/__main__.py +104 -0
  3. quor/adapters/__init__.py +0 -0
  4. quor/adapters/base.py +39 -0
  5. quor/adapters/claude.py +65 -0
  6. quor/adapters/dispatcher.py +212 -0
  7. quor/cli/__init__.py +0 -0
  8. quor/cli/commands/__init__.py +0 -0
  9. quor/cli/commands/doctor.py +190 -0
  10. quor/cli/commands/explain.py +84 -0
  11. quor/cli/commands/gain.py +54 -0
  12. quor/cli/commands/init.py +210 -0
  13. quor/cli/commands/validate.py +55 -0
  14. quor/cli/commands/verify.py +52 -0
  15. quor/cli/main.py +58 -0
  16. quor/config/__init__.py +0 -0
  17. quor/config/loader.py +43 -0
  18. quor/config/model.py +60 -0
  19. quor/errors.py +69 -0
  20. quor/filters/__init__.py +0 -0
  21. quor/filters/builtin/__init__.py +0 -0
  22. quor/filters/builtin/build.toml +89 -0
  23. quor/filters/builtin/cat.toml +50 -0
  24. quor/filters/builtin/git.toml +131 -0
  25. quor/filters/builtin/pytest.toml +59 -0
  26. quor/filters/builtin/z_generic.toml +34 -0
  27. quor/filters/loader.py +32 -0
  28. quor/filters/registry.py +278 -0
  29. quor/filters/trust.py +19 -0
  30. quor/pipeline/__init__.py +0 -0
  31. quor/pipeline/content_type.py +69 -0
  32. quor/pipeline/engine.py +151 -0
  33. quor/pipeline/mask.py +57 -0
  34. quor/pipeline/plugin_loader.py +517 -0
  35. quor/pipeline/stages/__init__.py +0 -0
  36. quor/pipeline/stages/_utils.py +48 -0
  37. quor/pipeline/stages/base.py +88 -0
  38. quor/pipeline/stages/deduplicate_consecutive.py +68 -0
  39. quor/pipeline/stages/group_repeated.py +162 -0
  40. quor/pipeline/stages/max_tokens.py +126 -0
  41. quor/pipeline/stages/remove_ansi.py +71 -0
  42. quor/pipeline/stages/strip_lines.py +70 -0
  43. quor/plugins/__init__.py +61 -0
  44. quor/plugins/base.py +473 -0
  45. quor/plugins/registry.py +349 -0
  46. quor/py.typed +0 -0
  47. quor/rewrite/__init__.py +0 -0
  48. quor/rewrite/classifier.py +222 -0
  49. quor/rewrite/lexer.py +198 -0
  50. quor/rewrite/rules.py +145 -0
  51. quor/tracking/__init__.py +0 -0
  52. quor/tracking/db.py +310 -0
  53. quor/tracking/schema.sql +28 -0
  54. quor-0.1.0.dist-info/METADATA +259 -0
  55. quor-0.1.0.dist-info/RECORD +58 -0
  56. quor-0.1.0.dist-info/WHEEL +4 -0
  57. quor-0.1.0.dist-info/entry_points.txt +3 -0
  58. quor-0.1.0.dist-info/licenses/LICENSE +201 -0
quor/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
quor/__main__.py ADDED
@@ -0,0 +1,104 @@
1
+ """
2
+ Quor entry point.
3
+
4
+ Version check runs at module level before any Quor import so we fail fast on
5
+ old Python without importing anything that might not exist.
6
+
7
+ Routing:
8
+ quor hook claude → hook path (no rich, stdout must stay valid JSON)
9
+ quor git status → dispatcher (run command, apply filter, print output)
10
+ quor schema / init / … → CLI path via typer
11
+ """
12
+
13
+ import contextlib
14
+ import sys
15
+
16
+ # CLI subcommands defined by Phase 7. Anything NOT in this set and NOT starting
17
+ # with "-" is treated as a dispatch target (e.g. "quor git status").
18
+ _CLI_COMMANDS: frozenset[str] = frozenset(
19
+ {"schema", "hook", "init", "validate", "explain", "gain", "verify", "doctor"}
20
+ )
21
+
22
+
23
+ def _check_python_version() -> None:
24
+ # Runtime guard: sys.executable may differ from the installer Python, so
25
+ # the requires-python constraint (enforced only at install time) is not
26
+ # sufficient. This must run before any other import.
27
+ if sys.version_info < (3, 11): # noqa: UP036
28
+ print(
29
+ f"Quor requires Python 3.11 or higher. "
30
+ f"You are running {sys.version_info.major}.{sys.version_info.minor}. "
31
+ f"Please upgrade: https://python.org/downloads/",
32
+ file=sys.stderr,
33
+ )
34
+ sys.exit(5) # ExitCode.DEPENDENCY_MISSING
35
+
36
+
37
+ _check_python_version()
38
+
39
+
40
+ def _run_hook() -> None:
41
+ # Read stdin immediately so it is available in the except branch.
42
+ original_bytes = sys.stdin.buffer.read()
43
+ try:
44
+ adapter = sys.argv[2] if len(sys.argv) > 2 else ""
45
+ if adapter != "claude":
46
+ sys.stdout.buffer.write(original_bytes)
47
+ print(f"[quor] Unknown hook adapter: {adapter!r}", file=sys.stderr)
48
+ return
49
+ import io
50
+
51
+ sys.stdin = io.TextIOWrapper(io.BytesIO(original_bytes), encoding="utf-8")
52
+ from quor.adapters.claude import run_hook
53
+
54
+ run_hook()
55
+ except Exception as exc: # noqa: BLE001 — hook must never raise
56
+ sys.stdout.buffer.write(original_bytes)
57
+ print(f"[quor] Hook error — returning original: {exc}", file=sys.stderr)
58
+
59
+
60
+ def _ensure_utf8_stdio() -> None:
61
+ # Windows consoles default text-mode stdout/stderr to the system codepage
62
+ # (often cp1252), which cannot encode the ✓/✗ glyphs used throughout the
63
+ # CLI and dispatch output. Reconfigure to UTF-8; harmless if already UTF-8
64
+ # or if the stream (e.g. a test runner's capture buffer) doesn't support it.
65
+ for stream in (sys.stdout, sys.stderr):
66
+ reconfigure = getattr(stream, "reconfigure", None)
67
+ if reconfigure is not None:
68
+ with contextlib.suppress(ValueError, OSError):
69
+ reconfigure(encoding="utf-8")
70
+
71
+
72
+ def _run_dispatch(args: list[str]) -> None:
73
+ from quor.adapters.dispatcher import run_dispatch
74
+ from quor.tracking.db import get_tracking_db
75
+
76
+ tracking = get_tracking_db()
77
+ try:
78
+ exit_code = run_dispatch(args, tracking=tracking)
79
+ finally:
80
+ tracking.close()
81
+ sys.exit(exit_code)
82
+
83
+
84
+ def main() -> None:
85
+ # Keep hook branch first: must never touch rich or typer.
86
+ if len(sys.argv) >= 2 and sys.argv[1] == "hook":
87
+ _run_hook()
88
+ return
89
+
90
+ _ensure_utf8_stdio()
91
+
92
+ # Dispatch branch: "quor git status" → run git status through filter
93
+ first_arg = sys.argv[1] if len(sys.argv) >= 2 else ""
94
+ if first_arg and first_arg not in _CLI_COMMANDS and not first_arg.startswith("-"):
95
+ _run_dispatch(sys.argv[1:])
96
+ return
97
+
98
+ from quor.cli.main import app
99
+
100
+ app()
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()
File without changes
quor/adapters/base.py ADDED
@@ -0,0 +1,39 @@
1
+ """Base types shared by all hook adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ from pydantic import BaseModel, ConfigDict
8
+
9
+
10
+ class ToolInput(BaseModel):
11
+ """The tool_input object inside a Claude Code PreToolUse hook payload."""
12
+
13
+ model_config = ConfigDict(extra="allow", frozen=True)
14
+ command: str = ""
15
+
16
+
17
+ class HookInput(BaseModel):
18
+ """Full Claude Code PreToolUse hook stdin payload."""
19
+
20
+ model_config = ConfigDict(extra="allow", frozen=True)
21
+ tool_name: str = ""
22
+ tool_input: ToolInput
23
+
24
+
25
+ class HookOutput(BaseModel):
26
+ """Hook stdout payload (same shape as HookInput, command possibly rewritten)."""
27
+
28
+ model_config = ConfigDict(extra="allow", frozen=True)
29
+ tool_name: str = ""
30
+ tool_input: ToolInput
31
+
32
+
33
+ @runtime_checkable
34
+ class HookAdapter(Protocol):
35
+ """Protocol for hook adapters. Each adapter handles one AI tool's hook format."""
36
+
37
+ def run_hook(self) -> None:
38
+ """Read JSON from sys.stdin, write (possibly modified) JSON to sys.stdout."""
39
+ ...
@@ -0,0 +1,65 @@
1
+ """Claude Code hook adapter.
2
+
3
+ Reads a Claude Code PreToolUse JSON payload from sys.stdin, rewrites the
4
+ `tool_input.command` field if Quor has a filter for it, and writes the
5
+ (possibly modified) JSON to sys.stdout.
6
+
7
+ Called by __main__._run_hook() which guarantees:
8
+ - sys.stdin is a TextIOWrapper over the original stdin bytes
9
+ - Any exception raised here is caught by __main__ and returns original bytes
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from typing import Any
16
+
17
+ import orjson
18
+
19
+ from quor.adapters.base import HookInput
20
+ from quor.rewrite.classifier import rewrite_command
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Hook script template — written by `quor init --claude` (Phase 7)
24
+ # Placeholders: {python} is replaced with sys.executable at install time.
25
+ # ---------------------------------------------------------------------------
26
+
27
+ HOOK_COMMAND = "{python} -m quor hook claude"
28
+
29
+ HOOK_PS1_TEMPLATE = """\
30
+ # Quor hook script — generated by `quor init --claude`
31
+ # Do not edit this file. To update, run `quor init --claude` again.
32
+ $ErrorActionPreference = 'Stop'
33
+ $json = [Console]::In.ReadToEnd()
34
+ $json | & '{python}' -m quor hook claude
35
+ """
36
+
37
+ _UTF8_BOM = ""
38
+
39
+
40
+ def run_hook() -> None:
41
+ """Process one PreToolUse hook call.
42
+
43
+ Reads JSON from sys.stdin (already set up by __main__._run_hook).
44
+ Writes modified JSON to sys.stdout.
45
+ Raises on parse/validation errors — caller handles fail-open.
46
+ """
47
+ raw = sys.stdin.read()
48
+
49
+ # Strip UTF-8 BOM (single or doubled — Cursor sends doubled BOM on Windows)
50
+ raw = raw.lstrip(_UTF8_BOM)
51
+
52
+ # Parse full payload as a dict so extra fields are preserved verbatim
53
+ data: dict[str, Any] = orjson.loads(raw)
54
+
55
+ # Validate the fields we care about (raises ValidationError on bad shape)
56
+ hook_input = HookInput.model_validate(data)
57
+ original_cmd = hook_input.tool_input.command
58
+
59
+ rewritten = rewrite_command(original_cmd)
60
+ if rewritten is not None and rewritten != original_cmd:
61
+ # Modify the dict in-place to preserve every extra field
62
+ data["tool_input"]["command"] = rewritten
63
+
64
+ sys.stdout.buffer.write(orjson.dumps(data))
65
+ sys.stdout.buffer.flush()
@@ -0,0 +1,212 @@
1
+ """Hook dispatcher: run the real command and apply content filtering.
2
+
3
+ Called when Claude Code executes the rewritten command `quor git status`.
4
+ sys.argv[1:] = ["git", "status"] → subprocess runs git status, output filtered.
5
+
6
+ Execution order:
7
+ subprocess → PRE_FILTER plugins → ContentMask filter → POST_FILTER plugins → stdout
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ import uuid
16
+ import warnings
17
+ from pathlib import Path
18
+
19
+ from quor.filters.registry import FilterRegistry
20
+ from quor.pipeline.content_type import detect
21
+ from quor.tracking.db import InvocationRecord, TrackingDB, count_tokens
22
+
23
+
24
+ def run_dispatch(args: list[str], tracking: TrackingDB | None = None) -> int:
25
+ """Run `args` as a subprocess, apply filter and plugin pipeline, write to stdout.
26
+
27
+ Returns the subprocess exit code. Never raises — any error falls through
28
+ to unfiltered output (fail-open). If `tracking` is provided, records the
29
+ invocation.
30
+
31
+ Plugin pipeline (fail-open at each step):
32
+ PRE_FILTER — before ContentMask; plugins may annotate or modify raw output
33
+ ContentMask — built-in TOML-configured compression stages
34
+ POST_FILTER — after ContentMask; plugins may observe or transform final output
35
+ """
36
+ if not args:
37
+ return 0
38
+
39
+ cmd_str = " ".join(args)
40
+ t0 = time.monotonic()
41
+
42
+ # --- Run the real command ---
43
+ try:
44
+ proc = subprocess.run(
45
+ args,
46
+ stdout=subprocess.PIPE,
47
+ stderr=None, # inherit: real stderr flows to user directly
48
+ encoding="utf-8",
49
+ errors="replace", # graceful handling of non-UTF-8 output
50
+ timeout=25, # 25 s leaves room for filter + plugins within 30 s hook budget
51
+ )
52
+ except subprocess.TimeoutExpired:
53
+ print(f"[quor] command {args[0]!r} timed out after 25 s", file=sys.stderr)
54
+ return 124
55
+ except (OSError, FileNotFoundError) as exc:
56
+ print(f"[quor] cannot run {args[0]!r}: {exc}", file=sys.stderr)
57
+ return 127
58
+
59
+ captured = proc.stdout or ""
60
+
61
+ # --- Lookup filter ---
62
+ filter_config = None
63
+ registry: FilterRegistry | None = None
64
+ try:
65
+ registry = FilterRegistry(project_root=Path.cwd())
66
+ filter_config = registry.find(cmd_str)
67
+ except Exception as exc: # noqa: BLE001
68
+ warnings.warn(f"[quor] filter registry error: {exc}", stacklevel=1)
69
+
70
+ # --- Setup plugin pipeline (fail-open; empty registry = no-op) ---
71
+ from quor.plugins.base import ExecutionMode, PluginCategory, PluginContext, PluginPayload
72
+ from quor.plugins.registry import PluginRegistry
73
+
74
+ plugin_registry = PluginRegistry()
75
+ plugin_ctx: PluginContext | None = None
76
+
77
+ try:
78
+ from quor.config.loader import load_user_config
79
+ from quor.pipeline.plugin_loader import discover_plugins
80
+
81
+ discover_plugins(plugin_registry, use_cache=True, tier="user")
82
+ if plugin_registry.all_plugins():
83
+ mode_str = load_user_config().mode
84
+ try:
85
+ mode = ExecutionMode(mode_str)
86
+ except ValueError:
87
+ mode = ExecutionMode.OPTIMIZE
88
+
89
+ plugin_ctx = PluginContext(
90
+ project_root=Path.cwd(),
91
+ mode=mode,
92
+ session_id="",
93
+ invocation_id=uuid.uuid4().hex,
94
+ )
95
+ plugin_registry.initialize_all(plugin_ctx)
96
+ except Exception as exc: # noqa: BLE001
97
+ warnings.warn(f"[quor] plugin discovery error: {exc}", stacklevel=1)
98
+ plugin_ctx = None
99
+
100
+ # --- PRE_FILTER plugins ---
101
+ pre_output = captured
102
+ if plugin_ctx is not None:
103
+ try:
104
+ pre_payload = PluginPayload(
105
+ command=cmd_str,
106
+ raw_output=captured,
107
+ current_output=captured,
108
+ content_type=detect(captured).value,
109
+ )
110
+ pre_payload = plugin_registry.run_category(
111
+ PluginCategory.PRE_FILTER, pre_payload, plugin_ctx
112
+ )
113
+ pre_output = pre_payload.current_output
114
+ except Exception as exc: # noqa: BLE001
115
+ warnings.warn(f"[quor] PRE_FILTER plugin error: {exc}", stacklevel=1)
116
+
117
+ # --- Passthrough when no filter matches ---
118
+ if filter_config is None or registry is None:
119
+ _teardown_plugins(plugin_registry, plugin_ctx)
120
+ _track(
121
+ tracking,
122
+ cmd_str=cmd_str,
123
+ original=captured,
124
+ filtered=pre_output,
125
+ filter_name=None,
126
+ was_passthrough=True,
127
+ t0=t0,
128
+ )
129
+ sys.stdout.write(pre_output)
130
+ sys.stdout.flush()
131
+ return proc.returncode
132
+
133
+ # --- Apply ContentMask filter ---
134
+ content_type = detect(pre_output).value
135
+ try:
136
+ filtered = registry.apply(filter_config, pre_output, content_type=content_type)
137
+ except Exception as exc: # noqa: BLE001
138
+ warnings.warn(f"[quor] filter apply error: {exc}", stacklevel=1)
139
+ filtered = pre_output
140
+
141
+ # --- POST_FILTER plugins ---
142
+ if plugin_ctx is not None:
143
+ try:
144
+ post_payload = PluginPayload(
145
+ command=cmd_str,
146
+ raw_output=captured,
147
+ current_output=filtered,
148
+ content_type=content_type,
149
+ )
150
+ post_payload = plugin_registry.run_category(
151
+ PluginCategory.POST_FILTER, post_payload, plugin_ctx
152
+ )
153
+ filtered = post_payload.current_output
154
+ except Exception as exc: # noqa: BLE001
155
+ warnings.warn(f"[quor] POST_FILTER plugin error: {exc}", stacklevel=1)
156
+
157
+ _teardown_plugins(plugin_registry, plugin_ctx)
158
+ _track(
159
+ tracking,
160
+ cmd_str=cmd_str,
161
+ original=captured,
162
+ filtered=filtered,
163
+ filter_name=filter_config.name,
164
+ was_passthrough=False,
165
+ t0=t0,
166
+ )
167
+
168
+ sys.stdout.write(filtered)
169
+ sys.stdout.flush()
170
+ return proc.returncode
171
+
172
+
173
+ def _teardown_plugins(
174
+ plugin_registry: object, plugin_ctx: object | None
175
+ ) -> None:
176
+ """Shutdown all plugins if they were initialized. Fail-open."""
177
+ if plugin_ctx is None:
178
+ return
179
+ try:
180
+ from quor.plugins.registry import PluginRegistry as _PR
181
+
182
+ if isinstance(plugin_registry, _PR):
183
+ plugin_registry.shutdown_all()
184
+ except Exception as exc: # noqa: BLE001
185
+ warnings.warn(f"[quor] plugin shutdown error: {exc}", stacklevel=1)
186
+
187
+
188
+ def _track(
189
+ tracking: TrackingDB | None,
190
+ *,
191
+ cmd_str: str,
192
+ original: str,
193
+ filtered: str,
194
+ filter_name: str | None,
195
+ was_passthrough: bool,
196
+ t0: float,
197
+ ) -> None:
198
+ if tracking is None:
199
+ return
200
+ try:
201
+ rec = InvocationRecord(
202
+ command=cmd_str,
203
+ project_path=Path.cwd().as_posix(),
204
+ original_tokens=count_tokens(original),
205
+ final_tokens=count_tokens(filtered),
206
+ filter_name=filter_name,
207
+ was_passthrough=was_passthrough,
208
+ duration_ms=(time.monotonic() - t0) * 1000,
209
+ )
210
+ tracking.record(rec)
211
+ except Exception as exc: # noqa: BLE001
212
+ warnings.warn(f"[quor] tracking record error: {exc}", stacklevel=1)
quor/cli/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,190 @@
1
+ """quor doctor — health check: dependencies, hook, tracking DB, filters, mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import io
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import platformdirs
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from quor.config.loader import load_user_config
15
+ from quor.errors import ExitCode
16
+ from quor.filters.registry import FilterRegistry
17
+
18
+ console = Console()
19
+
20
+ _REQUIRED_PACKAGES = ("typer", "pydantic", "orjson", "platformdirs", "regex", "rich")
21
+
22
+
23
+ class _FakeStdout:
24
+ """Stand-in for sys.stdout whose .buffer is writable (Python 3.14 makes the real one read-only)."""
25
+
26
+ def __init__(self) -> None:
27
+ self.buffer = io.BytesIO()
28
+
29
+ def write(self, s: str) -> int:
30
+ return 0
31
+
32
+ def flush(self) -> None:
33
+ pass
34
+
35
+
36
+ def doctor(
37
+ settings_path: Path | None = typer.Option(
38
+ None, "--settings-path", hidden=True, help="Override the Claude settings.json path (for testing)."
39
+ ),
40
+ ) -> None:
41
+ """Run health checks and print a summary with colored status indicators."""
42
+ checks: list[tuple[str, bool, str]] = []
43
+
44
+ checks.append(_check_python_version())
45
+ checks.extend(_check_dependencies())
46
+ checks.append(_check_hook_script())
47
+ checks.append(_check_hook_roundtrip())
48
+ checks.append(_check_hook_collision(settings_path))
49
+ checks.append(_check_sqlite())
50
+ checks.append(_check_filters())
51
+ checks.append(_check_mode())
52
+ checks.append(_check_plugins())
53
+
54
+ all_ok = True
55
+ for name, ok, detail in checks:
56
+ symbol = "[green]✓[/green]" if ok else "[red]✗[/red]"
57
+ suffix = f" — {detail}" if detail else ""
58
+ console.print(f"{symbol} {name}{suffix}")
59
+ all_ok = all_ok and ok
60
+
61
+ if not all_ok:
62
+ raise typer.Exit(code=ExitCode.GENERAL_ERROR)
63
+
64
+
65
+ def _check_python_version() -> tuple[str, bool, str]:
66
+ ok = sys.version_info >= (3, 11)
67
+ return ("Python ≥ 3.11", ok, f"{sys.version_info.major}.{sys.version_info.minor}")
68
+
69
+
70
+ def _check_dependencies() -> list[tuple[str, bool, str]]:
71
+ results: list[tuple[str, bool, str]] = []
72
+ for pkg in _REQUIRED_PACKAGES:
73
+ try:
74
+ importlib.import_module(pkg)
75
+ results.append((f"Dependency '{pkg}'", True, ""))
76
+ except ImportError as exc:
77
+ results.append((f"Dependency '{pkg}'", False, str(exc)))
78
+ return results
79
+
80
+
81
+ def _check_hook_script() -> tuple[str, bool, str]:
82
+ hook_path = Path(platformdirs.user_data_dir("quor")) / "hooks" / "claude-hook.ps1"
83
+ exists = hook_path.exists()
84
+ detail = str(hook_path) if exists else f"not found at {hook_path} — run `quor init --claude`"
85
+ return ("Hook script installed", exists, detail)
86
+
87
+
88
+ def _check_hook_collision(settings_path: Path | None = None) -> tuple[str, bool, str]:
89
+ """Warn if another tool's PreToolUse Bash hook is registered alongside Quor's."""
90
+ from quor.cli.commands.init import _find_conflicting_hooks, _read_settings
91
+ from quor.errors import ConfigError
92
+
93
+ settings_file = settings_path or (Path.home() / ".claude" / "settings.json")
94
+ if not settings_file.exists():
95
+ return ("No conflicting PreToolUse hooks", True, "")
96
+ try:
97
+ settings = _read_settings(settings_file)
98
+ conflicts = _find_conflicting_hooks(settings)
99
+ if conflicts:
100
+ detail = (
101
+ f"{len(conflicts)} other Bash hook(s) detected — "
102
+ "double-rewriting risk; run `quor init --claude` to review"
103
+ )
104
+ return ("No conflicting PreToolUse hooks", False, detail)
105
+ return ("No conflicting PreToolUse hooks", True, "")
106
+ except ConfigError as exc:
107
+ return ("No conflicting PreToolUse hooks", True, f"(could not check: {exc})")
108
+ except Exception: # noqa: BLE001 — settings file may not exist or be parseable
109
+ return ("No conflicting PreToolUse hooks", True, "")
110
+
111
+
112
+ def _check_hook_roundtrip() -> tuple[str, bool, str]:
113
+ import orjson
114
+
115
+ from quor.adapters.claude import run_hook
116
+
117
+ payload = orjson.dumps({"tool_name": "Bash", "tool_input": {"command": "git status"}})
118
+ old_stdin, old_stdout = sys.stdin, sys.stdout
119
+ try:
120
+ sys.stdin = io.TextIOWrapper(io.BytesIO(payload), encoding="utf-8")
121
+ fake_stdout = _FakeStdout()
122
+ sys.stdout = fake_stdout
123
+ run_hook()
124
+ result = orjson.loads(fake_stdout.buffer.getvalue())
125
+ rewritten = result.get("tool_input", {}).get("command", "")
126
+ if rewritten == "quor git status":
127
+ return ("Hook responds correctly", True, "")
128
+ return ("Hook responds correctly", False, f"unexpected rewrite: {rewritten!r}")
129
+ except Exception as exc: # noqa: BLE001
130
+ return ("Hook responds correctly", False, str(exc))
131
+ finally:
132
+ sys.stdin = old_stdin
133
+ sys.stdout = old_stdout
134
+
135
+
136
+ def _check_sqlite() -> tuple[str, bool, str]:
137
+ from quor.tracking.db import get_tracking_db
138
+
139
+ try:
140
+ db = get_tracking_db()
141
+ db.flush()
142
+ db.close()
143
+ return ("Tracking DB readable/writable", True, "")
144
+ except Exception as exc: # noqa: BLE001
145
+ return ("Tracking DB readable/writable", False, str(exc))
146
+
147
+
148
+ def _check_filters() -> tuple[str, bool, str]:
149
+ registry = FilterRegistry(project_root=Path.cwd())
150
+ total_failures = 0
151
+ for _, filter_config in registry.all_filters():
152
+ total_failures += len(registry.run_tests(filter_config))
153
+ if total_failures:
154
+ return ("Built-in filter tests pass", False, f"{total_failures} inline test failure(s)")
155
+ return ("Built-in filter tests pass", True, "")
156
+
157
+
158
+ def _check_mode() -> tuple[str, bool, str]:
159
+ mode = load_user_config().mode
160
+ return (f"Mode: {mode}", True, "")
161
+
162
+
163
+ def _check_plugins() -> tuple[str, bool, str]:
164
+ """Report discovered third-party stages and plugins; flag any load failures."""
165
+ from quor.pipeline.plugin_loader import get_load_report
166
+
167
+ try:
168
+ report = get_load_report(use_cache=False)
169
+ except Exception as exc: # noqa: BLE001
170
+ return ("Plugin discovery", True, f"(could not check: {exc})")
171
+
172
+ if report.is_empty:
173
+ return ("Plugin discovery", True, "no third-party plugins installed")
174
+
175
+ if report.failures:
176
+ names = ", ".join(f.entry_point_name for f in report.failures)
177
+ return (
178
+ "Plugin discovery",
179
+ False,
180
+ f"{len(report.failures)} load failure(s): {names}",
181
+ )
182
+
183
+ parts: list[str] = []
184
+ if report.stages:
185
+ stage_names = ", ".join(s.stage_type for s in report.stages)
186
+ parts.append(f"{len(report.stages)} stage(s): {stage_names}")
187
+ if report.plugins:
188
+ plugin_names = ", ".join(f"{p.plugin_id}@{p.version}" for p in report.plugins)
189
+ parts.append(f"{len(report.plugins)} plugin(s): {plugin_names}")
190
+ return ("Plugin discovery", True, "; ".join(parts))