cardinal-agent-core 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: cardinal-agent-core
3
+ Version: 0.3.0
4
+ Summary: The Cardinal agent-telemetry contract: OTLP emission, initiative resolution, spend-limits delivery, device-code consent.
5
+ Author-email: CardinalHQ <support@cardinalhq.io>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/cardinalhq/cardinal-agent-plugins
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+
11
+ # cardinal-agent-core
12
+
13
+ The Cardinal agent-telemetry contract, written once. See
14
+ `../docs/specs/agent-core.md` for the extraction spec and
15
+ `../README.md` for the monorepo layout.
16
+
17
+ Ships two ways:
18
+
19
+ - **Vendored** into each CLI plugin artifact at build time
20
+ (`build/vendor.py`) — plugins stay self-contained, no pip.
21
+ - **Installed** as a normal package (`pip install -e core/`) by
22
+ server-side consumers (the omnigent adapter) and the test suite.
23
+
24
+ ```bash
25
+ cd core && python3 -m unittest discover tests -v
26
+ ```
@@ -0,0 +1,13 @@
1
+ cardinal_core/__init__.py,sha256=-uhjC9IopPFu0ORTxg-7QNywtPKf7UpN0AxxcmV7WQI,623
2
+ cardinal_core/bashclass.py,sha256=NfG6MKWfGRBVm2vaj-Rfx48QVDrcq9BlbGYhl28dud0,5492
3
+ cardinal_core/deviceflow.py,sha256=w6EG1CloK5yFq83JLn0CIF4ZoA7bmeDvs_PfReAlZ1c,6688
4
+ cardinal_core/initiative.py,sha256=yhM8S5EiA2TvJoG7AwEVjWRwfRdPVXdDv9nKg3O7sWE,4175
5
+ cardinal_core/limits.py,sha256=b-w5GfCZjc-S-RdY0kJRdeAIaSjti8MYQ31Nlw31z9E,12739
6
+ cardinal_core/otlp.py,sha256=c2IPrcTT-7Gon6jMXxqJ1KwJf26mNxeI-aUu9lDavvs,6313
7
+ cardinal_core/paths.py,sha256=Rjr8KbXw-S_s5utqmDUl6OtkMPtMcHzieHfidwpaLVo,4500
8
+ cardinal_core/pricing.py,sha256=HXm_XiRvyabVv7hcbjOajEIP5GqdAh_5pUD3XyNq8XE,6575
9
+ cardinal_core/session.py,sha256=blgkrlC_nu9s9da7rCIpTqxy9CrWQF_cokFtCJpdw38,6746
10
+ cardinal_agent_core-0.3.0.dist-info/METADATA,sha256=UdPiVz4igRX8QGu6o1LzoMj0E6ALvtSzHipmU5-HvkA,914
11
+ cardinal_agent_core-0.3.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
12
+ cardinal_agent_core-0.3.0.dist-info/top_level.txt,sha256=_iORClBRzXHjbhqhLs3zLr26UGxKYGudDNy4XlHjvGw,14
13
+ cardinal_agent_core-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ cardinal_core
@@ -0,0 +1,14 @@
1
+ """cardinal-agent-core — the Cardinal agent-telemetry contract, written once.
2
+
3
+ Extracted from the four per-agent plugins (claude/codex/cursor/gemini) per
4
+ docs/specs/agent-core.md. Adapters supply agent-specific facts (paths,
5
+ event names, payload spellings); this package owns the algorithms and the
6
+ OTLP contract.
7
+
8
+ Design constraints honored throughout (spec §omnigent constraints):
9
+ - No module-level path constants — state locations come in via AgentPaths.
10
+ - No module-level connection state — emit targets are arguments.
11
+ - Identity (user_email/actor) is an argument, never a file read.
12
+ """
13
+
14
+ CORE_VERSION = "0.3.0"
@@ -0,0 +1,140 @@
1
+ """Bash verb classification — a closed enum derived from the command WORD
2
+ only; the command string is never emitted on cardinal.turn_tool, in whole
3
+ or in part. Ambiguity resolves toward the write-risky side (the harvester
4
+ discounts write work, so misclassifying read-as-write only costs savings
5
+ estimate, never privacy or correctness).
6
+
7
+ This is the single copy; per-plugin fixture parity is enforced by the
8
+ cross-adapter contract test (spec §Test strategy).
9
+
10
+ Write-risk ordering: when a compound command spans classes, the
11
+ lowest-index class wins and bash_multi=true is emitted.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ BASH_CLASS_RANK = (
17
+ "file-write",
18
+ "git-write",
19
+ "pkg",
20
+ "network",
21
+ "build",
22
+ "test",
23
+ "git-read",
24
+ "file-read",
25
+ "other",
26
+ )
27
+
28
+ # Single-word command → class. Unknown words → "other".
29
+ BASH_CMD_CLASS = {
30
+ # test
31
+ "pytest": "test", "tox": "test", "jest": "test", "vitest": "test",
32
+ "rspec": "test", "phpunit": "test",
33
+ # build
34
+ "make": "build", "cmake": "build", "tsc": "build", "gradle": "build",
35
+ "mvn": "build", "gcc": "build", "clang": "build", "webpack": "build",
36
+ # pkg
37
+ "pip": "pkg", "pip3": "pkg", "brew": "pkg", "gem": "pkg",
38
+ "apt": "pkg", "apt-get": "pkg", "yum": "pkg", "dnf": "pkg",
39
+ "apk": "pkg", "poetry": "pkg", "uv": "pkg",
40
+ # file-read
41
+ "ls": "file-read", "cat": "file-read", "find": "file-read",
42
+ "grep": "file-read", "rg": "file-read", "head": "file-read",
43
+ "tail": "file-read", "wc": "file-read", "du": "file-read",
44
+ "df": "file-read", "stat": "file-read", "file": "file-read",
45
+ "tree": "file-read", "which": "file-read", "pwd": "file-read",
46
+ "less": "file-read", "more": "file-read", "diff": "file-read",
47
+ "awk": "file-read", "echo": "file-read", "sort": "file-read",
48
+ "uniq": "file-read", "cut": "file-read", "jq": "file-read",
49
+ # file-write (sed classifies here: -i vs not is an argument, and
50
+ # arguments are never consulted — write-risky wins)
51
+ "rm": "file-write", "mv": "file-write", "cp": "file-write",
52
+ "mkdir": "file-write", "rmdir": "file-write", "chmod": "file-write",
53
+ "chown": "file-write", "touch": "file-write", "ln": "file-write",
54
+ "sed": "file-write", "tee": "file-write", "truncate": "file-write",
55
+ "dd": "file-write", "tar": "file-write", "unzip": "file-write",
56
+ "zip": "file-write",
57
+ # network
58
+ "curl": "network", "wget": "network", "gh": "network",
59
+ "ssh": "network", "scp": "network", "rsync": "network",
60
+ "nc": "network", "ping": "network", "dig": "network",
61
+ "host": "network", "nslookup": "network",
62
+ }
63
+
64
+ # Multiplexer commands whose class hangs on the SUBcommand word (still
65
+ # never an argument): {cmd: (subcommand → class, default class)}.
66
+ GIT_READ_SUBS = {
67
+ "status", "log", "diff", "show", "blame", "shortlog", "reflog",
68
+ "describe", "rev-parse", "ls-files", "ls-remote", "ls-tree",
69
+ "cat-file", "grep",
70
+ }
71
+ BASH_MULTIPLEX_CLASS = {
72
+ # git subcommands outside the read set default to git-write
73
+ # (write-risky wins for branch/tag/stash-style ambiguity).
74
+ "git": ({s: "git-read" for s in GIT_READ_SUBS}, "git-write"),
75
+ "go": (
76
+ {"test": "test", "vet": "test",
77
+ "build": "build", "run": "build", "generate": "build",
78
+ "get": "pkg", "install": "pkg", "mod": "pkg"},
79
+ "other",
80
+ ),
81
+ "cargo": (
82
+ {"test": "test", "bench": "test",
83
+ "build": "build", "check": "build", "run": "build",
84
+ "clippy": "build",
85
+ "add": "pkg", "install": "pkg", "update": "pkg",
86
+ "remove": "pkg"},
87
+ "other",
88
+ ),
89
+ "npm": (
90
+ {"test": "test", "run": "build", "exec": "build"},
91
+ "pkg", # install/i/ci/add/uninstall/update/…
92
+ ),
93
+ "pnpm": (
94
+ {"test": "test", "run": "build", "exec": "build"},
95
+ "pkg",
96
+ ),
97
+ "yarn": (
98
+ {"test": "test", "run": "build"},
99
+ "pkg",
100
+ ),
101
+ "bun": (
102
+ {"test": "test", "run": "build", "build": "build"},
103
+ "pkg",
104
+ ),
105
+ }
106
+
107
+
108
+ def classify_bash_command(command: str) -> tuple[str, bool] | None:
109
+ """Map a Bash command string to (bash_class, bash_multi).
110
+
111
+ Tokenizes on shell separators (&&, ||, ;, |, newline); classifies
112
+ each segment by its leading command word after stripping env-var
113
+ prefixes and sudo; the most write-risky class present wins
114
+ (BASH_CLASS_RANK order). bash_multi is True when segments span more
115
+ than one class. Only the command/subcommand WORD feeds the lookup —
116
+ no argument ever does, and nothing from the string is returned
117
+ beyond the closed enum. Returns None when no command word is found.
118
+ """
119
+ for sep in ("&&", "||", ";", "|", "\n"):
120
+ command = command.replace(sep, "\x00")
121
+ classes: set[str] = set()
122
+ for segment in command.split("\x00"):
123
+ words = segment.split()
124
+ # Strip env-var prefixes (FOO=bar) and sudo from the front.
125
+ while words and ("=" in words[0] or words[0] == "sudo"):
126
+ words.pop(0)
127
+ if not words:
128
+ continue
129
+ cmd = words[0].rsplit("/", 1)[-1] # /usr/bin/git → git
130
+ mux = BASH_MULTIPLEX_CLASS.get(cmd)
131
+ if mux is not None:
132
+ sub_map, default = mux
133
+ sub = words[1] if len(words) > 1 else ""
134
+ classes.add(sub_map.get(sub, default))
135
+ else:
136
+ classes.add(BASH_CMD_CLASS.get(cmd, "other"))
137
+ if not classes:
138
+ return None
139
+ winner = min(classes, key=BASH_CLASS_RANK.index)
140
+ return winner, len(classes) > 1
@@ -0,0 +1,192 @@
1
+ """Cardinal device-code consent flow and endpoint reachability probes.
2
+
3
+ Shared verbatim by every adapter's cardinal-connect. `client_id` names the
4
+ requesting plugin (e.g. "cardinal-codex-plugin") so the server can show
5
+ which agent is asking for consent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import socket
12
+ import time
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
+ from typing import Callable
17
+
18
+ DEFAULT_POLL_TIMEOUT_SECS = 600
19
+ DEFAULT_POLL_INTERVAL_SECS = 5
20
+ INGEST_PROBE_RETRY_SLEEPS = (1.0, 2.0, 4.0, 8.0, 16.0, 32.0)
21
+
22
+
23
+ class DeviceFlowError(RuntimeError):
24
+ """Raised for any unrecoverable device-flow failure; message is
25
+ user-presentable. Adapters decide how to exit (sys.exit for CLIs)."""
26
+
27
+
28
+ def derive_deployment_env(host: str) -> str:
29
+ try:
30
+ hostname = urllib.parse.urlparse(host).hostname or ""
31
+ except Exception:
32
+ return "unknown"
33
+ if hostname == "app.cardinalhq.io":
34
+ return "prod"
35
+ if "dogfood" in hostname:
36
+ return "dogfood"
37
+ if "cardinalhq.io" in hostname:
38
+ return "cardinal"
39
+ return "customer"
40
+
41
+
42
+ def _post_json(url: str, body: dict, timeout: int = 15) -> tuple[int, dict | None]:
43
+ payload = json.dumps(body).encode("utf-8")
44
+ req = urllib.request.Request(
45
+ url,
46
+ data=payload,
47
+ method="POST",
48
+ headers={"content-type": "application/json"},
49
+ )
50
+ try:
51
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
52
+ raw = resp.read()
53
+ return resp.status, json.loads(raw) if raw else None
54
+ except urllib.error.HTTPError as exc:
55
+ raw = exc.read() if hasattr(exc, "read") else b""
56
+ try:
57
+ return exc.code, json.loads(raw) if raw else None
58
+ except json.JSONDecodeError:
59
+ return exc.code, None
60
+
61
+
62
+ def start_device_code(host: str, scopes: list[str], client_id: str) -> dict:
63
+ status, body = _post_json(
64
+ host.rstrip("/") + "/api/auth/device/code",
65
+ {
66
+ "client": client_id,
67
+ "scopes": scopes,
68
+ "hostname": socket.gethostname(),
69
+ },
70
+ )
71
+ if status != 201 or not body:
72
+ err = (body or {}).get("error", f"HTTP {status}")
73
+ desc = (body or {}).get("error_description", "")
74
+ raise DeviceFlowError(f"device-code init failed: {err}{(': ' + desc) if desc else ''}")
75
+ return body
76
+
77
+
78
+ def poll_device_token(
79
+ host: str,
80
+ device_code: str,
81
+ client_id: str,
82
+ interval: int,
83
+ timeout: int,
84
+ ) -> dict:
85
+ deadline = time.monotonic() + timeout
86
+ while time.monotonic() < deadline:
87
+ status, body = _post_json(
88
+ host.rstrip("/") + "/api/auth/device/token",
89
+ {"device_code": device_code, "client": client_id},
90
+ timeout=20,
91
+ )
92
+ body = body or {}
93
+ if status == 200:
94
+ return body
95
+ err = body.get("error", f"http_{status}")
96
+ if err == "authorization_pending":
97
+ time.sleep(interval)
98
+ continue
99
+ if err == "slow_down":
100
+ interval += 5
101
+ time.sleep(interval)
102
+ continue
103
+ if err == "access_denied":
104
+ raise DeviceFlowError("Request was denied in the browser.")
105
+ if err == "expired_token":
106
+ raise DeviceFlowError("Consent request expired before approval. Re-run cardinal-connect.")
107
+ desc = body.get("error_description", "")
108
+ raise DeviceFlowError(f"device-code failed: {err}{(': ' + desc) if desc else ''}")
109
+ raise DeviceFlowError("Timed out waiting for browser approval. Re-run cardinal-connect.")
110
+
111
+
112
+ def verify_mcp_reachable(mcp_url: str | None, api_key: str | None) -> tuple[bool, str]:
113
+ if not mcp_url:
114
+ return False, "server returned no MCP URL"
115
+ if not api_key:
116
+ return False, "server returned no MCP API key"
117
+ req = urllib.request.Request(
118
+ mcp_url,
119
+ method="GET",
120
+ headers={"x-cardinalhq-api-key": api_key},
121
+ )
122
+ try:
123
+ with urllib.request.urlopen(req, timeout=10) as resp:
124
+ return resp.status not in (401, 403), f"HTTP {resp.status}"
125
+ except urllib.error.HTTPError as exc:
126
+ if exc.code in (401, 403):
127
+ return False, f"HTTP {exc.code} - MCP key invalid"
128
+ return True, f"HTTP {exc.code} - auth OK"
129
+ except (urllib.error.URLError, TimeoutError) as exc:
130
+ return False, f"network error: {exc}"
131
+
132
+
133
+ def _ingest_probe_once(endpoint: str, api_key: str, api_header: str) -> tuple[bool, str]:
134
+ url = endpoint.rstrip("/") + "/v1/metrics"
135
+ req = urllib.request.Request(
136
+ url,
137
+ data=b"",
138
+ method="POST",
139
+ headers={
140
+ "content-type": "application/x-protobuf",
141
+ api_header: api_key,
142
+ },
143
+ )
144
+ try:
145
+ with urllib.request.urlopen(req, timeout=10) as resp:
146
+ return resp.status not in (401, 403), f"HTTP {resp.status}"
147
+ except urllib.error.HTTPError as exc:
148
+ if exc.code in (401, 403):
149
+ return False, f"HTTP {exc.code} - ingest key invalid"
150
+ if exc.code < 500:
151
+ return True, f"HTTP {exc.code} on empty body - auth OK"
152
+ return False, f"HTTP {exc.code}"
153
+ except (urllib.error.URLError, TimeoutError) as exc:
154
+ return False, f"network error: {exc}"
155
+
156
+
157
+ def verify_ingest_reachable(
158
+ ingest: dict | None,
159
+ log: Callable[[str], None] = print,
160
+ sleeps: tuple[float, ...] = INGEST_PROBE_RETRY_SLEEPS,
161
+ ) -> tuple[bool, str]:
162
+ """Probe the ingest endpoint. Retries the 401 case on the retry
163
+ ladder because a freshly minted key can take seconds to propagate;
164
+ `sleeps` is injectable so callers (and tests) can shorten it
165
+ (core 0.2.0 gap #7)."""
166
+ if not ingest:
167
+ return False, "server returned no ingest credential"
168
+ endpoint = ingest.get("endpoint")
169
+ api_key = ingest.get("api_key")
170
+ api_header = ingest.get("api_header") or "x-cardinalhq-api-key"
171
+ if not endpoint:
172
+ return False, "server returned no ingest endpoint"
173
+ if not api_key:
174
+ return False, "server returned no ingest API key"
175
+
176
+ last_msg = ""
177
+ for attempt in range(len(sleeps) + 1):
178
+ ok, msg = _ingest_probe_once(str(endpoint), str(api_key), str(api_header))
179
+ if ok:
180
+ return True, msg
181
+ last_msg = msg
182
+ if "HTTP 401" not in msg:
183
+ return False, msg
184
+ if attempt < len(sleeps):
185
+ sleep_s = sleeps[attempt]
186
+ log(
187
+ f"ingest key returned 401; retrying in {sleep_s:.0f}s "
188
+ f"(attempt {attempt + 2}/{len(sleeps) + 1})..."
189
+ )
190
+ time.sleep(sleep_s)
191
+ total = sum(sleeps)
192
+ return False, f"{last_msg} after ~{total:.0f}s; ingest key did not propagate"
@@ -0,0 +1,135 @@
1
+ """Initiative resolution from git facts — pure logic, no I/O beyond git.
2
+
3
+ One branch = one initiative. Branch names following the
4
+ `<type-prefix>/<kebab-name>` convention classify exactly; protected
5
+ branches are research/scoping; everything else gets a stable name with
6
+ type 'feature'.
7
+
8
+ Kept in lockstep with conductor's normalizeInitiativeName — this is now
9
+ the single client-side copy (spec §Core module inventory).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import subprocess
16
+
17
+ PROTECTED_BRANCHES = frozenset({"main", "master", "develop", "trunk"})
18
+
19
+ # Noise words that appear between `worktree-` and the real name in
20
+ # EnterWorktree-style branches.
21
+ WORKTREE_NOISE = frozenset({
22
+ "fix", "feat", "bug", "bugfix", "issue", "issues", "pr",
23
+ })
24
+ NUMERIC_SEGMENT_RE = re.compile(r"^\d+$")
25
+
26
+ PREFIX_TO_TYPE = {
27
+ "feat": "feature",
28
+ "feature": "feature",
29
+ "perf": "feature",
30
+ "fix": "bugfix",
31
+ "bugfix": "bugfix",
32
+ "refactor": "refactor",
33
+ "cleanup": "refactor",
34
+ "infra": "infra",
35
+ "chore": "infra",
36
+ "test": "infra",
37
+ "tests": "infra",
38
+ "ci": "infra",
39
+ "build": "infra",
40
+ "deps": "infra",
41
+ "docs": "infra",
42
+ "doc": "infra",
43
+ "research": "research",
44
+ "spike": "research",
45
+ }
46
+
47
+ REMOTE_URL_RE = re.compile(r"(?:git@|https?://)([^:/]+)[:/]([^/]+)/(.+?)(?:\.git)?/?$")
48
+
49
+ COMMAND_RE = re.compile(r"^\s*/([A-Za-z0-9][\w:-]*)")
50
+ COMMAND_TAG_RE = re.compile(r"<command-name>\s*/?([\w:-]+)\s*</command-name>")
51
+
52
+
53
+ def strip_worktree_noise(name: str) -> str:
54
+ """worktree-fix-1018-github-app-repo-picker → github-app-repo-picker.
55
+ Conservative: non-worktree names pass through verbatim; if nothing
56
+ real remains after the head, keep the original."""
57
+ if not name.startswith("worktree-"):
58
+ return name
59
+ segs = name.split("-")
60
+ i = 1
61
+ while i < len(segs) and (
62
+ segs[i] in WORKTREE_NOISE or NUMERIC_SEGMENT_RE.match(segs[i])
63
+ ):
64
+ i += 1
65
+ if i < len(segs):
66
+ return "-".join(segs[i:])
67
+ return name
68
+
69
+
70
+ def resolve_initiative(branch: str | None) -> tuple[str | None, str]:
71
+ """(initiative_name, initiative_type) for a branch name."""
72
+ if not branch or branch == "HEAD":
73
+ return None, "research"
74
+ if branch in PROTECTED_BRANCHES:
75
+ return None, "research"
76
+ if "/" in branch:
77
+ prefix, _, rest = branch.partition("/")
78
+ mapped = PREFIX_TO_TYPE.get(prefix.lower())
79
+ if mapped and rest:
80
+ return strip_worktree_noise(rest), mapped
81
+ return strip_worktree_noise(branch), "feature"
82
+
83
+
84
+ def canonical_repo(remote_url: str | None) -> str | None:
85
+ """'git@github.com:org/name.git' → 'org/name'."""
86
+ if not remote_url:
87
+ return None
88
+ m = REMOTE_URL_RE.match(remote_url.strip())
89
+ if not m:
90
+ return None
91
+ name = re.sub(r"\.git$", "", m.group(3))
92
+ return f"{m.group(2)}/{name}" if m.group(2) and name else None
93
+
94
+
95
+ def detect_command(prompt: object) -> str | None:
96
+ """'/code-review --fix' → 'code-review'. Accepts the raw typed form
97
+ (anchored at start) and the expanded <command-name> tag form."""
98
+ if not isinstance(prompt, str):
99
+ return None
100
+ m = COMMAND_RE.match(prompt)
101
+ if m:
102
+ return m.group(1)
103
+ m = COMMAND_TAG_RE.search(prompt)
104
+ if m:
105
+ return m.group(1)
106
+ return None
107
+
108
+
109
+ def git(args: list[str], cwd: str, timeout: float = 1.0) -> str | None:
110
+ """Best-effort git invocation; None on any failure."""
111
+ try:
112
+ out = subprocess.run(
113
+ ["git", *args],
114
+ cwd=cwd,
115
+ capture_output=True,
116
+ text=True,
117
+ timeout=timeout,
118
+ check=False,
119
+ )
120
+ except (OSError, subprocess.TimeoutExpired):
121
+ return None
122
+ if out.returncode != 0:
123
+ return None
124
+ return out.stdout.strip() or None
125
+
126
+
127
+ def is_git_repo(cwd: str) -> bool:
128
+ return git(["rev-parse", "--is-inside-work-tree"], cwd) == "true"
129
+
130
+
131
+ def git_facts(cwd: str) -> tuple[str | None, str | None]:
132
+ """(repo 'org/name', branch) for cwd — best-effort."""
133
+ branch = git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)
134
+ remote = git(["remote", "get-url", "origin"], cwd)
135
+ return canonical_repo(remote), branch