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.
Files changed (43) hide show
  1. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/PKG-INFO +1 -1
  2. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/pyproject.toml +1 -1
  3. conduct_cli-0.6.18/src/conduct_cli/hooks/session_parser.py +225 -0
  4. conduct_cli-0.6.18/src/conduct_cli/hooks/stop.py +88 -0
  5. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  6. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
  7. conduct_cli-0.6.17/src/conduct_cli/hooks/stop.py +0 -30
  8. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/README.md +0 -0
  9. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/setup.cfg +0 -0
  10. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/setup.py +0 -0
  11. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/__init__.py +0 -0
  12. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/api.py +0 -0
  13. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/guard.py +0 -0
  14. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/guardmcp.py +0 -0
  15. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hook_precompact_template.py +0 -0
  16. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hook_session_start_template.py +0 -0
  17. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hook_stop_template.py +0 -0
  18. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hook_template.py +0 -0
  19. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hooks/__init__.py +0 -0
  20. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hooks/base.py +0 -0
  21. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hooks/posttooluse.py +0 -0
  22. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hooks/precompact.py +0 -0
  23. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hooks/pretooluse.py +0 -0
  24. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/hooks/session_start.py +0 -0
  25. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/log_util.py +0 -0
  26. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/main.py +0 -0
  27. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/mcp_server.py +0 -0
  28. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/memory.py +0 -0
  29. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli/paxel.py +0 -0
  30. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  31. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  32. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli.egg-info/requires.txt +0 -0
  33. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/src/conduct_cli.egg-info/top_level.txt +0 -0
  34. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_bash_operator_signature.py +0 -0
  35. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_command_word_matcher.py +0 -0
  36. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_guard_policy.py +0 -0
  37. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_guard_savings.py +0 -0
  38. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_hook_syntax.py +0 -0
  39. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_journal_drain.py +0 -0
  40. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_log_util.py +0 -0
  41. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_proxy_env.py +0 -0
  42. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_signed_policy.py +0 -0
  43. {conduct_cli-0.6.17 → conduct_cli-0.6.18}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.6.17
3
+ Version: 0.6.18
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.6.17"
7
+ version = "0.6.18"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.6.17
3
+ Version: 0.6.18
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -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