agent-governance 1.0.4__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,310 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ import urllib.error
9
+ import urllib.request
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timedelta, timezone
12
+ from importlib import metadata
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from packaging.version import InvalidVersion, Version
17
+
18
+ from agent_governance import PACKAGE_NAME, __version__
19
+
20
+ try:
21
+ import tomllib # type: ignore
22
+ except ModuleNotFoundError: # pragma: no cover
23
+ import tomli as tomllib # type: ignore
24
+
25
+ DEFAULT_TTL_HOURS = 24
26
+ ERROR_TTL_HOURS = 6
27
+ PYPI_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
28
+
29
+
30
+ @dataclass
31
+ class CacheEntry:
32
+ checked_at_utc: str
33
+ installed_version: str
34
+ latest_version: str
35
+ source: str
36
+ etag: str | None = None
37
+ last_error: str | None = None
38
+
39
+
40
+ def resolve_mode(cli_mode: str | None, env: dict[str, str]) -> str:
41
+ env_mode = env.get("AGENT_UPDATE_CHECK")
42
+ if env_mode:
43
+ return env_mode
44
+ return cli_mode or "auto"
45
+
46
+
47
+ def is_ci(env: dict[str, str]) -> bool:
48
+ return env.get("CI", "").lower() == "true"
49
+
50
+
51
+ def parse_time(value: str) -> datetime | None:
52
+ if not value:
53
+ return None
54
+ if value.endswith("Z"):
55
+ value = value[:-1] + "+00:00"
56
+ try:
57
+ return datetime.fromisoformat(value)
58
+ except ValueError:
59
+ return None
60
+
61
+
62
+ def format_time(dt: datetime) -> str:
63
+ return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
64
+
65
+
66
+ def read_cache(cache_path: Path) -> CacheEntry | None:
67
+ try:
68
+ if not cache_path.exists():
69
+ return None
70
+ data = json.loads(cache_path.read_text())
71
+ except (json.JSONDecodeError, OSError):
72
+ return None
73
+ if not isinstance(data, dict):
74
+ return None
75
+ return CacheEntry(
76
+ checked_at_utc=data.get("checked_at_utc", ""),
77
+ installed_version=data.get("installed_version", ""),
78
+ latest_version=data.get("latest_version", ""),
79
+ source=data.get("source", ""),
80
+ etag=data.get("etag"),
81
+ last_error=data.get("last_error"),
82
+ )
83
+
84
+
85
+ def write_cache(cache_path: Path, entry: CacheEntry) -> None:
86
+ payload = {
87
+ "checked_at_utc": entry.checked_at_utc,
88
+ "installed_version": entry.installed_version,
89
+ "latest_version": entry.latest_version,
90
+ "source": entry.source,
91
+ "etag": entry.etag,
92
+ "last_error": entry.last_error,
93
+ }
94
+ try:
95
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
96
+ cache_path.write_text(json.dumps(payload, indent=2, sort_keys=True))
97
+ except OSError:
98
+ try:
99
+ fallback = fallback_cache_path()
100
+ fallback.parent.mkdir(parents=True, exist_ok=True)
101
+ fallback.write_text(json.dumps(payload, indent=2, sort_keys=True))
102
+ except OSError:
103
+ return
104
+
105
+
106
+ def get_cache_path(env: dict[str, str]) -> Path:
107
+ cache_root = env.get("XDG_CACHE_HOME")
108
+ if cache_root:
109
+ return Path(cache_root) / "agent_governance" / "update_check.json"
110
+ return Path.home() / ".cache" / "agent_governance" / "update_check.json"
111
+
112
+
113
+ def fallback_cache_path() -> Path:
114
+ return Path.home() / ".agent_governance" / "cache" / "update_check.json"
115
+
116
+
117
+ def should_check(
118
+ now: datetime,
119
+ env: dict[str, str],
120
+ cache: CacheEntry | None,
121
+ mode: str,
122
+ ci: bool,
123
+ interactive: bool,
124
+ ) -> tuple[bool, str]:
125
+ mode = mode or "auto"
126
+ if mode == "off":
127
+ return False, "mode off"
128
+ if mode == "auto":
129
+ if ci:
130
+ return False, "ci auto disabled"
131
+ if not interactive:
132
+ return False, "non-interactive auto disabled"
133
+ if mode == "verbose":
134
+ if ci:
135
+ return False, "ci verbose disabled"
136
+ if not interactive:
137
+ return False, "non-interactive verbose disabled"
138
+ if mode == "on":
139
+ return True, "forced on"
140
+
141
+ if not cache:
142
+ return True, "no cache"
143
+
144
+ checked_at = parse_time(cache.checked_at_utc)
145
+ if not checked_at:
146
+ return True, "cache timestamp invalid"
147
+
148
+ installed_version, _source = get_installed_version(env)
149
+ if cache.installed_version and cache.installed_version != installed_version:
150
+ return True, "installed version changed"
151
+
152
+ ttl_hours = ERROR_TTL_HOURS if cache.last_error else DEFAULT_TTL_HOURS
153
+ if now - checked_at < timedelta(hours=ttl_hours):
154
+ return False, "cache fresh"
155
+ return True, "cache stale"
156
+
157
+
158
+ def _source_root_from_env(env: dict[str, str]) -> Path | None:
159
+ root = env.get("AGENT_GOVERNANCE_ROOT")
160
+ if root:
161
+ return Path(root).resolve()
162
+ for parent in Path(__file__).resolve().parents:
163
+ if (parent / "pyproject.toml").exists() or (parent / "VERSION").exists():
164
+ return parent
165
+ return None
166
+
167
+
168
+ def _version_from_pyproject(root: Path) -> str | None:
169
+ pyproject = root / "pyproject.toml"
170
+ if not pyproject.exists():
171
+ return None
172
+ try:
173
+ data = tomllib.loads(pyproject.read_text())
174
+ except tomllib.TOMLDecodeError:
175
+ return None
176
+ if not isinstance(data, dict):
177
+ return None
178
+ project = data.get("project", {})
179
+ if isinstance(project, dict):
180
+ version = project.get("version")
181
+ if isinstance(version, str) and version.strip():
182
+ return version.strip()
183
+ tool = data.get("tool", {})
184
+ if isinstance(tool, dict):
185
+ poetry = tool.get("poetry", {})
186
+ if isinstance(poetry, dict):
187
+ version = poetry.get("version")
188
+ if isinstance(version, str) and version.strip():
189
+ return version.strip()
190
+ return None
191
+
192
+
193
+ def _version_from_source(root: Path) -> str | None:
194
+ version = _version_from_pyproject(root)
195
+ if version:
196
+ return version
197
+ version_path = root / "VERSION"
198
+ if version_path.exists():
199
+ value = version_path.read_text().strip()
200
+ if value:
201
+ return value
202
+ return None
203
+
204
+
205
+ def get_installed_version(env: dict[str, str]) -> tuple[str, str]:
206
+ try:
207
+ return metadata.version(PACKAGE_NAME), "metadata"
208
+ except metadata.PackageNotFoundError:
209
+ root = _source_root_from_env(env)
210
+ if root:
211
+ version = _version_from_source(root)
212
+ if version:
213
+ return version, "source"
214
+ return __version__, "unknown"
215
+
216
+
217
+ def compare_versions(installed: str, latest: str) -> int:
218
+ try:
219
+ installed_v = Version(installed)
220
+ latest_v = Version(latest)
221
+ except InvalidVersion:
222
+ return 0
223
+ if latest_v.is_prerelease and not installed_v.is_prerelease:
224
+ return 0
225
+ if latest_v > installed_v:
226
+ return 1
227
+ return 0
228
+
229
+
230
+ def fetch_latest(
231
+ cache: CacheEntry | None, installed_version: str
232
+ ) -> tuple[str | None, str | None, str | None]:
233
+ headers = {
234
+ "User-Agent": f"{PACKAGE_NAME}/{installed_version}",
235
+ }
236
+ if cache and cache.etag:
237
+ headers["If-None-Match"] = cache.etag
238
+ req = urllib.request.Request(PYPI_URL, headers=headers)
239
+ try:
240
+ with urllib.request.urlopen(req, timeout=2.5) as resp:
241
+ if resp.status == 304 and cache:
242
+ return cache.latest_version, cache.etag, None
243
+ payload = json.loads(resp.read().decode("utf-8"))
244
+ latest = payload.get("info", {}).get("version")
245
+ if not isinstance(latest, str):
246
+ return None, None, "invalid version"
247
+ etag = resp.headers.get("ETag")
248
+ return latest, etag, None
249
+ except urllib.error.HTTPError as exc:
250
+ if exc.code == 304 and cache:
251
+ return cache.latest_version, cache.etag, None
252
+ return None, None, f"http error {exc.code}"
253
+ except Exception as exc: # pragma: no cover - network errors
254
+ return None, None, str(exc)
255
+
256
+
257
+ def maybe_check(
258
+ root: Path,
259
+ mode: str,
260
+ env: dict[str, str],
261
+ policy: str | None,
262
+ out: Any = sys.stderr,
263
+ ) -> None:
264
+ if policy == "off":
265
+ if mode == "verbose":
266
+ out.write("update check disabled by policy\n")
267
+ return
268
+
269
+ now = datetime.now(timezone.utc)
270
+ cache_path = get_cache_path(env)
271
+ cache = read_cache(cache_path)
272
+ ci = is_ci(env)
273
+ interactive = sys.stderr.isatty()
274
+ should_run, reason = should_check(now, env, cache, mode, ci, interactive)
275
+ if not should_run:
276
+ if mode == "verbose":
277
+ out.write(f"update check skipped: {reason}\n")
278
+ return
279
+
280
+ installed_version, _source = get_installed_version(env)
281
+ latest, etag, error = fetch_latest(cache, installed_version)
282
+ if error or not latest:
283
+ entry = CacheEntry(
284
+ checked_at_utc=format_time(now),
285
+ installed_version=installed_version,
286
+ latest_version=cache.latest_version if cache else "",
287
+ source="pypi",
288
+ etag=etag or (cache.etag if cache else None),
289
+ last_error=error or "unknown error",
290
+ )
291
+ write_cache(cache_path, entry)
292
+ if mode == "verbose":
293
+ out.write(f"update check failed: {entry.last_error}\n")
294
+ return
295
+
296
+ entry = CacheEntry(
297
+ checked_at_utc=format_time(now),
298
+ installed_version=installed_version,
299
+ latest_version=latest,
300
+ source="pypi",
301
+ etag=etag,
302
+ last_error=None,
303
+ )
304
+ write_cache(cache_path, entry)
305
+
306
+ if compare_versions(installed_version, latest) > 0:
307
+ out.write(
308
+ f"{PACKAGE_NAME} update available: installed {installed_version}, "
309
+ f"latest {latest} (source: pypi).\n"
310
+ )
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-governance
3
+ Version: 1.0.4
4
+ Summary: Agent governance tooling
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: pyyaml>=6.0
8
+ Requires-Dist: jsonschema>=4.21
9
+ Requires-Dist: packaging>=22
10
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
11
+
12
+ # Bounded Agent Framework
13
+
14
+ This repository defines a **repo-local, enforceable framework** for using AI sub-agents as bounded workers.
15
+
16
+ This is **not** an application.
17
+ This is **not** an orchestration service.
18
+ This is **not** an autonomy experiment.
19
+
20
+ This is infrastructure.
21
+
22
+ ---
23
+
24
+ ## What this system is
25
+
26
+ - Agents are **roles defined in Markdown**
27
+ - Agents are **instantiated per model call**
28
+ - Nothing is persistent
29
+ - Nothing runs automatically
30
+ - Nothing is trusted without artifacts
31
+
32
+ The framework exists to:
33
+
34
+ - force correct task decomposition
35
+ - prevent scope creep
36
+ - require evidence for completion
37
+ - make agent behavior reproducible and auditable
38
+
39
+ ---
40
+
41
+ ## Core invariants
42
+
43
+ 1. **Repo-local**
44
+ - All agent rules, schemas, and tools live in the repo
45
+ - No systemwide installs
46
+ - No hidden dependencies
47
+
48
+ 2. **Separation of concerns**
49
+ - Agent roles (Markdown) change frequently
50
+ - Schemas change occasionally
51
+ - Tooling changes rarely
52
+
53
+ 3. **Artifacts over prose**
54
+ - Diffs, logs, commands, tests are required
55
+ - Narrative text is secondary
56
+
57
+ 4. **Orchestrator ≠ implementer**
58
+ - Orchestrator plans and validates
59
+ - Workers produce changes
60
+ - Single-writer rule per scope
61
+
62
+ 5. **No schema, no run**
63
+ - Tasks and outputs must validate
64
+ - Invalid packets are rejected
65
+
66
+ ---
67
+
68
+ ## Repository layout (expected)
69
+
70
+ agents/
71
+ roles/
72
+ contracts/
73
+ checklists/
74
+ tools/
75
+ logs/
76
+ reports/
77
+ adr/
78
+
79
+ ---
80
+
81
+ ## Usage summary
82
+
83
+ 1. Create a task packet (YAML)
84
+ 2. Validate it against schema
85
+ 3. Run the agent with role + task
86
+ 4. Collect output packet + artifacts
87
+ 5. Validate output
88
+ 6. Gate before merge
89
+
90
+ Tooling baseline: use `agent-governance>=1.0.4`.
91
+
92
+ ## Repo introspection
93
+
94
+ `agentctl init` scans the current repo to generate a policy overlay plus an
95
+ evidence-backed report. It never edits repo files unless `--write` is set, and
96
+ it never calls an LLM.
97
+
98
+ ## Bootstrap policy block
99
+
100
+ Use `agentctl bootstrap` to author or update the AGENTS.md policy block from the
101
+ canonical role registry. It is preview-only unless `--write` is provided.
102
+
103
+ What it never does:
104
+
105
+ - no network access
106
+ - no repo mutation (except writing the three outputs when `--write` is set)
107
+ - no heuristic guesses without a cited file + line range
108
+
109
+ Outputs (when `--write`):
110
+
111
+ - `.agents/generated/AGENTS.repo.overlay.yaml`
112
+ - `.agents/generated/init_report.md`
113
+ - `.agents/generated/init_facts.json`
114
+
115
+ At runtime, the orchestrator merges the overlay with the base policy to add
116
+ repo-specific verify commands and risk paths before dispatching work. This lets
117
+ one global role set adapt to local tooling without changing the core policy.
118
+
119
+ Deterministic + auditable:
120
+
121
+ - each detected fact includes a source file and line range
122
+ - output ordering is stable for diff-friendly review
123
+ - dry-runs show planned writes without touching disk
124
+
125
+ ## Update checks
126
+
127
+ The CLI can optionally warn if a newer `agent_governance` version exists.
128
+ Defaults: once per 24h, skipped in CI, and never blocks command execution.
129
+
130
+ Disable with `--update-check=off` or `AGENT_UPDATE_CHECK=off`. Repos may set
131
+ `update_check: off` in `agents/repo_profile.yaml` to fully disable network use.
132
+
133
+ This system replaces trust with structure.
@@ -0,0 +1,12 @@
1
+ agent_governance/__init__.py,sha256=gVJvP56TC_hU6GYFMwuQE4vys120FBs21LzyBkFvhFY,1494
2
+ agent_governance/agentctl.py,sha256=7ouKYGek4GiHGCGkm_ghChosTR7mt1PFe4RMPw1Q8XQ,30982
3
+ agent_governance/init.py,sha256=6yGQZ9OUhbwI6RgTezj-d0Ku0s0EBm6xbF5WXDlIVLQ,34127
4
+ agent_governance/update_check.py,sha256=R-pudcL5RThA0V01-CqIJiQnXK6KZVVB8K80-ti7WuQ,9453
5
+ agent_governance/contracts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ agent_governance/contracts/output_packet.schema.json,sha256=zAwfreDQm5vmLvAdLcVr8uC_HOnRMU_M5Ji6AyemjRo,2088
7
+ agent_governance/contracts/task_packet.schema.json,sha256=4zUqV3QUUbV0zpyOpZV_1vZE-zNamyFxPs8KH2gQpg4,1664
8
+ agent_governance-1.0.4.dist-info/METADATA,sha256=IFx6V3Ghnkn8jkuITIquF6KmpO6m18ZH2nmfSsgtPnE,3448
9
+ agent_governance-1.0.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
+ agent_governance-1.0.4.dist-info/entry_points.txt,sha256=iSbWaXVz3_Sc0TDsgpgAA3qh7Jk5qSmxVfaBuQJTnvs,60
11
+ agent_governance-1.0.4.dist-info/top_level.txt,sha256=s1hxHyuJLAc3on5R5N8JCE0kI5z8_vMYKTTAWiax1eQ,17
12
+ agent_governance-1.0.4.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agentctl = agent_governance.agentctl:main
@@ -0,0 +1 @@
1
+ agent_governance