cardinal-agent-core 0.3.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.
- cardinal_agent_core-0.3.0/PKG-INFO +26 -0
- cardinal_agent_core-0.3.0/README.md +16 -0
- cardinal_agent_core-0.3.0/cardinal_agent_core.egg-info/PKG-INFO +26 -0
- cardinal_agent_core-0.3.0/cardinal_agent_core.egg-info/SOURCES.txt +16 -0
- cardinal_agent_core-0.3.0/cardinal_agent_core.egg-info/dependency_links.txt +1 -0
- cardinal_agent_core-0.3.0/cardinal_agent_core.egg-info/top_level.txt +1 -0
- cardinal_agent_core-0.3.0/cardinal_core/__init__.py +14 -0
- cardinal_agent_core-0.3.0/cardinal_core/bashclass.py +140 -0
- cardinal_agent_core-0.3.0/cardinal_core/deviceflow.py +192 -0
- cardinal_agent_core-0.3.0/cardinal_core/initiative.py +135 -0
- cardinal_agent_core-0.3.0/cardinal_core/limits.py +340 -0
- cardinal_agent_core-0.3.0/cardinal_core/otlp.py +186 -0
- cardinal_agent_core-0.3.0/cardinal_core/paths.py +152 -0
- cardinal_agent_core-0.3.0/cardinal_core/pricing.py +129 -0
- cardinal_agent_core-0.3.0/cardinal_core/session.py +159 -0
- cardinal_agent_core-0.3.0/pyproject.toml +18 -0
- cardinal_agent_core-0.3.0/setup.cfg +4 -0
- cardinal_agent_core-0.3.0/tests/test_core.py +629 -0
|
@@ -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,16 @@
|
|
|
1
|
+
# cardinal-agent-core
|
|
2
|
+
|
|
3
|
+
The Cardinal agent-telemetry contract, written once. See
|
|
4
|
+
`../docs/specs/agent-core.md` for the extraction spec and
|
|
5
|
+
`../README.md` for the monorepo layout.
|
|
6
|
+
|
|
7
|
+
Ships two ways:
|
|
8
|
+
|
|
9
|
+
- **Vendored** into each CLI plugin artifact at build time
|
|
10
|
+
(`build/vendor.py`) — plugins stay self-contained, no pip.
|
|
11
|
+
- **Installed** as a normal package (`pip install -e core/`) by
|
|
12
|
+
server-side consumers (the omnigent adapter) and the test suite.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
cd core && python3 -m unittest discover tests -v
|
|
16
|
+
```
|
|
@@ -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,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
cardinal_agent_core.egg-info/PKG-INFO
|
|
4
|
+
cardinal_agent_core.egg-info/SOURCES.txt
|
|
5
|
+
cardinal_agent_core.egg-info/dependency_links.txt
|
|
6
|
+
cardinal_agent_core.egg-info/top_level.txt
|
|
7
|
+
cardinal_core/__init__.py
|
|
8
|
+
cardinal_core/bashclass.py
|
|
9
|
+
cardinal_core/deviceflow.py
|
|
10
|
+
cardinal_core/initiative.py
|
|
11
|
+
cardinal_core/limits.py
|
|
12
|
+
cardinal_core/otlp.py
|
|
13
|
+
cardinal_core/paths.py
|
|
14
|
+
cardinal_core/pricing.py
|
|
15
|
+
cardinal_core/session.py
|
|
16
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -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
|