conduct-cli 0.6.17__tar.gz → 0.6.18__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.
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/PKG-INFO +1 -1
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/pyproject.toml +1 -1
- conduct_cli-0.6.18/src/conduct_cli/hooks/session_parser.py +225 -0
- conduct_cli-0.6.18/src/conduct_cli/hooks/stop.py +88 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
- conduct_cli-0.6.17/src/conduct_cli/hooks/stop.py +0 -30
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/README.md +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/setup.cfg +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/setup.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/guard.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hook_precompact_template.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hook_session_start_template.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hook_stop_template.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hook_template.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hooks/base.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hooks/posttooluse.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hooks/pretooluse.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/main.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_hook_syntax.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_log_util.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_switch.py +0 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Session parser — extracts intent and tool sequence from AI session files.
|
|
2
|
+
|
|
3
|
+
Supports Claude Code (claude_code_v1) and Codex CLI (codex_v1).
|
|
4
|
+
All parsing is best-effort: any failure sets status="failed" and never raises.
|
|
5
|
+
stdlib only — no third-party dependencies.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
_INTENT_MAX = 300
|
|
15
|
+
_TOOL_SEQ_MAX = 200
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class SessionData:
|
|
20
|
+
intent: str | None
|
|
21
|
+
tool_sequence: list[str]
|
|
22
|
+
status: Literal["ok", "partial", "failed", "unsupported"]
|
|
23
|
+
parser: str # "claude_code_v1" | "codex_v1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _truncate(s: str, n: int) -> str:
|
|
27
|
+
return s[:n] if len(s) > n else s
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── Claude Code parser ────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
class _ClaudeCodeParser:
|
|
33
|
+
"""Parses ~/.claude/projects/<cwd-hash>/<session_id>.jsonl"""
|
|
34
|
+
|
|
35
|
+
PARSER_ID = "claude_code_v1"
|
|
36
|
+
|
|
37
|
+
def _find_session_file(self, session_id: str, cwd: str | None) -> Path | None:
|
|
38
|
+
projects_root = Path.home() / ".claude" / "projects"
|
|
39
|
+
if not projects_root.exists():
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
# If cwd is given, derive the hash directory name Claude Code uses.
|
|
43
|
+
# Claude Code encodes the cwd as a URL-encoded-like path replacing / with -.
|
|
44
|
+
# Try direct match first, then scan all project dirs.
|
|
45
|
+
candidates: list[Path] = []
|
|
46
|
+
if cwd:
|
|
47
|
+
# Claude Code replaces path separators with hyphens and strips leading separator
|
|
48
|
+
slug = cwd.lstrip("/").replace("/", "-")
|
|
49
|
+
d = projects_root / slug
|
|
50
|
+
if d.is_dir():
|
|
51
|
+
candidates.append(d)
|
|
52
|
+
|
|
53
|
+
# Fall back: scan all subdirectories
|
|
54
|
+
if not candidates:
|
|
55
|
+
try:
|
|
56
|
+
candidates = [p for p in projects_root.iterdir() if p.is_dir()]
|
|
57
|
+
except Exception:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
for d in candidates:
|
|
61
|
+
f = d / f"{session_id}.jsonl"
|
|
62
|
+
if f.exists():
|
|
63
|
+
return f
|
|
64
|
+
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def parse(self, session_id: str, cwd: str | None) -> SessionData:
|
|
68
|
+
try:
|
|
69
|
+
session_file = self._find_session_file(session_id, cwd)
|
|
70
|
+
if session_file is None:
|
|
71
|
+
return SessionData(intent=None, tool_sequence=[], status="failed", parser=self.PARSER_ID)
|
|
72
|
+
|
|
73
|
+
intent: str | None = None
|
|
74
|
+
tool_sequence: list[str] = []
|
|
75
|
+
partial = False
|
|
76
|
+
|
|
77
|
+
for raw_line in session_file.read_text(errors="ignore").splitlines():
|
|
78
|
+
if not raw_line.strip():
|
|
79
|
+
continue
|
|
80
|
+
try:
|
|
81
|
+
entry = json.loads(raw_line)
|
|
82
|
+
except Exception:
|
|
83
|
+
partial = True
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
msg = entry.get("message", {})
|
|
87
|
+
if not isinstance(msg, dict):
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
role = msg.get("role")
|
|
91
|
+
content = msg.get("content")
|
|
92
|
+
|
|
93
|
+
# Extract first human message as intent
|
|
94
|
+
if intent is None and role == "user":
|
|
95
|
+
if isinstance(content, str) and content.strip():
|
|
96
|
+
intent = _truncate(content.strip(), _INTENT_MAX)
|
|
97
|
+
elif isinstance(content, list):
|
|
98
|
+
for block in content:
|
|
99
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
100
|
+
text = block.get("text", "").strip()
|
|
101
|
+
if text:
|
|
102
|
+
intent = _truncate(text, _INTENT_MAX)
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
# Collect tool_use names from assistant messages
|
|
106
|
+
if role == "assistant" and isinstance(content, list):
|
|
107
|
+
for block in content:
|
|
108
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
109
|
+
name = block.get("name")
|
|
110
|
+
if name and len(tool_sequence) < _TOOL_SEQ_MAX:
|
|
111
|
+
tool_sequence.append(name)
|
|
112
|
+
|
|
113
|
+
status: Literal["ok", "partial", "failed", "unsupported"] = "partial" if partial else "ok"
|
|
114
|
+
return SessionData(
|
|
115
|
+
intent=intent,
|
|
116
|
+
tool_sequence=tool_sequence,
|
|
117
|
+
status=status,
|
|
118
|
+
parser=self.PARSER_ID,
|
|
119
|
+
)
|
|
120
|
+
except Exception:
|
|
121
|
+
return SessionData(intent=None, tool_sequence=[], status="failed", parser=self.PARSER_ID)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── Codex parser ──────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
class _CodexParser:
|
|
127
|
+
"""Parses ~/.codex/sessions/<yyyy>/<MM>/<dd>/rollout-<ts>-<uuid>.jsonl
|
|
128
|
+
Intent comes from ~/.codex/session_index.jsonl (thread_name field).
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
PARSER_ID = "codex_v1"
|
|
132
|
+
|
|
133
|
+
def _get_intent_from_index(self, session_id: str) -> str | None:
|
|
134
|
+
index_file = Path.home() / ".codex" / "session_index.jsonl"
|
|
135
|
+
if not index_file.exists():
|
|
136
|
+
return None
|
|
137
|
+
try:
|
|
138
|
+
for raw_line in index_file.read_text(errors="ignore").splitlines():
|
|
139
|
+
if not raw_line.strip():
|
|
140
|
+
continue
|
|
141
|
+
try:
|
|
142
|
+
entry = json.loads(raw_line)
|
|
143
|
+
except Exception:
|
|
144
|
+
continue
|
|
145
|
+
if entry.get("id") == session_id:
|
|
146
|
+
name = entry.get("thread_name", "").strip()
|
|
147
|
+
return _truncate(name, _INTENT_MAX) if name else None
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def _find_session_file(self, session_id: str) -> Path | None:
|
|
153
|
+
sessions_root = Path.home() / ".codex" / "sessions"
|
|
154
|
+
if not sessions_root.exists():
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
# Walk all date-partitioned subdirectories
|
|
158
|
+
for f in sessions_root.rglob(f"*{session_id}*.jsonl"):
|
|
159
|
+
return f
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def parse(self, session_id: str, cwd: str | None) -> SessionData:
|
|
165
|
+
try:
|
|
166
|
+
intent = self._get_intent_from_index(session_id)
|
|
167
|
+
session_file = self._find_session_file(session_id)
|
|
168
|
+
if session_file is None:
|
|
169
|
+
# Intent from index alone is still useful
|
|
170
|
+
status: Literal["ok", "partial", "failed", "unsupported"] = "partial" if intent else "failed"
|
|
171
|
+
return SessionData(intent=intent, tool_sequence=[], status=status, parser=self.PARSER_ID)
|
|
172
|
+
|
|
173
|
+
tool_sequence: list[str] = []
|
|
174
|
+
partial = False
|
|
175
|
+
|
|
176
|
+
for raw_line in session_file.read_text(errors="ignore").splitlines():
|
|
177
|
+
if not raw_line.strip():
|
|
178
|
+
continue
|
|
179
|
+
try:
|
|
180
|
+
entry = json.loads(raw_line)
|
|
181
|
+
except Exception:
|
|
182
|
+
partial = True
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
# Codex emits {"type": "tool_call", "name": "..."} entries
|
|
186
|
+
if entry.get("type") == "tool_call":
|
|
187
|
+
name = entry.get("name") or entry.get("tool")
|
|
188
|
+
if name and len(tool_sequence) < _TOOL_SEQ_MAX:
|
|
189
|
+
tool_sequence.append(name)
|
|
190
|
+
|
|
191
|
+
status = "partial" if partial else "ok"
|
|
192
|
+
return SessionData(
|
|
193
|
+
intent=intent,
|
|
194
|
+
tool_sequence=tool_sequence,
|
|
195
|
+
status=status,
|
|
196
|
+
parser=self.PARSER_ID,
|
|
197
|
+
)
|
|
198
|
+
except Exception:
|
|
199
|
+
return SessionData(intent=None, tool_sequence=[], status="failed", parser=self.PARSER_ID)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ── Dispatcher ────────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
_PARSERS: dict[str, "_ClaudeCodeParser | _CodexParser"] = {
|
|
205
|
+
"claude_code": _ClaudeCodeParser(),
|
|
206
|
+
"claude-code": _ClaudeCodeParser(),
|
|
207
|
+
"codex": _CodexParser(),
|
|
208
|
+
"codex_cli": _CodexParser(),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def parse_session(ai_tool: str, session_id: str, cwd: str | None = None) -> SessionData:
|
|
213
|
+
"""Dispatcher: returns SessionData for the given ai_tool.
|
|
214
|
+
|
|
215
|
+
Never raises. Unknown tools return status="unsupported".
|
|
216
|
+
"""
|
|
217
|
+
parser = _PARSERS.get(ai_tool)
|
|
218
|
+
if parser is None:
|
|
219
|
+
return SessionData(
|
|
220
|
+
intent=None,
|
|
221
|
+
tool_sequence=[],
|
|
222
|
+
status="unsupported",
|
|
223
|
+
parser=ai_tool or "unknown",
|
|
224
|
+
)
|
|
225
|
+
return parser.parse(session_id, cwd)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""ConductGuard Stop hook — captures session transcript for team memory and parses intent."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from conduct_cli.hooks.base import detect_repo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _post_intent(session_id: str, ai_tool: str, cwd: str | None) -> None:
|
|
11
|
+
"""Parse session file and PATCH intent to the API. Never raises."""
|
|
12
|
+
try:
|
|
13
|
+
from conduct_cli.hooks.session_parser import parse_session
|
|
14
|
+
data = parse_session(ai_tool, session_id, cwd)
|
|
15
|
+
except Exception:
|
|
16
|
+
print("Guard: session parse failed", file=sys.stderr)
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import json as _json
|
|
21
|
+
import urllib.request
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
cfg_path = Path.home() / ".conduct" / "config.json"
|
|
25
|
+
if not cfg_path.exists():
|
|
26
|
+
return
|
|
27
|
+
cfg = _json.loads(cfg_path.read_text())
|
|
28
|
+
server = cfg.get("server", "").rstrip("/")
|
|
29
|
+
api_key = cfg.get("api_key", "")
|
|
30
|
+
workspace_id = cfg.get("workspace_id") or cfg.get("workspace", "")
|
|
31
|
+
if not server or not session_id:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
payload = _json.dumps({
|
|
35
|
+
"intent": data.intent,
|
|
36
|
+
"tool_sequence": data.tool_sequence,
|
|
37
|
+
"session_parse_status": data.status,
|
|
38
|
+
"session_parser": data.parser,
|
|
39
|
+
}).encode()
|
|
40
|
+
|
|
41
|
+
req = urllib.request.Request(
|
|
42
|
+
f"{server}/guard/sessions/{session_id}/intent",
|
|
43
|
+
data=payload,
|
|
44
|
+
headers={
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
"X-API-Key": api_key,
|
|
47
|
+
"X-Workspace-ID": workspace_id,
|
|
48
|
+
},
|
|
49
|
+
method="PATCH",
|
|
50
|
+
)
|
|
51
|
+
urllib.request.urlopen(req, timeout=8)
|
|
52
|
+
|
|
53
|
+
if data.status in ("ok", "partial"):
|
|
54
|
+
print("Guard: session intent captured", file=sys.stderr)
|
|
55
|
+
else:
|
|
56
|
+
print(f"Guard: session parse {data.status}", file=sys.stderr)
|
|
57
|
+
except Exception:
|
|
58
|
+
print("Guard: session parse failed", file=sys.stderr)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main() -> None:
|
|
62
|
+
try:
|
|
63
|
+
raw = sys.stdin.read()
|
|
64
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
65
|
+
except Exception:
|
|
66
|
+
data = {}
|
|
67
|
+
|
|
68
|
+
session_id = data.get("session_id", "")
|
|
69
|
+
transcript_path = data.get("transcript_path") or data.get("transcriptPath")
|
|
70
|
+
ai_tool = data.get("tool", "claude_code")
|
|
71
|
+
cwd = data.get("cwd")
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
from conduct_cli.memory import post_session_to_api
|
|
75
|
+
post_session_to_api(session_id, transcript_path, detect_repo())
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
_post_intent(session_id, ai_tool, cwd)
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
sys.exit(0)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
main()
|
|
@@ -25,6 +25,7 @@ src/conduct_cli/hooks/base.py
|
|
|
25
25
|
src/conduct_cli/hooks/posttooluse.py
|
|
26
26
|
src/conduct_cli/hooks/precompact.py
|
|
27
27
|
src/conduct_cli/hooks/pretooluse.py
|
|
28
|
+
src/conduct_cli/hooks/session_parser.py
|
|
28
29
|
src/conduct_cli/hooks/session_start.py
|
|
29
30
|
src/conduct_cli/hooks/stop.py
|
|
30
31
|
tests/test_bash_operator_signature.py
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
"""ConductGuard Stop hook — captures session transcript for team memory."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
import sys
|
|
6
|
-
|
|
7
|
-
from conduct_cli.hooks.base import detect_repo
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def main() -> None:
|
|
11
|
-
try:
|
|
12
|
-
raw = sys.stdin.read()
|
|
13
|
-
data = json.loads(raw) if raw.strip() else {}
|
|
14
|
-
except Exception:
|
|
15
|
-
data = {}
|
|
16
|
-
|
|
17
|
-
session_id = data.get("session_id", "")
|
|
18
|
-
transcript_path = data.get("transcript_path") or data.get("transcriptPath")
|
|
19
|
-
|
|
20
|
-
try:
|
|
21
|
-
from conduct_cli.memory import post_session_to_api
|
|
22
|
-
post_session_to_api(session_id, transcript_path, detect_repo())
|
|
23
|
-
except Exception:
|
|
24
|
-
pass
|
|
25
|
-
|
|
26
|
-
sys.exit(0)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if __name__ == "__main__":
|
|
30
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|