cluxion-agentplugin-supercoder 0.2.6__tar.gz → 0.2.8__tar.gz

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 (67) hide show
  1. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/PKG-INFO +1 -1
  2. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/__init__.py +1 -1
  3. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/pyproject.toml +1 -1
  4. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/scripts/repack_native_wheel.py +1 -5
  5. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/__init__.py +1 -1
  6. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/cli.py +1 -0
  7. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/cursor.py +6 -2
  8. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/hash_patch.py +62 -23
  9. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/doctor/framework.py +13 -10
  10. cluxion_agentplugin_supercoder-0.2.8/src/cluxion_agentplugin_supercoder/doctor/probes.py +355 -0
  11. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/runner.py +12 -1
  12. cluxion_agentplugin_supercoder-0.2.8/tests/test_cursor.py +72 -0
  13. cluxion_agentplugin_supercoder-0.2.8/tests/test_doctor.py +246 -0
  14. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_hash_patch.py +80 -0
  15. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/uv.lock +1 -1
  16. cluxion_agentplugin_supercoder-0.2.6/src/cluxion_agentplugin_supercoder/doctor/probes.py +0 -187
  17. cluxion_agentplugin_supercoder-0.2.6/tests/test_cursor.py +0 -14
  18. cluxion_agentplugin_supercoder-0.2.6/tests/test_doctor.py +0 -99
  19. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/.github/workflows/ci.yml +0 -0
  20. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/.github/workflows/publish.yml +0 -0
  21. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/.gitignore +0 -0
  22. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/ARCHITECTURE.md +0 -0
  23. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/Docs/README.md +0 -0
  24. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/Docs/agent-surfaces.md +0 -0
  25. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/Docs/architecture.md +0 -0
  26. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/Docs/capabilities.md +0 -0
  27. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/Docs/design.md +0 -0
  28. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/Docs/installation.md +0 -0
  29. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/Docs/rust-architecture.md +0 -0
  30. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/Docs/tools.md +0 -0
  31. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/LICENSE +0 -0
  32. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/README.md +0 -0
  33. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/adapters/claude/.claude-plugin/plugin.json +0 -0
  34. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/adapters/claude/skills/supercoder/SKILL.md +0 -0
  35. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/adapters/codex/config-snippet.toml +0 -0
  36. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/adapters/hermes/README.md +0 -0
  37. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/plugin.yaml +0 -0
  38. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/rust/supercoder_index/Cargo.lock +0 -0
  39. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/rust/supercoder_index/Cargo.toml +0 -0
  40. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/rust/supercoder_index/pyproject.toml +0 -0
  41. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/rust/supercoder_index/src/lib.rs +0 -0
  42. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/rust/supercoder_index/src/main.rs +0 -0
  43. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/rust/supercoder_index/src/outline.rs +0 -0
  44. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/rust/supercoder_index/src/syntax.rs +0 -0
  45. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/line_budget.py +0 -0
  46. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/lint_gate.py +0 -0
  47. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/queue.py +0 -0
  48. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/repo_map.py +0 -0
  49. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/retry_loop.py +0 -0
  50. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/safety.py +0 -0
  51. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/syntax_gate.py +0 -0
  52. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/core/test_gate.py +0 -0
  53. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/doctor/__init__.py +0 -0
  54. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/doctor/catalog.json +0 -0
  55. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/plugin.py +0 -0
  56. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/rust_bridge.py +0 -0
  57. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/src/cluxion_agentplugin_supercoder/schemas.py +0 -0
  58. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_line_budget.py +0 -0
  59. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_lint_gate.py +0 -0
  60. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_plugin.py +0 -0
  61. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_queue.py +0 -0
  62. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_repo_map.py +0 -0
  63. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_retry_loop.py +0 -0
  64. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_rust_bridge.py +0 -0
  65. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_safety.py +0 -0
  66. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_syntax_gate.py +0 -0
  67. {cluxion_agentplugin_supercoder-0.2.6 → cluxion_agentplugin_supercoder-0.2.8}/tests/test_test_gate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cluxion-agentplugin-supercoder
3
- Version: 0.2.6
3
+ Version: 0.2.8
4
4
  Summary: Universal agent coding harness plugin: cursor logic, safe patch, line budget, Rust index, test gates.
5
5
  Project-URL: Homepage, https://github.com/cluxion/cluxion-Agentplugin-supercoder
6
6
  Project-URL: Repository, https://github.com/cluxion/cluxion-Agentplugin-supercoder
@@ -9,4 +9,4 @@ if str(_SRC) not in sys.path:
9
9
 
10
10
  from cluxion_agentplugin_supercoder.plugin import register
11
11
 
12
- __all__ = ["register"]
12
+ __all__ = ["register"]
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cluxion-agentplugin-supercoder"
7
- version = "0.2.6"
7
+ version = "0.2.8"
8
8
  description = "Universal agent coding harness plugin: cursor logic, safe patch, line budget, Rust index, test gates."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -44,11 +44,7 @@ def _dist_info(tree: Path) -> Path:
44
44
 
45
45
  def _native_tags(native_tree: Path) -> list[str]:
46
46
  wheel_meta = (_dist_info(native_tree) / "WHEEL").read_text(encoding="utf-8")
47
- tags = [
48
- line.split(":", 1)[1].strip()
49
- for line in wheel_meta.splitlines()
50
- if line.startswith("Tag:")
51
- ]
47
+ tags = [line.split(":", 1)[1].strip() for line in wheel_meta.splitlines() if line.startswith("Tag:")]
52
48
  if not tags:
53
49
  raise SystemExit("native wheel has no Tag entries")
54
50
  return tags
@@ -1,5 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.2.6"
3
+ __version__ = "0.2.7"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -52,6 +52,7 @@ def main(argv: Sequence[str] | None = None) -> int:
52
52
  def load_catalog_for_text(catalog_path):
53
53
  # helper to avoid circular, but since framework has load_catalog
54
54
  from cluxion_agentplugin_supercoder.doctor.framework import load_catalog
55
+
55
56
  return load_catalog(Path(str(catalog_path)))
56
57
 
57
58
 
@@ -6,6 +6,7 @@ from dataclasses import dataclass
6
6
  from pathlib import Path
7
7
 
8
8
  from cluxion_agentplugin_supercoder.core.hash_patch import file_hash
9
+ from cluxion_agentplugin_supercoder.core.safety import pre_tool_gate
9
10
 
10
11
 
11
12
  @dataclass(frozen=True)
@@ -27,11 +28,14 @@ def read_window(
27
28
  max_lines: int = 120,
28
29
  purpose: str = "read",
29
30
  ) -> LineWindow:
31
+ gate = pre_tool_gate("read_window", {"path": rel_path}, workspace=root)
32
+ if gate.decision == "block":
33
+ raise PermissionError(gate.reason)
30
34
  path = (root / rel_path).resolve()
31
35
  if not path.exists():
32
36
  raise FileNotFoundError(rel_path)
33
- if not str(path).startswith(str(root.resolve())):
34
- raise PermissionError("path escapes workspace root")
37
+ if not path.is_relative_to(root.resolve()):
38
+ raise PermissionError("workspace escape blocked")
35
39
  text = path.read_text(encoding="utf-8")
36
40
  lines = text.splitlines()
37
41
  start = max(1, start_line)
@@ -3,15 +3,40 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import hashlib
6
+ import os
7
+ import tempfile
8
+ from contextlib import contextmanager
6
9
  from dataclasses import dataclass
7
10
  from difflib import SequenceMatcher
8
11
  from pathlib import Path
9
12
 
13
+ try:
14
+ import fcntl
15
+ except ImportError:
16
+ fcntl = None # type: ignore[assignment]
17
+
10
18
  DEFAULT_FUZZY_THRESHOLD = 0.86
11
19
  MAX_CONTEXT_SCAN = 8
12
20
  MAX_LINE_DRIFT = 2
13
21
 
14
22
 
23
+ @contextmanager
24
+ def _exclusive_lock(path: Path):
25
+ """Exclusive advisory lock on target file (fcntl.flock). Graceful degrade on non-POSIX."""
26
+ if fcntl is None or not path.exists():
27
+ yield
28
+ return
29
+ fd = os.open(str(path), os.O_RDWR)
30
+ try:
31
+ fcntl.flock(fd, fcntl.LOCK_EX)
32
+ yield
33
+ finally:
34
+ try:
35
+ fcntl.flock(fd, fcntl.LOCK_UN)
36
+ finally:
37
+ os.close(fd)
38
+
39
+
15
40
  @dataclass(frozen=True, slots=True)
16
41
  class PatchResult:
17
42
  success: bool
@@ -44,28 +69,31 @@ def apply_patch(
44
69
  ) -> PatchResult:
45
70
  if not path.exists():
46
71
  return _failed(str(path), "missing_file", expected_file_hash, "file not found")
47
- text = path.read_text(encoding="utf-8")
48
- current_hash = file_hash(text)
49
- if expected_file_hash and current_hash != _normalize_hash(expected_file_hash):
50
- return _failed(str(path), "stale_file", expected_file_hash, "file changed since cursor was created")
51
- exact = _exact_spans(text, old_text)
52
- if exact:
53
- start, end = exact[0]
54
- return _commit(path, text, start, end, new_text, "exact", expected_file_hash or current_hash, current_hash, 1.0)
55
- fuzzy = _best_fuzzy_span(text, old_text)
56
- if fuzzy and fuzzy[3] >= fuzzy_threshold and not fuzzy[4]:
57
- return _commit(
58
- path,
59
- text,
60
- fuzzy[0],
61
- fuzzy[1],
62
- new_text,
63
- "fuzzy",
64
- expected_file_hash or current_hash,
65
- current_hash,
66
- fuzzy[3],
67
- )
68
- return _failed(str(path), "no_match", expected_file_hash or current_hash, "patch target not found")
72
+ with _exclusive_lock(path):
73
+ text = path.read_text(encoding="utf-8")
74
+ current_hash = file_hash(text)
75
+ if expected_file_hash and current_hash != _normalize_hash(expected_file_hash):
76
+ return _failed(str(path), "stale_file", expected_file_hash, "file changed since cursor was created")
77
+ exact = _exact_spans(text, old_text)
78
+ if exact:
79
+ start, end = exact[0]
80
+ return _commit(
81
+ path, text, start, end, new_text, "exact", expected_file_hash or current_hash, current_hash, 1.0
82
+ )
83
+ fuzzy = _best_fuzzy_span(text, old_text)
84
+ if fuzzy and fuzzy[3] >= fuzzy_threshold and not fuzzy[4]:
85
+ return _commit(
86
+ path,
87
+ text,
88
+ fuzzy[0],
89
+ fuzzy[1],
90
+ new_text,
91
+ "fuzzy",
92
+ expected_file_hash or current_hash,
93
+ current_hash,
94
+ fuzzy[3],
95
+ )
96
+ return _failed(str(path), "no_match", expected_file_hash or current_hash, "patch target not found")
69
97
 
70
98
 
71
99
  def _normalize_newlines(content: str) -> str:
@@ -143,6 +171,17 @@ def _best_fuzzy_span(text: str, reference: str) -> tuple[int, int, str, float, b
143
171
  return best[0], best[1], best[2], best[3], ambiguous
144
172
 
145
173
 
174
+ def _atomic_write(path: Path, content: str) -> None:
175
+ """Atomic replace via temp in same dir + fsync to prevent corruption on crash."""
176
+ dir_ = path.parent
177
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=dir_, delete=False, suffix=".tmp") as tmp:
178
+ tmp.write(content)
179
+ tmp.flush()
180
+ os.fsync(tmp.fileno())
181
+ tmp_path = Path(tmp.name)
182
+ os.replace(tmp_path, path)
183
+
184
+
146
185
  def _commit(
147
186
  path: Path,
148
187
  text: str,
@@ -155,7 +194,7 @@ def _commit(
155
194
  score: float,
156
195
  ) -> PatchResult:
157
196
  updated = f"{text[:start]}{new_content}{text[end:]}"
158
- path.write_text(updated, encoding="utf-8")
197
+ _atomic_write(path, updated)
159
198
  return PatchResult(True, str(path), strategy, "patch applied", expected, matched, round(score, 4), 1)
160
199
 
161
200
 
@@ -45,14 +45,23 @@ class DoctorResult:
45
45
  version: str
46
46
  checks: tuple[CheckResult, ...]
47
47
 
48
+ @property
49
+ def summary(self) -> str:
50
+ if any(c.status == "fail" for c in self.checks):
51
+ return "fail"
52
+ if any(c.severity == "critical" and c.status == "skip" for c in self.checks):
53
+ return "degraded"
54
+ return "ok"
55
+
48
56
  @property
49
57
  def ok(self) -> bool:
50
- return not any(c.status == "fail" for c in self.checks)
58
+ return self.summary == "ok"
51
59
 
52
60
  def to_json_object(self) -> dict[str, Any]:
53
61
  return {
54
62
  "plugin": self.plugin,
55
63
  "version": self.version,
64
+ "summary": self.summary,
56
65
  "checks": [
57
66
  {
58
67
  "check_id": c.check_id,
@@ -68,9 +77,7 @@ class DoctorResult:
68
77
 
69
78
 
70
79
  class DoctorContext:
71
- def __init__(
72
- self, cwd: Path, hermes_bin: str, run: Callable[[list[str]], subprocess.CompletedProcess]
73
- ) -> None:
80
+ def __init__(self, cwd: Path, hermes_bin: str, run: Callable[[list[str]], subprocess.CompletedProcess]) -> None:
74
81
  self.cwd = cwd
75
82
  self.hermes_bin = hermes_bin
76
83
  self.run = run
@@ -151,14 +158,10 @@ def run_doctor(
151
158
 
152
159
 
153
160
  def render_json(result: DoctorResult) -> str:
154
- return json.dumps(
155
- result.to_json_object(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
156
- )
161
+ return json.dumps(result.to_json_object(), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
157
162
 
158
163
 
159
- def render_text(
160
- result: DoctorResult, catalog: tuple[CatalogEntry, ...], *, verbose: bool = False
161
- ) -> str:
164
+ def render_text(result: DoctorResult, catalog: tuple[CatalogEntry, ...], *, verbose: bool = False) -> str:
162
165
  entry_map = {e.check_id: e for e in catalog}
163
166
  lines: list[str] = []
164
167
  for c in result.checks:
@@ -0,0 +1,355 @@
1
+ """Plugin-specific probes for supercoder doctor. Cross-cutting + selected specific checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+ import importlib.util
7
+ import json
8
+ import os
9
+ import shutil
10
+ import tempfile
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+
14
+ from .framework import DoctorContext
15
+
16
+ PROBES: dict[str, Callable[[DoctorContext], tuple[str, str]]] = {}
17
+
18
+ _HERMES_ABSENT_SKIP = "hermes binary not on PATH — cannot verify"
19
+
20
+
21
+ def _hermes_path(ctx: DoctorContext) -> str | None:
22
+ return shutil.which(ctx.hermes_bin)
23
+
24
+
25
+ def _register(name: str):
26
+ def deco(fn):
27
+ PROBES[name] = fn
28
+ return fn
29
+
30
+ return deco
31
+
32
+
33
+ @_register("hermes_on_path")
34
+ def hermes_on_path(ctx: DoctorContext) -> tuple[str, str]:
35
+ p = _hermes_path(ctx)
36
+ if p:
37
+ return "pass", str(p)
38
+ return "skip", _HERMES_ABSENT_SKIP
39
+
40
+
41
+ @_register("hermes_version")
42
+ def hermes_version(ctx: DoctorContext) -> tuple[str, str]:
43
+ if _hermes_path(ctx) is None:
44
+ return "skip", _HERMES_ABSENT_SKIP
45
+ try:
46
+ cp = ctx.run([ctx.hermes_bin, "--version"])
47
+ if cp.returncode == 0 and "Hermes Agent v" in cp.stdout:
48
+ return "pass", cp.stdout.strip()
49
+ return "fail", cp.stdout.strip() or cp.stderr.strip()
50
+ except Exception as e:
51
+ return "fail", f"run error: {e}"
52
+
53
+
54
+ @_register("hermes_oneshot_flag")
55
+ def hermes_oneshot_flag(ctx: DoctorContext) -> tuple[str, str]:
56
+ if _hermes_path(ctx) is None:
57
+ return "skip", _HERMES_ABSENT_SKIP
58
+ try:
59
+ cp = ctx.run([ctx.hermes_bin, "--help"])
60
+ out = cp.stdout + cp.stderr
61
+ if "-z" in out and "--oneshot" in out:
62
+ return "pass", "present"
63
+ return "fail", "missing in --help"
64
+ except Exception as e:
65
+ return "fail", f"run error: {e}"
66
+
67
+
68
+ @_register("entry_point_registered")
69
+ def entry_point_registered(ctx: DoctorContext) -> tuple[str, str]:
70
+ try:
71
+ eps = importlib.metadata.entry_points(group="hermes_agent.plugins")
72
+ for ep in eps:
73
+ if "cluxion-agentplugin-supercoder" in (ep.name or "").lower() or "cluxion_agentplugin_supercoder" in (
74
+ ep.value or ""
75
+ ):
76
+ mod = ep.load()
77
+ if hasattr(mod, "register") and callable(mod.register):
78
+ return "pass", ep.value or str(ep)
79
+ return "warn", "entry point metadata not present (dev PYTHONPATH ok)"
80
+ except Exception as e:
81
+ return "fail", f"metadata error: {e}"
82
+
83
+
84
+ @_register("toolset_valid")
85
+ def toolset_valid(ctx: DoctorContext) -> tuple[str, str]:
86
+ if _hermes_path(ctx) is None:
87
+ return "skip", _HERMES_ABSENT_SKIP
88
+ try:
89
+ cp = ctx.run([ctx.hermes_bin, "tools", "list"])
90
+ if cp.returncode == 0 and "supercoder" in cp.stdout:
91
+ return "pass", "supercoder present"
92
+ return "fail", "supercoder not in tools list"
93
+ except Exception as e:
94
+ return "fail", f"run error: {e}"
95
+
96
+
97
+ @_register("install_integrity")
98
+ def install_integrity(ctx: DoctorContext) -> tuple[str, str]:
99
+ try:
100
+ from cluxion_agentplugin_supercoder import __version__ as pkg_version
101
+
102
+ dist_version = importlib.metadata.version("cluxion-agentplugin-supercoder")
103
+ if dist_version == pkg_version:
104
+ return "pass", dist_version
105
+ return "warn", f"dist={dist_version} pkg={pkg_version}"
106
+ except Exception as e:
107
+ return "warn", f"version error: {e}"
108
+
109
+
110
+ @_register("native_module_importable")
111
+ def native_module_importable(ctx: DoctorContext) -> tuple[str, str]:
112
+ try:
113
+ mod = __import__("supercoder_index_native")
114
+ if hasattr(mod, "run"):
115
+ return "pass", "imported (native backend available)"
116
+ return "warn", "imported but expected symbols missing"
117
+ except Exception:
118
+ return "warn", "native missing → using fallback (slower)"
119
+
120
+
121
+ # plugin-specific probes (deterministic ones only) - for supercoder we can add if symbols found
122
+ # for now, handler_exception_coverage is cross-cutting
123
+ @_register("handler_exception_coverage")
124
+ def handler_exception_coverage(ctx: DoctorContext) -> tuple[str, str]:
125
+ try:
126
+ from cluxion_agentplugin_supercoder import runner
127
+ from cluxion_agentplugin_supercoder.plugin import _wrap
128
+
129
+ def bad_cb(_payload: dict[str, object]) -> runner.ToolResult:
130
+ raise TypeError("test TypeError for coverage")
131
+
132
+ result = _wrap(bad_cb)({})
133
+ parsed = json.loads(result)
134
+ if parsed.get("ok") is False and "TypeError" in str(parsed.get("error", "")):
135
+ return "pass", "degraded to error JSON"
136
+ return "fail", f"no error json: {result[:100]}"
137
+ except Exception as e:
138
+ return "skip", f"cannot invoke guard: {e}"
139
+
140
+
141
+ # NEW deterministic probes for previously-skipped catalog checks (import-avail, json-det, abi3/sqlite patterns adapted)
142
+ # hermes_requirements_installed (import availability using find_spec to satisfy linter)
143
+ @_register("hermes_requirements_installed")
144
+ def hermes_requirements_installed(ctx: DoctorContext) -> tuple[str, str]:
145
+ try:
146
+ if importlib.util.find_spec("psutil") and importlib.util.find_spec("yaml"):
147
+ return "pass", "psutil+PyYAML importable"
148
+ return "warn", "missing dep"
149
+ except Exception as e:
150
+ return "skip", f"import check error: {e}"
151
+
152
+
153
+ # repo_map_deterministic (json determinism + real call)
154
+ @_register("repo_map_deterministic")
155
+ def repo_map_deterministic(ctx: DoctorContext) -> tuple[str, str]:
156
+ try:
157
+ from cluxion_agentplugin_supercoder.core.repo_map import build_repo_map
158
+
159
+ m1 = build_repo_map(ctx.cwd, budget_chars=2000)
160
+ m2 = build_repo_map(ctx.cwd, budget_chars=2000)
161
+
162
+ def strip(d):
163
+ if isinstance(d, dict):
164
+ return {k: strip(v) for k, v in d.items() if k != "_stats"}
165
+ if isinstance(d, list):
166
+ return [strip(x) for x in d]
167
+ return d
168
+
169
+ if strip(m1) == strip(m2):
170
+ j1 = json.dumps(m1, sort_keys=True)
171
+ j2 = json.dumps(m2, sort_keys=True)
172
+ if j1 == j2:
173
+ return "pass", "deterministic + json roundtrip ok"
174
+ return "warn", "map match but json not"
175
+ return "fail", "non-deterministic"
176
+ except Exception as e:
177
+ return "skip", f"cannot run: {e}"
178
+
179
+
180
+ # ruff_binary_discoverable (real env/path probe)
181
+ @_register("ruff_binary_discoverable")
182
+ def ruff_binary_discoverable(ctx: DoctorContext) -> tuple[str, str]:
183
+ try:
184
+ envb = os.environ.get("CLUXION_SUPERCODER_RUFF_BIN")
185
+ if envb and Path(envb).is_file():
186
+ return "pass", envb
187
+ cands = [Path(ctx.cwd) / ".venv/bin/ruff", shutil.which("ruff")]
188
+ for c in cands:
189
+ if c and Path(c).is_file():
190
+ return "pass", str(c)
191
+ return "warn", "no ruff binary (advisory)"
192
+ except Exception as e:
193
+ return "skip", f"probe error: {e}"
194
+
195
+
196
+ # file_hash_consistency (real check)
197
+ @_register("file_hash_consistency")
198
+ def file_hash_consistency(ctx: DoctorContext) -> tuple[str, str]:
199
+ try:
200
+ from cluxion_agentplugin_supercoder.core.hash_patch import _normalize_newlines, file_hash
201
+
202
+ c = "a=1\r\nb=2"
203
+ if file_hash(c) == file_hash(_normalize_newlines(c)):
204
+ return "pass", "CRLF safe"
205
+ return "fail", "hash mismatch"
206
+ except Exception as e:
207
+ return "skip", f"hash error: {e}"
208
+
209
+
210
+ _SECRET_BLOCKED = "secret file access blocked"
211
+ _ESCAPE_BLOCKED = "workspace escape blocked"
212
+
213
+
214
+ def _assert_tool_blocks(
215
+ tool_fn: Callable[[dict[str, object]], object],
216
+ *,
217
+ cwd: str,
218
+ path: str,
219
+ expected_error: str,
220
+ extra: dict[str, object] | None = None,
221
+ ) -> str | None:
222
+ from cluxion_agentplugin_supercoder import runner
223
+
224
+ payload: dict[str, object] = {"cwd": cwd, "path": path}
225
+ if extra:
226
+ payload.update(extra)
227
+ result = tool_fn(payload)
228
+ if not isinstance(result, runner.ToolResult):
229
+ return f"unexpected result type for {path}"
230
+ if result.ok or result.payload.get("error") != expected_error:
231
+ return f"{tool_fn.__name__} on {path}: ok={result.ok} error={result.payload.get('error')}"
232
+ return None
233
+
234
+
235
+ @_register("path_security_secrets_blocked")
236
+ def path_security_secrets_blocked(ctx: DoctorContext) -> tuple[str, str]:
237
+ try:
238
+ from cluxion_agentplugin_supercoder import runner
239
+
240
+ with tempfile.TemporaryDirectory() as td:
241
+ root = Path(td)
242
+ (root / ".env").write_text("KEY=secret", encoding="utf-8")
243
+ cred = root / "config" / "credentials"
244
+ cred.mkdir(parents=True)
245
+ (cred / "db.json").write_text("{}", encoding="utf-8")
246
+ for rel in (".env", "config/credentials/db.json"):
247
+ for tool_fn, extra in (
248
+ (runner.read_window_tool, None),
249
+ (runner.patch_tool, {"old_text": "x", "new_text": "y", "syntax_gate": False}),
250
+ ):
251
+ err = _assert_tool_blocks(
252
+ tool_fn,
253
+ cwd=str(root),
254
+ path=rel,
255
+ expected_error=_SECRET_BLOCKED,
256
+ extra=extra,
257
+ )
258
+ if err:
259
+ return "fail", err
260
+ return "pass", "read_window_tool + patch_tool block .env and credentials"
261
+ except Exception as e:
262
+ return "skip", f"cannot run: {e}"
263
+
264
+
265
+ @_register("hermes_context_workspace_root")
266
+ def hermes_context_workspace_root(ctx: DoctorContext) -> tuple[str, str]:
267
+ try:
268
+ from cluxion_agentplugin_supercoder import runner
269
+
270
+ with tempfile.TemporaryDirectory() as td:
271
+ base = Path(td)
272
+ outside = base / "outside"
273
+ outside.mkdir()
274
+ (outside / "secret.txt").write_text("leaked", encoding="utf-8")
275
+ workspace = base / "work"
276
+ workspace.mkdir()
277
+ sibling = base / "work2"
278
+ sibling.mkdir()
279
+ (sibling / "x").write_text("leaked", encoding="utf-8")
280
+ for path in ("../outside/secret.txt", "../work2/x"):
281
+ err = _assert_tool_blocks(
282
+ runner.read_window_tool,
283
+ cwd=str(workspace),
284
+ path=path,
285
+ expected_error=_ESCAPE_BLOCKED,
286
+ )
287
+ if err:
288
+ return "fail", err
289
+ return "pass", "traversal + sibling-prefix escape blocked"
290
+ except Exception as e:
291
+ return "skip", f"cannot run: {e}"
292
+
293
+
294
+ @_register("patch_cursor_validity")
295
+ def patch_cursor_validity(ctx: DoctorContext) -> tuple[str, str]:
296
+ try:
297
+ from cluxion_agentplugin_supercoder import runner
298
+ from cluxion_agentplugin_supercoder.core.cursor import read_window
299
+
300
+ with tempfile.TemporaryDirectory() as td:
301
+ root = Path(td)
302
+ target = root / "t.py"
303
+ target.write_text("x=1", encoding="utf-8")
304
+ window = read_window(root, "t.py")
305
+ target.write_text("x=2", encoding="utf-8")
306
+ result = runner.patch_tool(
307
+ {
308
+ "cwd": str(root),
309
+ "path": "t.py",
310
+ "old_text": "x=1",
311
+ "new_text": "x=3",
312
+ "expected_file_hash": window.file_hash,
313
+ "syntax_gate": False,
314
+ "lint_gate": False,
315
+ }
316
+ )
317
+ if result.ok:
318
+ return "fail", "patch applied with stale hash"
319
+ if result.payload.get("strategy") != "stale_file":
320
+ return "fail", f"expected stale_file, got {result.payload.get('strategy')}"
321
+ return "pass", "stale hash blocked"
322
+ except Exception as e:
323
+ return "skip", f"cannot run: {e}"
324
+
325
+
326
+ @_register("stale_cursor_protection_enforced")
327
+ def stale_cursor_protection_enforced(ctx: DoctorContext) -> tuple[str, str]:
328
+ try:
329
+ from cluxion_agentplugin_supercoder import runner
330
+
331
+ with tempfile.TemporaryDirectory() as td:
332
+ root = Path(td)
333
+ (root / "t.py").write_text("x=1", encoding="utf-8")
334
+ result = runner.patch_tool(
335
+ {
336
+ "cwd": str(root),
337
+ "path": "t.py",
338
+ "old_text": "x",
339
+ "new_text": "y",
340
+ "stale_cursor": True,
341
+ "syntax_gate": False,
342
+ "lint_gate": False,
343
+ }
344
+ )
345
+ if result.ok:
346
+ return "fail", "patch applied with stale_cursor=True"
347
+ error = str(result.payload.get("error", ""))
348
+ if "stale cursor" not in error:
349
+ return "fail", f"unexpected error: {error}"
350
+ return "pass", "stale_cursor flag blocked"
351
+ except Exception as e:
352
+ return "skip", f"cannot run: {e}"
353
+
354
+
355
+ # note: other checks in catalog will be reported as skip (no probe)
@@ -73,6 +73,14 @@ def plan(payload: Mapping[str, object]) -> ToolResult:
73
73
  def read_window_tool(payload: Mapping[str, object]) -> ToolResult:
74
74
  root = _workspace(payload)
75
75
  rel = str(payload.get("path", "")).strip()
76
+ gate = pre_tool_gate(
77
+ "read_window",
78
+ payload,
79
+ workspace=root,
80
+ stale_cursor=bool(payload.get("stale_cursor", False)),
81
+ )
82
+ if gate.decision == "block":
83
+ return ToolResult(False, {"error": gate.reason})
76
84
  start = _int(payload.get("start_line", 1), 1)
77
85
  max_lines = _int(payload.get("max_lines", 120), 120)
78
86
  decision = budget_for("inspect", requested_lines=max_lines)
@@ -182,7 +190,10 @@ def repo_map_tool(payload: Mapping[str, object]) -> ToolResult:
182
190
  result = repo_map.build_repo_map(
183
191
  _workspace(payload),
184
192
  max_files=_int(payload.get("max_files", repo_map.DEFAULT_MAX_FILES), repo_map.DEFAULT_MAX_FILES),
185
- max_symbols_per_file=_int(payload.get("max_symbols_per_file", repo_map.DEFAULT_MAX_SYMBOLS_PER_FILE), repo_map.DEFAULT_MAX_SYMBOLS_PER_FILE),
193
+ max_symbols_per_file=_int(
194
+ payload.get("max_symbols_per_file", repo_map.DEFAULT_MAX_SYMBOLS_PER_FILE),
195
+ repo_map.DEFAULT_MAX_SYMBOLS_PER_FILE,
196
+ ),
186
197
  budget_chars=_int(payload.get("budget_chars", repo_map.DEFAULT_BUDGET_CHARS), repo_map.DEFAULT_BUDGET_CHARS),
187
198
  )
188
199
  return ToolResult(bool(result.get("ok", False)), {key: value for key, value in result.items() if key != "ok"})