credence-governor-claude-code 0.1.0__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 (19) hide show
  1. credence_governor_claude_code-0.1.0/PKG-INFO +149 -0
  2. credence_governor_claude_code-0.1.0/README.md +131 -0
  3. credence_governor_claude_code-0.1.0/credence_governor_claude_code/__init__.py +15 -0
  4. credence_governor_claude_code-0.1.0/credence_governor_claude_code/client.py +38 -0
  5. credence_governor_claude_code-0.1.0/credence_governor_claude_code/effectors.py +41 -0
  6. credence_governor_claude_code-0.1.0/credence_governor_claude_code/hook.py +72 -0
  7. credence_governor_claude_code-0.1.0/credence_governor_claude_code/install.py +92 -0
  8. credence_governor_claude_code-0.1.0/credence_governor_claude_code/transcript.py +175 -0
  9. credence_governor_claude_code-0.1.0/credence_governor_claude_code.egg-info/PKG-INFO +149 -0
  10. credence_governor_claude_code-0.1.0/credence_governor_claude_code.egg-info/SOURCES.txt +17 -0
  11. credence_governor_claude_code-0.1.0/credence_governor_claude_code.egg-info/dependency_links.txt +1 -0
  12. credence_governor_claude_code-0.1.0/credence_governor_claude_code.egg-info/entry_points.txt +3 -0
  13. credence_governor_claude_code-0.1.0/credence_governor_claude_code.egg-info/requires.txt +5 -0
  14. credence_governor_claude_code-0.1.0/credence_governor_claude_code.egg-info/top_level.txt +1 -0
  15. credence_governor_claude_code-0.1.0/pyproject.toml +38 -0
  16. credence_governor_claude_code-0.1.0/setup.cfg +4 -0
  17. credence_governor_claude_code-0.1.0/tests/test_effectors.py +36 -0
  18. credence_governor_claude_code-0.1.0/tests/test_roundtrip.py +69 -0
  19. credence_governor_claude_code-0.1.0/tests/test_transcript.py +114 -0
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: credence-governor-claude-code
3
+ Version: 0.1.0
4
+ Summary: Claude Code adapter for credence-governor — a thin PreToolUse hook that Bayesian-governs tool calls (proceed/block/ask) via the governor daemon.
5
+ Author: Guy Freeman
6
+ License-Expression: AGPL-3.0-or-later
7
+ Project-URL: Homepage, https://github.com/gfrmin/credence-governor
8
+ Project-URL: Repository, https://github.com/gfrmin/credence-governor
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=7.4; extra == "dev"
16
+ Requires-Dist: ruff; extra == "dev"
17
+ Requires-Dist: credence-governor-core; extra == "dev"
18
+
19
+ # credence-governor — Claude Code adapter
20
+
21
+ A thin **Claude Code** adapter for [credence-governor](../../README.md): a
22
+ `PreToolUse` subprocess hook that Bayesian-governs tool calls (`proceed` /
23
+ `block` / `ask`) by asking the governor daemon, which runs the one EU-max reasoner
24
+ over the Credence engine.
25
+
26
+ The adapter carries **no probabilistic code and no feature logic**. Its only job is
27
+ to translate Claude Code's native hook events into the harness-neutral wire shape and
28
+ render the daemon's decision. Feature & taint extraction is server-side in the daemon
29
+ (single-sourced — DRY).
30
+
31
+ ## How it works
32
+
33
+ ```
34
+ Claude Code ──PreToolUse JSON on stdin──▶ credence-governor-claude-code (this hook)
35
+ │ transcript_path JSONL → neutral messages
36
+ ▼ POST /decide (tool, input, session)
37
+ governor daemon (credence-governor-core)
38
+ │ server-side feature/taint extraction
39
+ ▼ one EU-max decision over the skin wire
40
+ credence-skin engine (Julia)
41
+ ◀──permissionDecision JSON on stdout──────┘
42
+ ```
43
+
44
+ - **`transcript.py`** — replays the session transcript (`transcript_path`) into the
45
+ neutral `messages` list the extractors expect (`user` / `assistant` / `tool_call` /
46
+ `tool_result`). The proposed call — already persisted to the transcript before
47
+ `PreToolUse` fires — is excluded so loop counts see priors only. Sidechain
48
+ (subagent) turns are excluded by default.
49
+ - **`client.py`** — `POST /decide` (stdlib `urllib`).
50
+ - **`effectors.py`** — maps the governor action to a `PreToolUse` decision.
51
+ - **`hook.py`** — the entry point (`credence-governor-claude-code`).
52
+
53
+ ## Overlay semantics (the governor never weakens the host)
54
+
55
+ | governor action | Claude Code decision | effect |
56
+ |---|---|---|
57
+ | `proceed` | *(nothing emitted)* | defer to Claude Code's own permission rules |
58
+ | `block` | `deny` + reason | refuse regardless of native rules |
59
+ | `ask` | `ask` + reason | force a user prompt even if native rules would auto-allow |
60
+
61
+ `permissionDecision: "allow"` is **never** emitted — that would bypass Claude Code's
62
+ own safety prompts. The governor can only *add* friction, not remove it.
63
+
64
+ **Fail-open is absolute.** A malformed event, a daemon that is down, a timeout, or any
65
+ error prints nothing and exits 0 — the call proceeds through Claude Code's normal
66
+ flow. Run with `CREDENCE_GOVERNOR_DEBUG=1` to log decisions/failures to stderr.
67
+
68
+ ## Capability boundary (Claude Code, hook transport)
69
+
70
+ | Dimension | Supported |
71
+ |---|---|
72
+ | Waste gating (loops / repetition) | ✅ |
73
+ | Safety (taint-flow / exfil / injected imperatives) | ✅ |
74
+ | Routing / model selection | ❌ (no model-resolve hook) |
75
+ | Cost / dollars-saved | ◑ observe-only |
76
+ | Online ask-response learning | ◌ deferred — see below |
77
+
78
+ v1 gates **`PreToolUse` only**. `PostToolUse` / `UserPromptSubmit` are intentionally
79
+ not wired: neither carries a clean approval label, and treating "the call completed"
80
+ as belief evidence would be a second learning path (a constitutional violation), not
81
+ just a weak one. The warm-trained brain governs; the daemon logs every decision.
82
+ Online ask-learning on Claude Code (inferring the user's reply from the next
83
+ transcript turn) is an open design question, deferred.
84
+
85
+ ## Install
86
+
87
+ **1. Start the daemon** — every adapter shares one local daemon; see the
88
+ [core README](../../packages/governor_core) or the
89
+ [repo quickstart](../../README.md#install). In short, from a clone of the repo:
90
+
91
+ ```bash
92
+ pip install -e packages/governor_core -e ~/git/credence/apps/skin/clients/python
93
+ CREDENCE_ENGINE_DIR=~/git/credence credence-governor-daemon # or CREDENCE_SKIN_COMMAND="docker run … credence-skin@<digest>"
94
+ ```
95
+
96
+ **2. Register the hook** — two ways; both end at the same `PreToolUse` hook.
97
+
98
+ ### Method A — as a Claude Code plugin (recommended; no `pip install`)
99
+
100
+ The hook is pure stdlib, so the plugin **bundles** it and runs it via
101
+ `${CLAUDE_PLUGIN_ROOT}` — there's nothing to `pip install`. Inside Claude Code:
102
+
103
+ ```
104
+ /plugin marketplace add gfrmin/credence-governor
105
+ /plugin install credence-governor-claude-code@credence-governor
106
+ ```
107
+
108
+ The hook is active in new sessions (run `/reload-plugins` to pick it up in the current
109
+ one). Manage or remove it from the `/plugin` menu. The plugin reuses the *same*
110
+ `credence_governor_claude_code` package this directory ships — `plugin_hook.py` is a
111
+ thin launcher that puts it on `sys.path` and calls the same entry point the pip console
112
+ script uses (one implementation, two install paths). Manifests:
113
+ [`.claude-plugin/plugin.json`](.claude-plugin/plugin.json),
114
+ [`hooks/hooks.json`](hooks/hooks.json), and the repo-root
115
+ [`.claude-plugin/marketplace.json`](../../.claude-plugin/marketplace.json).
116
+
117
+ ### Method B — as a pip package + settings hook
118
+
119
+ If you'd rather manage it with pip (or script the registration):
120
+
121
+ ```bash
122
+ pip install -e adapters/claude-code # not yet on PyPI; once published: pip install credence-governor-claude-code
123
+ credence-governor-cc-install # → ~/.claude/settings.json (idempotent)
124
+ # --project → ./.claude/settings.json · --uninstall → remove
125
+ ```
126
+
127
+ Either way the hook has **no runtime dependencies** (pure stdlib); `credence-governor-core`
128
+ is needed only for the daemon and the parity test. Static config example:
129
+ [`settings.example.json`](settings.example.json).
130
+
131
+ ### Environment
132
+
133
+ | Variable | Default | Meaning |
134
+ |---|---|---|
135
+ | `CREDENCE_GOVERNOR_URL` | `http://127.0.0.1:8787` | governor daemon base URL |
136
+ | `CREDENCE_GOVERNOR_TIMEOUT` | `5.0` | per-call `/decide` timeout (s); on timeout → fail open |
137
+ | `CREDENCE_GOVERNOR_PROFILE` | *(none)* | utility profile passed to the daemon (e.g. `flow-guard`) |
138
+ | `CREDENCE_GOVERNOR_DEBUG` | *(off)* | log decisions/failures to stderr |
139
+
140
+ ## Tests
141
+
142
+ ```bash
143
+ python -m pytest tests -q
144
+ ```
145
+
146
+ Pure-stdlib at runtime; the round-trip parity test (`test_roundtrip.py`) imports
147
+ `credence-governor-core` to assert the wire payload deserialises through the daemon's
148
+ `event_and_session_from_payload` to the Session the extractors expect — it is skipped
149
+ if the core isn't importable.
@@ -0,0 +1,131 @@
1
+ # credence-governor — Claude Code adapter
2
+
3
+ A thin **Claude Code** adapter for [credence-governor](../../README.md): a
4
+ `PreToolUse` subprocess hook that Bayesian-governs tool calls (`proceed` /
5
+ `block` / `ask`) by asking the governor daemon, which runs the one EU-max reasoner
6
+ over the Credence engine.
7
+
8
+ The adapter carries **no probabilistic code and no feature logic**. Its only job is
9
+ to translate Claude Code's native hook events into the harness-neutral wire shape and
10
+ render the daemon's decision. Feature & taint extraction is server-side in the daemon
11
+ (single-sourced — DRY).
12
+
13
+ ## How it works
14
+
15
+ ```
16
+ Claude Code ──PreToolUse JSON on stdin──▶ credence-governor-claude-code (this hook)
17
+ │ transcript_path JSONL → neutral messages
18
+ ▼ POST /decide (tool, input, session)
19
+ governor daemon (credence-governor-core)
20
+ │ server-side feature/taint extraction
21
+ ▼ one EU-max decision over the skin wire
22
+ credence-skin engine (Julia)
23
+ ◀──permissionDecision JSON on stdout──────┘
24
+ ```
25
+
26
+ - **`transcript.py`** — replays the session transcript (`transcript_path`) into the
27
+ neutral `messages` list the extractors expect (`user` / `assistant` / `tool_call` /
28
+ `tool_result`). The proposed call — already persisted to the transcript before
29
+ `PreToolUse` fires — is excluded so loop counts see priors only. Sidechain
30
+ (subagent) turns are excluded by default.
31
+ - **`client.py`** — `POST /decide` (stdlib `urllib`).
32
+ - **`effectors.py`** — maps the governor action to a `PreToolUse` decision.
33
+ - **`hook.py`** — the entry point (`credence-governor-claude-code`).
34
+
35
+ ## Overlay semantics (the governor never weakens the host)
36
+
37
+ | governor action | Claude Code decision | effect |
38
+ |---|---|---|
39
+ | `proceed` | *(nothing emitted)* | defer to Claude Code's own permission rules |
40
+ | `block` | `deny` + reason | refuse regardless of native rules |
41
+ | `ask` | `ask` + reason | force a user prompt even if native rules would auto-allow |
42
+
43
+ `permissionDecision: "allow"` is **never** emitted — that would bypass Claude Code's
44
+ own safety prompts. The governor can only *add* friction, not remove it.
45
+
46
+ **Fail-open is absolute.** A malformed event, a daemon that is down, a timeout, or any
47
+ error prints nothing and exits 0 — the call proceeds through Claude Code's normal
48
+ flow. Run with `CREDENCE_GOVERNOR_DEBUG=1` to log decisions/failures to stderr.
49
+
50
+ ## Capability boundary (Claude Code, hook transport)
51
+
52
+ | Dimension | Supported |
53
+ |---|---|
54
+ | Waste gating (loops / repetition) | ✅ |
55
+ | Safety (taint-flow / exfil / injected imperatives) | ✅ |
56
+ | Routing / model selection | ❌ (no model-resolve hook) |
57
+ | Cost / dollars-saved | ◑ observe-only |
58
+ | Online ask-response learning | ◌ deferred — see below |
59
+
60
+ v1 gates **`PreToolUse` only**. `PostToolUse` / `UserPromptSubmit` are intentionally
61
+ not wired: neither carries a clean approval label, and treating "the call completed"
62
+ as belief evidence would be a second learning path (a constitutional violation), not
63
+ just a weak one. The warm-trained brain governs; the daemon logs every decision.
64
+ Online ask-learning on Claude Code (inferring the user's reply from the next
65
+ transcript turn) is an open design question, deferred.
66
+
67
+ ## Install
68
+
69
+ **1. Start the daemon** — every adapter shares one local daemon; see the
70
+ [core README](../../packages/governor_core) or the
71
+ [repo quickstart](../../README.md#install). In short, from a clone of the repo:
72
+
73
+ ```bash
74
+ pip install -e packages/governor_core -e ~/git/credence/apps/skin/clients/python
75
+ CREDENCE_ENGINE_DIR=~/git/credence credence-governor-daemon # or CREDENCE_SKIN_COMMAND="docker run … credence-skin@<digest>"
76
+ ```
77
+
78
+ **2. Register the hook** — two ways; both end at the same `PreToolUse` hook.
79
+
80
+ ### Method A — as a Claude Code plugin (recommended; no `pip install`)
81
+
82
+ The hook is pure stdlib, so the plugin **bundles** it and runs it via
83
+ `${CLAUDE_PLUGIN_ROOT}` — there's nothing to `pip install`. Inside Claude Code:
84
+
85
+ ```
86
+ /plugin marketplace add gfrmin/credence-governor
87
+ /plugin install credence-governor-claude-code@credence-governor
88
+ ```
89
+
90
+ The hook is active in new sessions (run `/reload-plugins` to pick it up in the current
91
+ one). Manage or remove it from the `/plugin` menu. The plugin reuses the *same*
92
+ `credence_governor_claude_code` package this directory ships — `plugin_hook.py` is a
93
+ thin launcher that puts it on `sys.path` and calls the same entry point the pip console
94
+ script uses (one implementation, two install paths). Manifests:
95
+ [`.claude-plugin/plugin.json`](.claude-plugin/plugin.json),
96
+ [`hooks/hooks.json`](hooks/hooks.json), and the repo-root
97
+ [`.claude-plugin/marketplace.json`](../../.claude-plugin/marketplace.json).
98
+
99
+ ### Method B — as a pip package + settings hook
100
+
101
+ If you'd rather manage it with pip (or script the registration):
102
+
103
+ ```bash
104
+ pip install -e adapters/claude-code # not yet on PyPI; once published: pip install credence-governor-claude-code
105
+ credence-governor-cc-install # → ~/.claude/settings.json (idempotent)
106
+ # --project → ./.claude/settings.json · --uninstall → remove
107
+ ```
108
+
109
+ Either way the hook has **no runtime dependencies** (pure stdlib); `credence-governor-core`
110
+ is needed only for the daemon and the parity test. Static config example:
111
+ [`settings.example.json`](settings.example.json).
112
+
113
+ ### Environment
114
+
115
+ | Variable | Default | Meaning |
116
+ |---|---|---|
117
+ | `CREDENCE_GOVERNOR_URL` | `http://127.0.0.1:8787` | governor daemon base URL |
118
+ | `CREDENCE_GOVERNOR_TIMEOUT` | `5.0` | per-call `/decide` timeout (s); on timeout → fail open |
119
+ | `CREDENCE_GOVERNOR_PROFILE` | *(none)* | utility profile passed to the daemon (e.g. `flow-guard`) |
120
+ | `CREDENCE_GOVERNOR_DEBUG` | *(off)* | log decisions/failures to stderr |
121
+
122
+ ## Tests
123
+
124
+ ```bash
125
+ python -m pytest tests -q
126
+ ```
127
+
128
+ Pure-stdlib at runtime; the round-trip parity test (`test_roundtrip.py`) imports
129
+ `credence-governor-core` to assert the wire payload deserialises through the daemon's
130
+ `event_and_session_from_payload` to the Session the extractors expect — it is skipped
131
+ if the core isn't importable.
@@ -0,0 +1,15 @@
1
+ """credence-governor-claude-code — the Claude Code adapter.
2
+
3
+ A thin subprocess-hook shim: it translates Claude Code's native PreToolUse events
4
+ into the harness-neutral wire shape and asks the governor daemon (credence-governor-core)
5
+ for a proceed/block/ask decision. Carries no probabilistic code and no feature logic
6
+ — extraction is server-side in the daemon (DRY). The adapter's only job is
7
+ native-events -> neutral schema (+ render the decision).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .hook import handle, main
13
+ from .transcript import messages_from_transcript, session_from_hook
14
+
15
+ __all__ = ["main", "handle", "session_from_hook", "messages_from_transcript"]
@@ -0,0 +1,38 @@
1
+ """client.py — POST the neutral event to the governor daemon's `/decide`.
2
+
3
+ Zero-dependency (urllib): the hook is a short-lived subprocess that must start fast
4
+ and never block a tool call for long. The caller treats every failure as fail-open.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import urllib.request
12
+ from typing import Any
13
+
14
+ DEFAULT_URL = "http://127.0.0.1:8787"
15
+ DEFAULT_TIMEOUT = 5.0
16
+
17
+
18
+ def base_url() -> str:
19
+ return os.environ.get("CREDENCE_GOVERNOR_URL", DEFAULT_URL)
20
+
21
+
22
+ def timeout_s() -> float:
23
+ try:
24
+ return float(os.environ.get("CREDENCE_GOVERNOR_TIMEOUT", DEFAULT_TIMEOUT))
25
+ except (TypeError, ValueError):
26
+ return DEFAULT_TIMEOUT
27
+
28
+
29
+ def decide(payload: dict[str, Any]) -> dict[str, Any]:
30
+ """POST /decide and return the daemon's JSON ({action, features, event_id}).
31
+ Raises on transport/HTTP error — the caller fails open."""
32
+ url = base_url().rstrip("/") + "/decide"
33
+ data = json.dumps(payload).encode("utf-8")
34
+ req = urllib.request.Request(
35
+ url, data=data, headers={"content-type": "application/json"}, method="POST"
36
+ )
37
+ with urllib.request.urlopen(req, timeout=timeout_s()) as resp: # noqa: S310 (localhost daemon)
38
+ return json.loads(resp.read().decode("utf-8"))
@@ -0,0 +1,41 @@
1
+ """effectors.py — a governor action -> a Claude Code PreToolUse decision.
2
+
3
+ The governor is an OVERLAY on Claude Code's native permission system, so it may only
4
+ ever ADD restriction, never remove it:
5
+
6
+ proceed -> None (emit nothing: defer to Claude Code's own permission rules)
7
+ block -> deny (refuse regardless of native rules)
8
+ ask -> ask (force a user prompt even if native rules would auto-allow)
9
+
10
+ We deliberately never emit permissionDecision "allow": that would bypass Claude
11
+ Code's native safety prompts, i.e. the governance layer weakening the host. A
12
+ returned None means "print nothing", which leaves the call to the normal flow.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ _DECISION = {"proceed": None, "block": "deny", "ask": "ask"}
20
+
21
+
22
+ def _reason(action: str, tool_name: str) -> str:
23
+ tool = tool_name or "this tool"
24
+ if action == "block":
25
+ return f"credence-governor refused `{tool}`: low expected utility under the current belief (likely a loop or unsafe action)."
26
+ return f"credence-governor wants confirmation before `{tool}` runs."
27
+
28
+
29
+ def pretooluse_output(action: str, tool_name: str = "") -> dict[str, Any] | None:
30
+ """The JSON to print on stdout for a PreToolUse hook, or None to print nothing
31
+ (proceed / unknown action -> defer to Claude Code)."""
32
+ decision = _DECISION.get(action)
33
+ if decision is None:
34
+ return None
35
+ return {
36
+ "hookSpecificOutput": {
37
+ "hookEventName": "PreToolUse",
38
+ "permissionDecision": decision,
39
+ "permissionDecisionReason": _reason(action, tool_name),
40
+ }
41
+ }
@@ -0,0 +1,72 @@
1
+ """hook.py — the Claude Code subprocess-hook entry point.
2
+
3
+ Registered as a `PreToolUse` hook (see install.py / settings.example.json). On each
4
+ proposed tool call Claude Code pipes a JSON event on stdin; we translate it to the
5
+ neutral wire shape, ask the governor daemon for a decision, and print a PreToolUse
6
+ permission decision on stdout.
7
+
8
+ v1 gates PreToolUse only. PostToolUse / UserPromptSubmit are intentionally NOT wired:
9
+ neither carries a clean approval label, and recording "the call completed" as belief
10
+ evidence would be a second learning path (Invariant 1) — wrong, not just weak. Online
11
+ ask-learning on Claude Code is an open design question (transcript-inference), so v1
12
+ is observation-only: the warm-trained brain governs, the daemon logs every decision.
13
+
14
+ Fail-open is absolute: any malformed input, daemon-down, timeout, or non-PreToolUse
15
+ event prints nothing and exits 0, leaving the call to Claude Code's normal flow. The
16
+ governor can add friction (deny / ask) but never removes the host's own safety.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import sys
24
+ from typing import Any
25
+
26
+ from . import client, effectors, transcript
27
+
28
+
29
+ def _debug(msg: str) -> None:
30
+ if os.environ.get("CREDENCE_GOVERNOR_DEBUG"):
31
+ sys.stderr.write(f"credence-governor: {msg}\n")
32
+
33
+
34
+ def _read_stdin() -> dict[str, Any]:
35
+ raw = sys.stdin.read()
36
+ return json.loads(raw) if raw and raw.strip() else {}
37
+
38
+
39
+ def handle(hook_input: dict[str, Any]) -> dict[str, Any] | None:
40
+ """Pure core: a PreToolUse hook payload -> the stdout JSON (or None to defer).
41
+ Raises on daemon failure so `main` can fail open around it."""
42
+ if (hook_input.get("hook_event_name") or "") != "PreToolUse":
43
+ return None # v1 gates only PreToolUse
44
+ payload = transcript.session_from_hook(hook_input)
45
+ profile = os.environ.get("CREDENCE_GOVERNOR_PROFILE")
46
+ if profile:
47
+ payload["profile"] = profile
48
+ result = client.decide(payload)
49
+ action = str(result.get("action") or "proceed")
50
+ _debug(f"{payload['tool_name']} -> {action} ({result.get('features')})")
51
+ return effectors.pretooluse_output(action, payload["tool_name"])
52
+
53
+
54
+ def main() -> int:
55
+ try:
56
+ hook_input = _read_stdin()
57
+ except (ValueError, OSError) as err:
58
+ _debug(f"unreadable stdin (failing open): {err}")
59
+ return 0
60
+ try:
61
+ out = handle(hook_input)
62
+ except Exception as err: # fail-open: daemon down, timeout, anything
63
+ _debug(f"decision error (failing open): {err}")
64
+ return 0
65
+ if out is not None:
66
+ sys.stdout.write(json.dumps(out))
67
+ sys.stdout.flush()
68
+ return 0
69
+
70
+
71
+ if __name__ == "__main__":
72
+ raise SystemExit(main())
@@ -0,0 +1,92 @@
1
+ """install.py — idempotently register (or remove) the PreToolUse hook in a Claude
2
+ Code settings.json.
3
+
4
+ credence-governor-cc-install # -> ~/.claude/settings.json
5
+ credence-governor-cc-install --project # -> ./.claude/settings.json
6
+ credence-governor-cc-install --path X # -> X
7
+ credence-governor-cc-install --uninstall
8
+
9
+ The hook command defaults to the installed console script `credence-governor-claude-code`.
10
+ Existing settings are preserved; we only touch hooks.PreToolUse, and dedupe by command.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import sys
19
+ from typing import Any
20
+
21
+ HOOK_COMMAND = "credence-governor-claude-code"
22
+ _MATCHER = "*" # all tools
23
+
24
+
25
+ def _load(path: str) -> dict[str, Any]:
26
+ if not os.path.exists(path):
27
+ return {}
28
+ with open(path, encoding="utf-8") as fh:
29
+ text = fh.read().strip()
30
+ return json.loads(text) if text else {}
31
+
32
+
33
+ def _save(path: str, data: dict[str, Any]) -> None:
34
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
35
+ with open(path, "w", encoding="utf-8") as fh:
36
+ json.dump(data, fh, indent=2)
37
+ fh.write("\n")
38
+
39
+
40
+ def _command_present(entries: list[dict[str, Any]], command: str) -> bool:
41
+ for entry in entries:
42
+ for h in entry.get("hooks", []):
43
+ if h.get("type") == "command" and h.get("command") == command:
44
+ return True
45
+ return False
46
+
47
+
48
+ def add(settings: dict[str, Any], command: str = HOOK_COMMAND) -> dict[str, Any]:
49
+ hooks = settings.setdefault("hooks", {})
50
+ pre = hooks.setdefault("PreToolUse", [])
51
+ if not _command_present(pre, command):
52
+ pre.append({"matcher": _MATCHER, "hooks": [{"type": "command", "command": command}]})
53
+ return settings
54
+
55
+
56
+ def remove(settings: dict[str, Any], command: str = HOOK_COMMAND) -> dict[str, Any]:
57
+ pre = settings.get("hooks", {}).get("PreToolUse", [])
58
+ for entry in pre:
59
+ entry["hooks"] = [
60
+ h for h in entry.get("hooks", []) if not (h.get("type") == "command" and h.get("command") == command)
61
+ ]
62
+ settings.get("hooks", {})["PreToolUse"] = [e for e in pre if e.get("hooks")]
63
+ return settings
64
+
65
+
66
+ def _target(args: argparse.Namespace) -> str:
67
+ if args.path:
68
+ return args.path
69
+ if args.project:
70
+ return os.path.join(os.getcwd(), ".claude", "settings.json")
71
+ return os.path.join(os.path.expanduser("~"), ".claude", "settings.json")
72
+
73
+
74
+ def main() -> int:
75
+ ap = argparse.ArgumentParser(description="Register the credence-governor PreToolUse hook.")
76
+ ap.add_argument("--project", action="store_true", help="install into ./.claude/settings.json")
77
+ ap.add_argument("--path", help="explicit settings.json path")
78
+ ap.add_argument("--command", default=HOOK_COMMAND, help="hook command to register")
79
+ ap.add_argument("--uninstall", action="store_true", help="remove the hook instead")
80
+ args = ap.parse_args()
81
+
82
+ path = _target(args)
83
+ settings = _load(path)
84
+ settings = remove(settings, args.command) if args.uninstall else add(settings, args.command)
85
+ _save(path, settings)
86
+ verb = "removed from" if args.uninstall else "installed into"
87
+ sys.stdout.write(f"credence-governor PreToolUse hook {verb} {path}\n")
88
+ return 0
89
+
90
+
91
+ if __name__ == "__main__":
92
+ raise SystemExit(main())
@@ -0,0 +1,175 @@
1
+ """transcript.py — Claude Code transcript JSONL -> the neutral wire `session`.
2
+
3
+ The adapter's whole job is native-events -> the harness-neutral schema the daemon
4
+ extracts from (see ../../packages/governor_core/credence_governor_core/schema.py).
5
+ A Claude Code PreToolUse hook hands us `transcript_path` (a JSONL of the session so
6
+ far) + the proposed `tool_name`/`tool_input`. We replay the transcript into the
7
+ `messages` list the server-side extractors expect:
8
+
9
+ user — a real user prompt (drives time-since-user)
10
+ assistant — model prose (kept only as a turn marker)
11
+ tool_call — an assistant `tool_use` block (drives repetition / parent-tool / loops)
12
+ tool_result — a user `tool_result` block (the taint SOURCE for safety)
13
+
14
+ Two correctness points the extractors depend on:
15
+ • the PROPOSED call must NOT appear in `messages` (repetition/identical_call count
16
+ PRIOR calls only). Claude Code persists the assistant `tool_use` to the transcript
17
+ *before* firing PreToolUse, so we drop the trailing tool_call iff it matches.
18
+ • sidechain (subagent) turns are excluded by default — they would pollute the main
19
+ thread's loop counts with a subagent's unrelated calls.
20
+
21
+ Pure stdlib, no governor-core import: the hook must start fast and fail open. The
22
+ shared contract is the JSON key set, not shared code.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ from typing import Any, Iterable
30
+
31
+
32
+ def _iter_jsonl(path: str) -> Iterable[dict[str, Any]]:
33
+ with open(path, encoding="utf-8") as fh:
34
+ for line in fh:
35
+ line = line.strip()
36
+ if not line:
37
+ continue
38
+ try:
39
+ rec = json.loads(line)
40
+ except (ValueError, TypeError):
41
+ continue
42
+ if isinstance(rec, dict):
43
+ yield rec
44
+
45
+
46
+ def _blocks(content: Any) -> list[Any]:
47
+ """Normalise a message `content` to a list of blocks (a bare string is one
48
+ text block)."""
49
+ if isinstance(content, str):
50
+ return [{"type": "text", "text": content}]
51
+ if isinstance(content, list):
52
+ return content
53
+ return []
54
+
55
+
56
+ def result_to_text(content: Any) -> str:
57
+ """Flatten a tool_result's content to plain text so taint tokens read cleanly.
58
+ Claude Code tool_result content is a string or a list of `{type:text,text}` (and
59
+ occasionally nested `{content:[...]}`)."""
60
+ if content is None:
61
+ return ""
62
+ if isinstance(content, str):
63
+ return content
64
+ if isinstance(content, list):
65
+ parts: list[str] = []
66
+ for b in content:
67
+ if isinstance(b, dict):
68
+ if b.get("type") == "text" and isinstance(b.get("text"), str):
69
+ parts.append(b["text"])
70
+ elif "content" in b:
71
+ parts.append(result_to_text(b["content"]))
72
+ elif isinstance(b, str):
73
+ parts.append(b)
74
+ return "\n".join(p for p in parts if p)
75
+ try:
76
+ return json.dumps(content)
77
+ except (TypeError, ValueError):
78
+ return str(content)
79
+
80
+
81
+ def _has_text(blocks: list[Any]) -> bool:
82
+ for b in blocks:
83
+ if isinstance(b, dict) and b.get("type") == "text" and (b.get("text") or "").strip():
84
+ return True
85
+ if isinstance(b, str) and b.strip():
86
+ return True
87
+ return False
88
+
89
+
90
+ def messages_from_transcript(path: str, include_sidechain: bool = False) -> list[dict[str, Any]]:
91
+ """Replay a Claude Code transcript into neutral wire messages, in order."""
92
+ out: list[dict[str, Any]] = []
93
+ for rec in _iter_jsonl(path):
94
+ if rec.get("type") not in ("user", "assistant"):
95
+ continue
96
+ if rec.get("isSidechain") and not include_sidechain:
97
+ continue
98
+ ts = rec.get("timestamp")
99
+ message = rec.get("message") or {}
100
+ role = message.get("role")
101
+ blocks = _blocks(message.get("content"))
102
+
103
+ if role == "assistant":
104
+ for b in blocks:
105
+ if not isinstance(b, dict):
106
+ continue
107
+ if b.get("type") == "tool_use":
108
+ out.append(
109
+ {
110
+ "role": "tool_call",
111
+ "tool_name": b.get("name"),
112
+ "input": b.get("input"),
113
+ "timestamp": ts,
114
+ }
115
+ )
116
+ elif b.get("type") == "text" and (b.get("text") or "").strip():
117
+ out.append({"role": "assistant", "timestamp": ts})
118
+ elif role == "user":
119
+ for b in blocks:
120
+ if isinstance(b, dict) and b.get("type") == "tool_result":
121
+ out.append(
122
+ {"role": "tool_result", "result": result_to_text(b.get("content")), "timestamp": ts}
123
+ )
124
+ # A user record carrying tool_result blocks is a tool delivery, not a
125
+ # human turn; only emit a user message when there is genuine user text.
126
+ if _has_text(blocks):
127
+ out.append({"role": "user", "timestamp": ts})
128
+ return out
129
+
130
+
131
+ def drop_proposed(messages: list[dict[str, Any]], tool_name: str, tool_input: Any) -> list[dict[str, Any]]:
132
+ """Remove the trailing tool_call iff it is the proposed call (already persisted
133
+ to the transcript by the time PreToolUse fires). Only the LAST tool_call is a
134
+ candidate; if it doesn't match (transcript not yet flushed) nothing is dropped."""
135
+ for i in range(len(messages) - 1, -1, -1):
136
+ if messages[i].get("role") == "tool_call":
137
+ if messages[i].get("tool_name") == tool_name and messages[i].get("input") == tool_input:
138
+ del messages[i]
139
+ break
140
+ return messages
141
+
142
+
143
+ def find_project_root(cwd: str) -> str:
144
+ """Nearest enclosing dir containing a `.git` (file or dir), or "" if none —
145
+ feeds the working-directory-relative feature (project-root/subdir/outside)."""
146
+ cur = os.path.abspath(cwd) if cwd else ""
147
+ while cur and cur != os.path.dirname(cur):
148
+ if os.path.exists(os.path.join(cur, ".git")):
149
+ return cur
150
+ cur = os.path.dirname(cur)
151
+ return ""
152
+
153
+
154
+ def session_from_hook(hook_input: dict[str, Any]) -> dict[str, Any]:
155
+ """A PreToolUse hook payload -> the `/decide` request body the daemon extracts
156
+ from (neutral AgentToolEvent + Session, snake_case keys)."""
157
+ cwd = hook_input.get("cwd") or ""
158
+ transcript = hook_input.get("transcript_path") or ""
159
+ tool_name = hook_input.get("tool_name") or ""
160
+ tool_input = hook_input.get("tool_input")
161
+
162
+ messages: list[dict[str, Any]] = []
163
+ if transcript and os.path.exists(transcript):
164
+ messages = messages_from_transcript(transcript)
165
+ drop_proposed(messages, tool_name, tool_input)
166
+
167
+ return {
168
+ "tool_name": tool_name,
169
+ "input": tool_input,
170
+ "session": {
171
+ "cwd": cwd,
172
+ "project_root": find_project_root(cwd),
173
+ "messages": messages,
174
+ },
175
+ }
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: credence-governor-claude-code
3
+ Version: 0.1.0
4
+ Summary: Claude Code adapter for credence-governor — a thin PreToolUse hook that Bayesian-governs tool calls (proceed/block/ask) via the governor daemon.
5
+ Author: Guy Freeman
6
+ License-Expression: AGPL-3.0-or-later
7
+ Project-URL: Homepage, https://github.com/gfrmin/credence-governor
8
+ Project-URL: Repository, https://github.com/gfrmin/credence-governor
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=7.4; extra == "dev"
16
+ Requires-Dist: ruff; extra == "dev"
17
+ Requires-Dist: credence-governor-core; extra == "dev"
18
+
19
+ # credence-governor — Claude Code adapter
20
+
21
+ A thin **Claude Code** adapter for [credence-governor](../../README.md): a
22
+ `PreToolUse` subprocess hook that Bayesian-governs tool calls (`proceed` /
23
+ `block` / `ask`) by asking the governor daemon, which runs the one EU-max reasoner
24
+ over the Credence engine.
25
+
26
+ The adapter carries **no probabilistic code and no feature logic**. Its only job is
27
+ to translate Claude Code's native hook events into the harness-neutral wire shape and
28
+ render the daemon's decision. Feature & taint extraction is server-side in the daemon
29
+ (single-sourced — DRY).
30
+
31
+ ## How it works
32
+
33
+ ```
34
+ Claude Code ──PreToolUse JSON on stdin──▶ credence-governor-claude-code (this hook)
35
+ │ transcript_path JSONL → neutral messages
36
+ ▼ POST /decide (tool, input, session)
37
+ governor daemon (credence-governor-core)
38
+ │ server-side feature/taint extraction
39
+ ▼ one EU-max decision over the skin wire
40
+ credence-skin engine (Julia)
41
+ ◀──permissionDecision JSON on stdout──────┘
42
+ ```
43
+
44
+ - **`transcript.py`** — replays the session transcript (`transcript_path`) into the
45
+ neutral `messages` list the extractors expect (`user` / `assistant` / `tool_call` /
46
+ `tool_result`). The proposed call — already persisted to the transcript before
47
+ `PreToolUse` fires — is excluded so loop counts see priors only. Sidechain
48
+ (subagent) turns are excluded by default.
49
+ - **`client.py`** — `POST /decide` (stdlib `urllib`).
50
+ - **`effectors.py`** — maps the governor action to a `PreToolUse` decision.
51
+ - **`hook.py`** — the entry point (`credence-governor-claude-code`).
52
+
53
+ ## Overlay semantics (the governor never weakens the host)
54
+
55
+ | governor action | Claude Code decision | effect |
56
+ |---|---|---|
57
+ | `proceed` | *(nothing emitted)* | defer to Claude Code's own permission rules |
58
+ | `block` | `deny` + reason | refuse regardless of native rules |
59
+ | `ask` | `ask` + reason | force a user prompt even if native rules would auto-allow |
60
+
61
+ `permissionDecision: "allow"` is **never** emitted — that would bypass Claude Code's
62
+ own safety prompts. The governor can only *add* friction, not remove it.
63
+
64
+ **Fail-open is absolute.** A malformed event, a daemon that is down, a timeout, or any
65
+ error prints nothing and exits 0 — the call proceeds through Claude Code's normal
66
+ flow. Run with `CREDENCE_GOVERNOR_DEBUG=1` to log decisions/failures to stderr.
67
+
68
+ ## Capability boundary (Claude Code, hook transport)
69
+
70
+ | Dimension | Supported |
71
+ |---|---|
72
+ | Waste gating (loops / repetition) | ✅ |
73
+ | Safety (taint-flow / exfil / injected imperatives) | ✅ |
74
+ | Routing / model selection | ❌ (no model-resolve hook) |
75
+ | Cost / dollars-saved | ◑ observe-only |
76
+ | Online ask-response learning | ◌ deferred — see below |
77
+
78
+ v1 gates **`PreToolUse` only**. `PostToolUse` / `UserPromptSubmit` are intentionally
79
+ not wired: neither carries a clean approval label, and treating "the call completed"
80
+ as belief evidence would be a second learning path (a constitutional violation), not
81
+ just a weak one. The warm-trained brain governs; the daemon logs every decision.
82
+ Online ask-learning on Claude Code (inferring the user's reply from the next
83
+ transcript turn) is an open design question, deferred.
84
+
85
+ ## Install
86
+
87
+ **1. Start the daemon** — every adapter shares one local daemon; see the
88
+ [core README](../../packages/governor_core) or the
89
+ [repo quickstart](../../README.md#install). In short, from a clone of the repo:
90
+
91
+ ```bash
92
+ pip install -e packages/governor_core -e ~/git/credence/apps/skin/clients/python
93
+ CREDENCE_ENGINE_DIR=~/git/credence credence-governor-daemon # or CREDENCE_SKIN_COMMAND="docker run … credence-skin@<digest>"
94
+ ```
95
+
96
+ **2. Register the hook** — two ways; both end at the same `PreToolUse` hook.
97
+
98
+ ### Method A — as a Claude Code plugin (recommended; no `pip install`)
99
+
100
+ The hook is pure stdlib, so the plugin **bundles** it and runs it via
101
+ `${CLAUDE_PLUGIN_ROOT}` — there's nothing to `pip install`. Inside Claude Code:
102
+
103
+ ```
104
+ /plugin marketplace add gfrmin/credence-governor
105
+ /plugin install credence-governor-claude-code@credence-governor
106
+ ```
107
+
108
+ The hook is active in new sessions (run `/reload-plugins` to pick it up in the current
109
+ one). Manage or remove it from the `/plugin` menu. The plugin reuses the *same*
110
+ `credence_governor_claude_code` package this directory ships — `plugin_hook.py` is a
111
+ thin launcher that puts it on `sys.path` and calls the same entry point the pip console
112
+ script uses (one implementation, two install paths). Manifests:
113
+ [`.claude-plugin/plugin.json`](.claude-plugin/plugin.json),
114
+ [`hooks/hooks.json`](hooks/hooks.json), and the repo-root
115
+ [`.claude-plugin/marketplace.json`](../../.claude-plugin/marketplace.json).
116
+
117
+ ### Method B — as a pip package + settings hook
118
+
119
+ If you'd rather manage it with pip (or script the registration):
120
+
121
+ ```bash
122
+ pip install -e adapters/claude-code # not yet on PyPI; once published: pip install credence-governor-claude-code
123
+ credence-governor-cc-install # → ~/.claude/settings.json (idempotent)
124
+ # --project → ./.claude/settings.json · --uninstall → remove
125
+ ```
126
+
127
+ Either way the hook has **no runtime dependencies** (pure stdlib); `credence-governor-core`
128
+ is needed only for the daemon and the parity test. Static config example:
129
+ [`settings.example.json`](settings.example.json).
130
+
131
+ ### Environment
132
+
133
+ | Variable | Default | Meaning |
134
+ |---|---|---|
135
+ | `CREDENCE_GOVERNOR_URL` | `http://127.0.0.1:8787` | governor daemon base URL |
136
+ | `CREDENCE_GOVERNOR_TIMEOUT` | `5.0` | per-call `/decide` timeout (s); on timeout → fail open |
137
+ | `CREDENCE_GOVERNOR_PROFILE` | *(none)* | utility profile passed to the daemon (e.g. `flow-guard`) |
138
+ | `CREDENCE_GOVERNOR_DEBUG` | *(off)* | log decisions/failures to stderr |
139
+
140
+ ## Tests
141
+
142
+ ```bash
143
+ python -m pytest tests -q
144
+ ```
145
+
146
+ Pure-stdlib at runtime; the round-trip parity test (`test_roundtrip.py`) imports
147
+ `credence-governor-core` to assert the wire payload deserialises through the daemon's
148
+ `event_and_session_from_payload` to the Session the extractors expect — it is skipped
149
+ if the core isn't importable.
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ credence_governor_claude_code/__init__.py
4
+ credence_governor_claude_code/client.py
5
+ credence_governor_claude_code/effectors.py
6
+ credence_governor_claude_code/hook.py
7
+ credence_governor_claude_code/install.py
8
+ credence_governor_claude_code/transcript.py
9
+ credence_governor_claude_code.egg-info/PKG-INFO
10
+ credence_governor_claude_code.egg-info/SOURCES.txt
11
+ credence_governor_claude_code.egg-info/dependency_links.txt
12
+ credence_governor_claude_code.egg-info/entry_points.txt
13
+ credence_governor_claude_code.egg-info/requires.txt
14
+ credence_governor_claude_code.egg-info/top_level.txt
15
+ tests/test_effectors.py
16
+ tests/test_roundtrip.py
17
+ tests/test_transcript.py
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ credence-governor-cc-install = credence_governor_claude_code.install:main
3
+ credence-governor-claude-code = credence_governor_claude_code.hook:main
@@ -0,0 +1,5 @@
1
+
2
+ [dev]
3
+ pytest>=7.4
4
+ ruff
5
+ credence-governor-core
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "credence-governor-claude-code"
7
+ version = "0.1.0"
8
+ description = "Claude Code adapter for credence-governor — a thin PreToolUse hook that Bayesian-governs tool calls (proceed/block/ask) via the governor daemon."
9
+ requires-python = ">=3.11"
10
+ readme = "README.md"
11
+ license = "AGPL-3.0-or-later"
12
+ authors = [{ name = "Guy Freeman" }]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ ]
18
+ # Runtime is pure stdlib (json, urllib, os): the hook must start fast and fail open.
19
+ # The shared contract with the daemon is the JSON key set, not shared code.
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/gfrmin/credence-governor"
24
+ Repository = "https://github.com/gfrmin/credence-governor"
25
+
26
+ [project.scripts]
27
+ credence-governor-claude-code = "credence_governor_claude_code.hook:main"
28
+ credence-governor-cc-install = "credence_governor_claude_code.install:main"
29
+
30
+ [project.optional-dependencies]
31
+ # governor-core is a TEST-only dep: the round-trip parity test asserts our wire
32
+ # payload deserialises through the daemon's event_and_session_from_payload to the
33
+ # Session the server-side extractors expect.
34
+ dev = ["pytest>=7.4", "ruff", "credence-governor-core"]
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["."]
38
+ include = ["credence_governor_claude_code*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,36 @@
1
+ """Action -> PreToolUse decision. The governor may only add restriction:
2
+ proceed defers (None), block denies, ask prompts; "allow" is never emitted."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from credence_governor_claude_code.effectors import pretooluse_output
7
+
8
+
9
+ def test_proceed_defers_to_host():
10
+ assert pretooluse_output("proceed", "Bash") is None
11
+
12
+
13
+ def test_unknown_action_defers():
14
+ assert pretooluse_output("weird", "Bash") is None
15
+
16
+
17
+ def test_block_denies_with_reason():
18
+ out = pretooluse_output("block", "Bash")
19
+ hso = out["hookSpecificOutput"]
20
+ assert hso["hookEventName"] == "PreToolUse"
21
+ assert hso["permissionDecision"] == "deny"
22
+ assert "Bash" in hso["permissionDecisionReason"]
23
+
24
+
25
+ def test_ask_prompts_with_reason():
26
+ out = pretooluse_output("ask", "Write")
27
+ hso = out["hookSpecificOutput"]
28
+ assert hso["permissionDecision"] == "ask"
29
+ assert "Write" in hso["permissionDecisionReason"]
30
+
31
+
32
+ def test_never_emits_allow():
33
+ for action in ("proceed", "block", "ask", "weird"):
34
+ out = pretooluse_output(action, "X")
35
+ if out is not None:
36
+ assert out["hookSpecificOutput"]["permissionDecision"] != "allow"
@@ -0,0 +1,69 @@
1
+ """Wire-contract parity: the payload the adapter POSTs must deserialise through the
2
+ daemon's `event_and_session_from_payload` and feed the server-side extractors to the
3
+ right feature buckets. This is the only place the two sides are tied together — at
4
+ the JSON contract, not at shared code. Skipped if governor-core isn't importable."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+
11
+ import pytest
12
+
13
+ # Make the sibling core package importable without an install.
14
+ _CORE = os.path.normpath(
15
+ os.path.join(os.path.dirname(__file__), "..", "..", "..", "packages", "governor_core")
16
+ )
17
+ import sys
18
+
19
+ if _CORE not in sys.path:
20
+ sys.path.insert(0, _CORE)
21
+
22
+ core = pytest.importorskip("credence_governor_core")
23
+
24
+ from credence_governor_claude_code.transcript import session_from_hook # noqa: E402
25
+
26
+
27
+ def _write(tmp_path, records) -> str:
28
+ p = os.path.join(tmp_path, "t.jsonl")
29
+ with open(p, "w", encoding="utf-8") as fh:
30
+ for r in records:
31
+ fh.write(json.dumps(r) + "\n")
32
+ return p
33
+
34
+
35
+ def test_payload_deserialises_and_extracts(tmp_path):
36
+ from credence_governor_core import extract_features, extract_safety
37
+ from credence_governor_core.schema import event_and_session_from_payload
38
+
39
+ def tool_use(cmd, ts):
40
+ return {"type": "assistant", "timestamp": ts, "isSidechain": False,
41
+ "message": {"role": "assistant",
42
+ "content": [{"type": "tool_use", "id": "t", "name": "Bash",
43
+ "input": {"command": cmd}}]}}
44
+
45
+ # Three prior identical Bash(ls) calls + the proposed fourth -> a loop.
46
+ records = [
47
+ {"type": "user", "timestamp": "2026-06-25T10:00:00.000Z", "isSidechain": False,
48
+ "message": {"role": "user", "content": "loop ls please"}},
49
+ tool_use("ls", "2026-06-25T10:00:01.000Z"),
50
+ tool_use("ls", "2026-06-25T10:00:02.000Z"),
51
+ tool_use("ls", "2026-06-25T10:00:03.000Z"),
52
+ tool_use("ls", "2026-06-25T10:00:04.000Z"), # the proposed call (dropped)
53
+ ]
54
+ path = _write(tmp_path, records)
55
+ payload = session_from_hook(
56
+ {"hook_event_name": "PreToolUse", "cwd": str(tmp_path), "transcript_path": path,
57
+ "tool_name": "Bash", "tool_input": {"command": "ls"}}
58
+ )
59
+
60
+ event, session = event_and_session_from_payload(payload)
61
+ assert event.tool_name == "Bash"
62
+ # The proposed call was excluded -> exactly the three priors remain.
63
+ assert sum(1 for m in session.messages if m.role == "tool_call") == 3
64
+
65
+ feats = {**extract_features(event, session), **extract_safety(event, session)}
66
+ # Three identical priors -> rep-3plus / ident-3plus (the loop signal the daemon decides on).
67
+ assert feats["recent-repetition-count"] == "rep-3plus"
68
+ assert feats["recent-identical-call-count"] == "ident-3plus"
69
+ assert feats["parent-tool-call-name"] == "bash"
@@ -0,0 +1,114 @@
1
+ """Transcript replay: Claude Code JSONL -> neutral wire messages.
2
+
3
+ The fixtures are minimal hand-written transcript records matching the real schema
4
+ (type=user/assistant, message.content blocks, top-level timestamp/isSidechain).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+
12
+ from credence_governor_claude_code.transcript import (
13
+ drop_proposed,
14
+ messages_from_transcript,
15
+ result_to_text,
16
+ session_from_hook,
17
+ )
18
+
19
+
20
+ def _write(tmp_path, records) -> str:
21
+ p = os.path.join(tmp_path, "transcript.jsonl")
22
+ with open(p, "w", encoding="utf-8") as fh:
23
+ for r in records:
24
+ fh.write(json.dumps(r) + "\n")
25
+ return p
26
+
27
+
28
+ def _user(text, ts="2026-06-25T10:00:00.000Z", sidechain=False):
29
+ return {"type": "user", "timestamp": ts, "isSidechain": sidechain,
30
+ "message": {"role": "user", "content": text}}
31
+
32
+
33
+ def _tool_use(name, inp, ts="2026-06-25T10:00:01.000Z", sidechain=False):
34
+ return {"type": "assistant", "timestamp": ts, "isSidechain": sidechain,
35
+ "message": {"role": "assistant",
36
+ "content": [{"type": "tool_use", "id": "t1", "name": name, "input": inp}]}}
37
+
38
+
39
+ def _tool_result(content, ts="2026-06-25T10:00:02.000Z", sidechain=False):
40
+ return {"type": "user", "timestamp": ts, "isSidechain": sidechain,
41
+ "message": {"role": "user",
42
+ "content": [{"type": "tool_result", "tool_use_id": "t1", "content": content}]}}
43
+
44
+
45
+ def test_roles_and_order(tmp_path):
46
+ path = _write(tmp_path, [
47
+ _user("hello"),
48
+ _tool_use("Bash", {"command": "ls"}),
49
+ _tool_result("a.txt\nb.txt"),
50
+ ])
51
+ msgs = messages_from_transcript(path)
52
+ assert [m["role"] for m in msgs] == ["user", "tool_call", "tool_result"]
53
+ assert msgs[1]["tool_name"] == "Bash"
54
+ assert msgs[1]["input"] == {"command": "ls"}
55
+ assert msgs[2]["result"] == "a.txt\nb.txt"
56
+
57
+
58
+ def test_sidechain_excluded_by_default(tmp_path):
59
+ path = _write(tmp_path, [
60
+ _user("hi"),
61
+ _tool_use("Read", {"file_path": "x"}, sidechain=True),
62
+ ])
63
+ assert [m["role"] for m in messages_from_transcript(path)] == ["user"]
64
+ assert len(messages_from_transcript(path, include_sidechain=True)) == 2
65
+
66
+
67
+ def test_tool_result_list_content_flattened():
68
+ text = result_to_text([{"type": "text", "text": "alpha"}, {"type": "text", "text": "beta"}])
69
+ assert text == "alpha\nbeta"
70
+
71
+
72
+ def test_drop_proposed_removes_matching_trailing_call():
73
+ msgs = [
74
+ {"role": "tool_call", "tool_name": "Bash", "input": {"command": "ls"}},
75
+ {"role": "tool_call", "tool_name": "Bash", "input": {"command": "pwd"}},
76
+ ]
77
+ drop_proposed(msgs, "Bash", {"command": "pwd"})
78
+ assert [m["input"] for m in msgs] == [{"command": "ls"}]
79
+
80
+
81
+ def test_drop_proposed_keeps_nonmatching_trailing_call():
82
+ msgs = [{"role": "tool_call", "tool_name": "Bash", "input": {"command": "ls"}}]
83
+ drop_proposed(msgs, "Bash", {"command": "different"})
84
+ assert len(msgs) == 1
85
+
86
+
87
+ def test_session_from_hook_excludes_proposed_call(tmp_path):
88
+ # The proposed Bash(pwd) is already persisted to the transcript when PreToolUse
89
+ # fires; the wire `messages` must NOT contain it (repetition counts priors only).
90
+ path = _write(tmp_path, [
91
+ _user("go"),
92
+ _tool_use("Bash", {"command": "ls"}),
93
+ _tool_result("ok"),
94
+ _tool_use("Bash", {"command": "pwd"}),
95
+ ])
96
+ payload = session_from_hook(
97
+ {"hook_event_name": "PreToolUse", "cwd": str(tmp_path),
98
+ "transcript_path": path, "tool_name": "Bash", "tool_input": {"command": "pwd"}}
99
+ )
100
+ assert payload["tool_name"] == "Bash"
101
+ assert payload["input"] == {"command": "pwd"}
102
+ calls = [m for m in payload["session"]["messages"] if m["role"] == "tool_call"]
103
+ assert [c["input"] for c in calls] == [{"command": "ls"}]
104
+
105
+
106
+ def test_project_root_detected(tmp_path):
107
+ os.makedirs(os.path.join(tmp_path, ".git"))
108
+ sub = os.path.join(tmp_path, "src")
109
+ os.makedirs(sub)
110
+ payload = session_from_hook(
111
+ {"hook_event_name": "PreToolUse", "cwd": sub, "transcript_path": "",
112
+ "tool_name": "Read", "tool_input": {}}
113
+ )
114
+ assert payload["session"]["project_root"] == str(tmp_path)