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,945 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from datetime import datetime
11
+ from importlib import resources
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+ from jsonschema import validate, ValidationError
16
+
17
+ from importlib import metadata
18
+
19
+ from packaging.version import InvalidVersion, Version
20
+
21
+ from agent_governance import PACKAGE_NAME, get_version
22
+ from agent_governance.init import (
23
+ ParseError,
24
+ check_agents_md,
25
+ parse_agents_policy,
26
+ render_error_report,
27
+ run_init,
28
+ )
29
+
30
+ CANONICAL_ROLES = [
31
+ {"name": "triage", "purpose": "Clarify scope, risks, and next steps."},
32
+ {"name": "testing", "purpose": "Add/adjust tests and verify coverage."},
33
+ {"name": "bugfix", "purpose": "Fix defects with minimal scope."},
34
+ {"name": "refactor", "purpose": "Improve structure without behavior changes."},
35
+ {"name": "docs", "purpose": "Update documentation and usage notes."},
36
+ {"name": "data_contract", "purpose": "Define or validate data contracts."},
37
+ {"name": "perf", "purpose": "Profile and improve performance."},
38
+ {"name": "security", "purpose": "Harden security and address threats."},
39
+ ]
40
+
41
+ DEFAULT_MIN_TOOL_VERSION = "1.0.4"
42
+ from agent_governance.update_check import maybe_check, resolve_mode
43
+
44
+
45
+ def resolve_repo_root() -> Path:
46
+ env_root = os.environ.get("AGENT_GOVERNANCE_ROOT")
47
+ if env_root:
48
+ return Path(env_root).resolve()
49
+ start = Path.cwd().resolve()
50
+ for parent in [start, *start.parents]:
51
+ git_marker = parent / ".git"
52
+ if git_marker.exists():
53
+ return parent
54
+ return start
55
+
56
+
57
+ ROOT = resolve_repo_root()
58
+ CONTRACTS = ROOT / "agents" / "contracts"
59
+ TEMPLATES = CONTRACTS / "templates"
60
+ REPORTS_TASKS = ROOT / "reports" / "tasks"
61
+ REPORTS_GATES = ROOT / "reports" / "gates"
62
+ LOGS_AGENTS = ROOT / "logs" / "agents"
63
+
64
+
65
+ def _load_agents_policy(strict: bool) -> dict[str, object] | None:
66
+ path = ROOT / "AGENTS.md"
67
+ if not path.exists():
68
+ return None
69
+ try:
70
+ policy, _block = parse_agents_policy(path)
71
+ except ParseError:
72
+ if strict:
73
+ raise
74
+ return None
75
+ version = policy.get("policy_schema_version")
76
+ if not isinstance(version, int):
77
+ if strict:
78
+ raise ParseError(path, "policy_schema_version must be an integer")
79
+ return None
80
+ if version < 1 or version > 1:
81
+ if strict:
82
+ raise ParseError(path, f"unsupported policy_schema_version: {version}")
83
+ return None
84
+ return policy
85
+
86
+
87
+ def _detect_installation() -> tuple[str, bool, str]:
88
+ version = get_version()
89
+ if version == "unknown":
90
+ return version, False, "unknown_version"
91
+ try:
92
+ dist = metadata.distribution(PACKAGE_NAME)
93
+ except metadata.PackageNotFoundError:
94
+ return version, False, "no_metadata"
95
+ direct_url = dist.read_text("direct_url.json")
96
+ if direct_url:
97
+ try:
98
+ data = json.loads(direct_url)
99
+ except json.JSONDecodeError:
100
+ return version, False, "direct_url_invalid"
101
+ dir_info = data.get("dir_info", {}) if isinstance(data, dict) else {}
102
+ if isinstance(dir_info, dict) and dir_info.get("editable") is True:
103
+ return version, False, "editable"
104
+ url = data.get("url")
105
+ if isinstance(url, str) and url.startswith("file:"):
106
+ return version, False, "file_url"
107
+ return version, True, "pinned"
108
+
109
+
110
+ def _enforce_tool_policy() -> int:
111
+ strict = os.environ.get("CI", "").lower() == "true"
112
+ policy = _load_agents_policy(strict=strict)
113
+ if not policy:
114
+ return 0
115
+
116
+ require_pinned = policy.get("require_pinned_tool")
117
+ min_version = policy.get("min_tool_version")
118
+ max_version = policy.get("max_tool_version")
119
+
120
+ if require_pinned is not None and not isinstance(require_pinned, bool):
121
+ raise ParseError(ROOT / "AGENTS.md", "require_pinned_tool must be boolean")
122
+ if min_version is not None and not isinstance(min_version, str):
123
+ raise ParseError(ROOT / "AGENTS.md", "min_tool_version must be a string")
124
+ if max_version is not None and not isinstance(max_version, str):
125
+ raise ParseError(ROOT / "AGENTS.md", "max_tool_version must be a string")
126
+
127
+ installed_version, pinned, pin_reason = _detect_installation()
128
+
129
+ if require_pinned and not pinned:
130
+ print(
131
+ f"policy requires pinned agent-governance install: detected {pin_reason}",
132
+ file=sys.stderr,
133
+ )
134
+ return 2
135
+
136
+ if min_version or max_version:
137
+ try:
138
+ current = Version(installed_version)
139
+ except InvalidVersion as exc:
140
+ raise ParseError(ROOT / "AGENTS.md", f"invalid installed version: {exc}")
141
+ if min_version:
142
+ try:
143
+ minimum = Version(min_version)
144
+ except InvalidVersion as exc:
145
+ raise ParseError(
146
+ ROOT / "AGENTS.md", f"invalid min_tool_version: {exc}"
147
+ )
148
+ if current < minimum:
149
+ print(
150
+ f"installed version {current} < min_tool_version {minimum}",
151
+ file=sys.stderr,
152
+ )
153
+ return 2
154
+ if max_version:
155
+ try:
156
+ maximum = Version(max_version)
157
+ except InvalidVersion as exc:
158
+ raise ParseError(
159
+ ROOT / "AGENTS.md", f"invalid max_tool_version: {exc}"
160
+ )
161
+ if current > maximum:
162
+ print(
163
+ f"installed version {current} > max_tool_version {maximum}",
164
+ file=sys.stderr,
165
+ )
166
+ return 2
167
+
168
+ return 0
169
+
170
+
171
+ def _render_policy_block(
172
+ allowed_roles: list[str],
173
+ min_tool_version: str | None,
174
+ max_tool_version: str | None,
175
+ require_pinned_tool: bool | None,
176
+ ) -> str:
177
+ lines = [
178
+ "policy_schema_version: 1",
179
+ f"min_tool_version: {min_tool_version or DEFAULT_MIN_TOOL_VERSION}",
180
+ ]
181
+ if max_tool_version:
182
+ lines.append(f"max_tool_version: {max_tool_version}")
183
+ if require_pinned_tool is not None:
184
+ flag = "true" if require_pinned_tool else "false"
185
+ lines.append(f"require_pinned_tool: {flag}")
186
+ lines.append("allowed_roles:")
187
+ for role in sorted(allowed_roles):
188
+ lines.append(f" - {role}")
189
+ return "\n".join(lines + [""])
190
+
191
+
192
+ def _find_policy_block(lines: list[str]) -> tuple[int, int] | None:
193
+ fenced_starts = [idx for idx, line in enumerate(lines) if line.strip().startswith("```yaml")]
194
+ if fenced_starts:
195
+ if len(fenced_starts) > 1:
196
+ raise ParseError(ROOT / "AGENTS.md", "multiple policy blocks found")
197
+ start = fenced_starts[0]
198
+ end = None
199
+ for idx in range(start + 1, len(lines)):
200
+ if lines[idx].strip() == "```":
201
+ end = idx
202
+ break
203
+ if end is None:
204
+ raise ParseError(ROOT / "AGENTS.md", "unterminated policy block")
205
+ for idx, line in enumerate(lines):
206
+ if start <= idx <= end:
207
+ continue
208
+ if line.lstrip().startswith("policy_schema_version:"):
209
+ raise ParseError(ROOT / "AGENTS.md", "multiple policy blocks found")
210
+ return start, end
211
+
212
+ starts = [
213
+ idx
214
+ for idx, line in enumerate(lines)
215
+ if line.lstrip().startswith("policy_schema_version:")
216
+ ]
217
+ if not starts:
218
+ return None
219
+ if len(starts) > 1:
220
+ raise ParseError(ROOT / "AGENTS.md", "multiple policy blocks found")
221
+ start = starts[0]
222
+ start_indent = len(lines[start]) - len(lines[start].lstrip())
223
+ end = start
224
+ for idx in range(start + 1, len(lines)):
225
+ line = lines[idx]
226
+ if not line.strip():
227
+ break
228
+ if re.match(r"^#{1,6}\\s", line.lstrip()):
229
+ break
230
+ line_indent = len(line) - len(line.lstrip())
231
+ if line_indent < start_indent:
232
+ break
233
+ end = idx
234
+ return start, end
235
+
236
+
237
+ def _parse_allowed_roles(raw: str) -> list[str]:
238
+ roles = [item.strip() for item in raw.split(",") if item.strip()]
239
+ if not roles:
240
+ raise ParseError(ROOT / "AGENTS.md", "no roles selected")
241
+ known = {entry["name"] for entry in CANONICAL_ROLES}
242
+ unknown = sorted(set(roles) - known)
243
+ if unknown:
244
+ raise ParseError(
245
+ ROOT / "AGENTS.md",
246
+ f"unknown roles: {', '.join(unknown)}",
247
+ )
248
+ return roles
249
+
250
+
251
+ def _interactive_role_selection() -> list[str]:
252
+ if not sys.stdin.isatty():
253
+ raise ParseError(
254
+ ROOT / "AGENTS.md",
255
+ "interactive selection requires a TTY; use --allow",
256
+ )
257
+ print("Available roles:")
258
+ for idx, entry in enumerate(CANONICAL_ROLES, start=1):
259
+ print(f"{idx}. {entry['name']} — {entry['purpose']}")
260
+ selection = input("Select roles (comma-separated numbers or 'all'): ").strip()
261
+ if not selection:
262
+ raise ParseError(ROOT / "AGENTS.md", "no roles selected")
263
+ if selection.lower() == "all":
264
+ return [entry["name"] for entry in CANONICAL_ROLES]
265
+ choices = []
266
+ for item in selection.split(","):
267
+ item = item.strip()
268
+ if not item:
269
+ continue
270
+ if not item.isdigit():
271
+ raise ParseError(ROOT / "AGENTS.md", f"invalid selection: {item}")
272
+ choices.append(int(item))
273
+ roles = []
274
+ for idx in choices:
275
+ if idx < 1 or idx > len(CANONICAL_ROLES):
276
+ raise ParseError(ROOT / "AGENTS.md", f"invalid selection: {idx}")
277
+ roles.append(CANONICAL_ROLES[idx - 1]["name"])
278
+ if not roles:
279
+ raise ParseError(ROOT / "AGENTS.md", "no roles selected")
280
+ return roles
281
+
282
+
283
+ REQUIRED_SECTIONS = [
284
+ (
285
+ "## agent init behavior",
286
+ [
287
+ "- init is evidence-only (no LLM)",
288
+ "- ignore: .venv/, node_modules/, .git/",
289
+ "- prefer python3 over python when present",
290
+ ],
291
+ ),
292
+ ("## Notes", ["- Add human context here."]),
293
+ ]
294
+
295
+
296
+ def _render_required_sections() -> list[str]:
297
+ lines: list[str] = []
298
+ for heading, items in REQUIRED_SECTIONS:
299
+ if lines:
300
+ lines.append("")
301
+ lines.append(heading)
302
+ lines.extend(items)
303
+ return lines
304
+
305
+
306
+ def cmd_bootstrap(
307
+ allow: str | None,
308
+ write: bool,
309
+ min_tool_version: str | None,
310
+ max_tool_version: str | None,
311
+ require_pinned_tool: bool | None,
312
+ ) -> int:
313
+ try:
314
+ allowed_roles = _parse_allowed_roles(allow) if allow else _interactive_role_selection()
315
+ except ParseError as exc:
316
+ report = render_error_report(ROOT, exc.path, exc.message)
317
+ print(report, end="")
318
+ return 2
319
+
320
+ block = _render_policy_block(
321
+ allowed_roles,
322
+ min_tool_version,
323
+ max_tool_version,
324
+ require_pinned_tool,
325
+ )
326
+
327
+ target = ROOT / "AGENTS.md"
328
+ base_lines = block.strip().splitlines()
329
+ base_lines.append("")
330
+ base_lines.extend(_render_required_sections())
331
+ if target.exists():
332
+ lines = target.read_text().splitlines()
333
+ try:
334
+ _find_policy_block(lines)
335
+ except ParseError as exc:
336
+ report = render_error_report(ROOT, exc.path, exc.message)
337
+ print(report, end="")
338
+ return 2
339
+ content = "\n".join(base_lines) + "\n"
340
+ if not write:
341
+ print(content)
342
+ return 0
343
+ target.write_text(content)
344
+ return 0
345
+
346
+
347
+ def load_schema(name: str) -> dict:
348
+ try:
349
+ schema_path = resources.files("agent_governance.contracts").joinpath(name)
350
+ with schema_path.open("r", encoding="utf-8") as f:
351
+ return json.load(f)
352
+ except FileNotFoundError:
353
+ pass
354
+ if os.environ.get("AGENT_GOVERNANCE_SCHEMA_OVERRIDE") == "1":
355
+ with open(CONTRACTS / name, "r", encoding="utf-8") as f:
356
+ return json.load(f)
357
+ raise FileNotFoundError(f"schema not found: {name}")
358
+
359
+
360
+ def load_yaml(path):
361
+ with open(path, "r") as f:
362
+ return yaml.safe_load(f)
363
+
364
+
365
+ def write_yaml(path, data):
366
+ with open(path, "w") as f:
367
+ yaml.safe_dump(data, f, sort_keys=False)
368
+
369
+
370
+ def ensure_dir(path):
371
+ path.mkdir(parents=True, exist_ok=True)
372
+
373
+
374
+ def slugify(value):
375
+ slug = re.sub(r"[^a-zA-Z0-9]+", "-", value).strip("-").lower()
376
+ return slug
377
+
378
+
379
+ def tool_version() -> str:
380
+ return get_version()
381
+
382
+
383
+ def get_repo_context():
384
+ try:
385
+ branch = (
386
+ subprocess.check_output(
387
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
388
+ cwd=ROOT,
389
+ stderr=subprocess.DEVNULL,
390
+ )
391
+ .decode()
392
+ .strip()
393
+ )
394
+ commit = subprocess.check_output(
395
+ ["git", "rev-parse", "HEAD"],
396
+ cwd=ROOT,
397
+ stderr=subprocess.DEVNULL,
398
+ )
399
+ commit = commit.decode().strip()
400
+ except Exception:
401
+ branch = "unknown"
402
+ commit = "unknown"
403
+ return branch, commit
404
+
405
+
406
+ def load_repo_profile():
407
+ profile_path = ROOT / "agents" / "repo_profile.yaml"
408
+ if not profile_path.exists():
409
+ raise FileNotFoundError("missing agents/repo_profile.yaml")
410
+ return load_yaml(profile_path)
411
+
412
+
413
+ def load_repo_policy_update_check() -> str | None:
414
+ profile_path = ROOT / "agents" / "repo_profile.yaml"
415
+ if not profile_path.exists():
416
+ return None
417
+ profile = load_yaml(profile_path) or {}
418
+ update_check = profile.get("update_check")
419
+ if isinstance(update_check, str):
420
+ return update_check
421
+ return None
422
+
423
+
424
+ def validate_packet(packet_path, schema_name):
425
+ schema = load_schema(schema_name)
426
+ data = load_yaml(packet_path)
427
+ validate(instance=data, schema=schema)
428
+
429
+
430
+ def cmd_validate(kind, file_path):
431
+ if kind == "task":
432
+ schema = "task_packet.schema.json"
433
+ elif kind == "output":
434
+ schema = "output_packet.schema.json"
435
+ else:
436
+ raise ValueError("kind must be 'task' or 'output'")
437
+
438
+ try:
439
+ validate_packet(file_path, schema)
440
+ print(f"{kind} packet valid: {file_path}")
441
+ except ValidationError as e:
442
+ print(f"{kind} packet INVALID:")
443
+ print(e.message)
444
+ raise SystemExit(2)
445
+
446
+
447
+ def load_task_template():
448
+ template_path = TEMPLATES / "task.yaml"
449
+ if template_path.exists():
450
+ return load_yaml(template_path)
451
+ return {
452
+ "id": "",
453
+ "title": "",
454
+ "role": "",
455
+ "goal": "TBD",
456
+ "repo_context": {"branch": "unknown", "commit": "unknown", "paths": []},
457
+ "inputs": [],
458
+ "constraints": {"allowed_write_paths": [], "forbidden_actions": []},
459
+ "deliverables": ["diff", "logs", "commands"],
460
+ "stop_conditions": ["TBD"],
461
+ }
462
+
463
+
464
+ def cmd_new_task(role, title):
465
+ ensure_dir(REPORTS_TASKS)
466
+ now = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
467
+ slug = slugify(title)
468
+ task_id = f"{now}_{slug}" if slug else now
469
+
470
+ task = load_task_template()
471
+ task["id"] = task_id
472
+ task["title"] = title
473
+ task["role"] = role
474
+
475
+ repo_context = task.get("repo_context") or {}
476
+ branch, commit = get_repo_context()
477
+ repo_context.setdefault("branch", branch)
478
+ repo_context.setdefault("commit", commit)
479
+ task["repo_context"] = repo_context
480
+
481
+ output_path = REPORTS_TASKS / f"{task_id}.task.yaml"
482
+ write_yaml(output_path, task)
483
+ print(str(output_path))
484
+
485
+
486
+ def cmd_log(run_id, task_file, append_file):
487
+ ensure_dir(LOGS_AGENTS)
488
+ output_path = LOGS_AGENTS / f"{run_id}.log"
489
+ timestamp = datetime.utcnow().isoformat() + "Z"
490
+ task_packet = load_yaml(task_file) or {}
491
+ task_id = task_packet.get("id", "unknown")
492
+ role = task_packet.get("role", "unknown")
493
+
494
+ with open(output_path, "a") as out:
495
+ out.write("---\n")
496
+ out.write(f"task_id: {task_id}\n")
497
+ out.write(f"role: {role}\n")
498
+ out.write(f"run_id: {run_id}\n")
499
+ out.write(f"timestamp: {timestamp}\n")
500
+ out.write("---\n")
501
+ with open(append_file, "r") as src:
502
+ content = src.read()
503
+ out.write(content)
504
+ if content and not content.endswith("\n"):
505
+ out.write("\n")
506
+
507
+ print(str(output_path))
508
+
509
+
510
+ def run_gate_command(name, command, run_dir):
511
+ stdout_path = run_dir / f"{name}.stdout.log"
512
+ stderr_path = run_dir / f"{name}.stderr.log"
513
+ completed = subprocess.run(
514
+ command,
515
+ cwd=ROOT,
516
+ shell=True,
517
+ text=True,
518
+ capture_output=True,
519
+ )
520
+ stdout_path.write_text(completed.stdout)
521
+ stderr_path.write_text(completed.stderr)
522
+ return {
523
+ "name": name,
524
+ "command": command,
525
+ "returncode": completed.returncode,
526
+ "stdout": str(stdout_path),
527
+ "stderr": str(stderr_path),
528
+ "stdout_content": completed.stdout,
529
+ "stderr_content": completed.stderr,
530
+ }
531
+
532
+
533
+ def cmd_gate_pr():
534
+ profile = load_repo_profile()
535
+ commands = profile.get("commands", {})
536
+ policies = profile.get("policies") or {}
537
+ require_tests = bool(policies.get("require_tests", True))
538
+ test_glob = policies.get("python_test_glob", "tests/test_*.py")
539
+ required = ["test", "lint", "typecheck", "format"]
540
+ missing = [name for name in required if not commands.get(name)]
541
+ if missing:
542
+ raise SystemExit(f"missing commands in repo_profile.yaml: {', '.join(missing)}")
543
+
544
+ ensure_dir(REPORTS_GATES)
545
+ now = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
546
+ run_dir = REPORTS_GATES / now
547
+ ensure_dir(run_dir)
548
+
549
+ results = []
550
+ test_files = []
551
+ if require_tests and test_glob:
552
+ test_files = list(ROOT.glob(test_glob))
553
+ for name in required:
554
+ result = run_gate_command(name, commands[name], run_dir)
555
+ if name == "test":
556
+ reasons = []
557
+ if require_tests and test_glob and not test_files:
558
+ reasons.append(
559
+ f"require_tests true: no test files matched python_test_glob={test_glob}"
560
+ )
561
+ combined_output = f"{result['stdout_content']}\n{result['stderr_content']}"
562
+ if require_tests and re.search(r"collected\\s+0\\s+items", combined_output):
563
+ reasons.append("require_tests true: pytest collected 0 items")
564
+ if reasons:
565
+ result["returncode"] = 1
566
+ result["reason"] = "; ".join(reasons)
567
+ results.append(result)
568
+
569
+ report_path = REPORTS_GATES / f"{now}.md"
570
+ failed = [r for r in results if r["returncode"] != 0]
571
+
572
+ lines = []
573
+ lines.append(f"# Gate Report {now}\n")
574
+ for result in results:
575
+ status = "PASS" if result["returncode"] == 0 else "FAIL"
576
+ lines.append(f"## {result['name']} — {status}")
577
+ lines.append(f"- command: `{result['command']}`")
578
+ lines.append(f"- stdout: {result['stdout']}")
579
+ lines.append(f"- stderr: {result['stderr']}\n")
580
+ if result.get("reason"):
581
+ lines.append(f"- reason: {result['reason']}\n")
582
+ summary = "PASS" if not failed else "FAIL"
583
+ lines.append(f"## Summary — {summary}")
584
+ lines.append(f"- total: {len(results)}")
585
+ lines.append(f"- failed: {len(failed)}")
586
+ report_path.write_text("\n".join(lines) + "\n")
587
+
588
+ if failed:
589
+ raise SystemExit(1)
590
+ print(str(report_path))
591
+
592
+
593
+ def _load_init_overlay(root: Path) -> dict[str, object] | None:
594
+ overlay_path = root / ".agents" / "generated" / "AGENTS.repo.overlay.yaml"
595
+ if not overlay_path.exists():
596
+ return None
597
+ try:
598
+ return load_yaml(overlay_path) or {}
599
+ except Exception:
600
+ return None
601
+
602
+
603
+ def _print_gate_plan(overlay: dict[str, object] | None) -> None:
604
+ print("## Gate plan")
605
+ if not overlay:
606
+ print("- init overlay not found; no plan available")
607
+ print("")
608
+ return
609
+ verify = overlay.get("verify_commands", [])
610
+ risk_paths = overlay.get("risk_paths", [])
611
+ if not verify:
612
+ print("- verify_commands: none")
613
+ else:
614
+ print("- verify_commands:")
615
+ for item in verify:
616
+ cmd = " ".join(item.get("command", []))
617
+ cwd = item.get("cwd", ".")
618
+ print(f" - {cmd} (cwd: {cwd})")
619
+ if risk_paths:
620
+ print("- risk_paths:")
621
+ for path in risk_paths:
622
+ print(f" - {path}")
623
+ print("")
624
+
625
+
626
+ def _check_tools_available(overlay: dict[str, object] | None) -> None:
627
+ print("## Tool availability")
628
+ if not overlay:
629
+ print("- init overlay missing; tool checks skipped")
630
+ print("")
631
+ return
632
+ verify = overlay.get("verify_commands", [])
633
+ if not verify:
634
+ print("- no verify commands to check")
635
+ print("")
636
+ return
637
+ for item in verify:
638
+ command = item.get("command", [])
639
+ if not command:
640
+ print("- <empty command>: skipped")
641
+ continue
642
+ tool = command[0]
643
+ available = shutil.which(tool) is not None
644
+ status = "ok" if available else "missing"
645
+ print(f"- {tool}: {status}")
646
+ print("")
647
+
648
+
649
+ def cmd_gate_pr_dry_run() -> int:
650
+ print("# Gate Dry Run")
651
+ print("")
652
+ try:
653
+ agents_status, _template = check_agents_md(
654
+ ROOT, signals=None, facts=None, strict=True
655
+ )
656
+ except Exception as exc:
657
+ if hasattr(exc, "path") and hasattr(exc, "message"):
658
+ report = render_error_report(ROOT, exc.path, exc.message)
659
+ print(report, end="")
660
+ return 2
661
+ print(f"internal error: {exc}", file=sys.stderr)
662
+ return 1
663
+
664
+ print("## AGENTS.md status")
665
+ print(f"- status: {agents_status['status']}")
666
+ for detail in agents_status.get("details", []):
667
+ print(f"- {detail}")
668
+ print("")
669
+
670
+ overlay = _load_init_overlay(ROOT)
671
+ _print_gate_plan(overlay)
672
+ _check_tools_available(overlay)
673
+ return 0
674
+
675
+
676
+ def cmd_ops() -> int:
677
+ lines = [
678
+ "# Agent Governance Ops Contract",
679
+ "",
680
+ "## Recommended install method",
681
+ "- Use a pinned version: agent-governance==<version>",
682
+ "- Prefer pipx for global CLI use; use a venv for project-local installs",
683
+ "",
684
+ "## Pinning rule",
685
+ "- Always install with an explicit version pin",
686
+ "- CI must install the pinned version and run gate checks with it",
687
+ "",
688
+ "## Rollback one-liner",
689
+ "- pipx: pipx install agent-governance==<prev_version> --force",
690
+ "- venv: pip install --force-reinstall agent-governance==<prev_version>",
691
+ "",
692
+ "## Verify version",
693
+ "- agentctl --version",
694
+ "",
695
+ "## Install commands",
696
+ "- pipx: pipx install agent-governance==<version>",
697
+ "- venv: pip install agent-governance==<version>",
698
+ "",
699
+ "## CI",
700
+ "- Install pinned version (pipx or venv) before running gate checks",
701
+ "- Run: agentctl gate pr",
702
+ "",
703
+ ]
704
+ print("\n".join(lines))
705
+ return 0
706
+
707
+
708
+ def build_parser():
709
+ parser = argparse.ArgumentParser(
710
+ prog="agentctl",
711
+ description=(
712
+ "Agent governance CLI (init, gate, validate, ops guidance). "
713
+ "Policy enforcement is driven by AGENTS.md."
714
+ ),
715
+ )
716
+ parser.add_argument(
717
+ "--version",
718
+ action="version",
719
+ version=f"%(prog)s {tool_version()}",
720
+ )
721
+ parser.add_argument(
722
+ "--update-check",
723
+ choices=["auto", "on", "off", "verbose"],
724
+ default="auto",
725
+ help="Enable update checks (auto|on|off|verbose)",
726
+ )
727
+ subparsers = parser.add_subparsers(dest="cmd", required=True)
728
+
729
+ new_task = subparsers.add_parser("new-task", help="Create a task packet")
730
+ new_task.add_argument("--role", required=True, help="Role for the task")
731
+ new_task.add_argument("--title", required=True, help="Task title")
732
+
733
+ validate_cmd = subparsers.add_parser("validate", help="Validate a packet")
734
+ validate_cmd.add_argument("kind", choices=["task", "output"])
735
+ validate_cmd.add_argument("file")
736
+
737
+ log_cmd = subparsers.add_parser("log", help="Append to agent run log")
738
+ log_cmd.add_argument("--run-id", required=True)
739
+ log_cmd.add_argument("--task", required=True, help="Task packet file")
740
+ log_cmd.add_argument("--append", required=True, help="Text file to append")
741
+
742
+ gate_cmd = subparsers.add_parser(
743
+ "gate",
744
+ help="Run repo gates",
745
+ description=(
746
+ "Run PR gate checks using agents/repo_profile.yaml.\n"
747
+ "Use --dry-run to validate AGENTS.md and print a plan without a profile."
748
+ ),
749
+ formatter_class=argparse.RawTextHelpFormatter,
750
+ )
751
+ gate_cmd.add_argument("kind", choices=["pr"])
752
+ gate_cmd.add_argument(
753
+ "--dry-run",
754
+ action="store_true",
755
+ default=False,
756
+ help="Validate AGENTS.md and show planned gates without repo_profile.yaml",
757
+ )
758
+
759
+ subparsers.add_parser("ops", help="Print install/pin/rollback guidance")
760
+ subparsers.add_parser("doctor", help="Alias for ops guidance")
761
+
762
+ bootstrap_cmd = subparsers.add_parser(
763
+ "bootstrap",
764
+ help="Author AGENTS.md policy block from canonical roles",
765
+ )
766
+ bootstrap_cmd.add_argument(
767
+ "--allow",
768
+ help="Comma-separated role names (non-interactive)",
769
+ )
770
+ bootstrap_cmd.add_argument(
771
+ "--write",
772
+ action="store_true",
773
+ default=False,
774
+ help="Write AGENTS.md (default is preview only)",
775
+ )
776
+ bootstrap_cmd.add_argument("--min-tool-version")
777
+ bootstrap_cmd.add_argument("--max-tool-version")
778
+ bootstrap_cmd.add_argument(
779
+ "--require-pinned-tool",
780
+ action="store_true",
781
+ default=None,
782
+ help="Require pinned (non-editable) installs",
783
+ )
784
+
785
+ init_description = (
786
+ "Deterministic repo introspection (no LLM, evidence-only).\n"
787
+ "When --write is set, writes:\n"
788
+ " - .agents/generated/AGENTS.repo.overlay.yaml\n"
789
+ " - .agents/generated/init_report.md\n"
790
+ " - .agents/generated/init_facts.json\n"
791
+ "Default out dir: .agents/generated\n"
792
+ "If .gitignore exists, append one line for the out dir. Otherwise no change.\n"
793
+ "Policy enforcement uses AGENTS.md (min/max tool version, pinned installs).\n"
794
+ "Exit codes:\n"
795
+ " 0 success (including dry-run)\n"
796
+ " nonzero parse errors or write failures\n"
797
+ "\n"
798
+ "Examples:\n"
799
+ " agentctl init\n"
800
+ " agentctl init --write --out-dir .agents/custom"
801
+ )
802
+ init_cmd = subparsers.add_parser(
803
+ "init",
804
+ help="Generate a repo-specific policy overlay",
805
+ description=init_description,
806
+ formatter_class=argparse.RawTextHelpFormatter,
807
+ )
808
+ init_cmd.add_argument("--write", action="store_true", default=False)
809
+ init_cmd.add_argument("--out-dir", default=".agents/generated")
810
+ init_cmd.add_argument("--force", action="store_true", default=False)
811
+ init_cmd.add_argument(
812
+ "--print-agents-template",
813
+ action="store_true",
814
+ default=False,
815
+ help="Print AGENTS.md starter template when missing",
816
+ )
817
+ init_cmd.add_argument(
818
+ "--strict",
819
+ action="store_true",
820
+ default=None,
821
+ help="Fail on invalid AGENTS.md (default in CI)",
822
+ )
823
+ init_cmd.add_argument(
824
+ "--no-strict",
825
+ action="store_true",
826
+ default=None,
827
+ help="Warn on invalid AGENTS.md",
828
+ )
829
+
830
+ return parser
831
+
832
+
833
+ def main():
834
+ parser = build_parser()
835
+ args = parser.parse_args()
836
+ env = dict(os.environ)
837
+ env["AGENT_GOVERNANCE_ROOT"] = str(ROOT)
838
+
839
+ if args.cmd == "new-task":
840
+ mode = resolve_mode(args.update_check, env)
841
+ policy = load_repo_policy_update_check()
842
+ try:
843
+ maybe_check(ROOT, mode, env, policy)
844
+ except Exception:
845
+ if mode == "verbose":
846
+ print("update check failed", file=sys.stderr)
847
+ cmd_new_task(args.role, args.title)
848
+ elif args.cmd == "validate":
849
+ mode = resolve_mode(args.update_check, env)
850
+ policy = load_repo_policy_update_check()
851
+ try:
852
+ maybe_check(ROOT, mode, env, policy)
853
+ except Exception:
854
+ if mode == "verbose":
855
+ print("update check failed", file=sys.stderr)
856
+ cmd_validate(args.kind, args.file)
857
+ elif args.cmd == "log":
858
+ mode = resolve_mode(args.update_check, env)
859
+ policy = load_repo_policy_update_check()
860
+ try:
861
+ maybe_check(ROOT, mode, env, policy)
862
+ except Exception:
863
+ if mode == "verbose":
864
+ print("update check failed", file=sys.stderr)
865
+ cmd_log(args.run_id, args.task, args.append)
866
+ elif args.cmd == "gate":
867
+ if args.kind != "pr":
868
+ raise SystemExit("only 'pr' is supported")
869
+ if args.dry_run:
870
+ raise SystemExit(cmd_gate_pr_dry_run())
871
+ mode = resolve_mode(args.update_check, env)
872
+ policy = load_repo_policy_update_check()
873
+ try:
874
+ maybe_check(ROOT, mode, env, policy)
875
+ except Exception:
876
+ if mode == "verbose":
877
+ print("update check failed", file=sys.stderr)
878
+ try:
879
+ policy_code = _enforce_tool_policy()
880
+ except ParseError as exc:
881
+ report = render_error_report(ROOT, exc.path, exc.message)
882
+ print(report, end="")
883
+ raise SystemExit(2)
884
+ if policy_code != 0:
885
+ raise SystemExit(policy_code)
886
+ cmd_gate_pr()
887
+ elif args.cmd == "init":
888
+ mode = resolve_mode(args.update_check, env)
889
+ policy = load_repo_policy_update_check()
890
+ try:
891
+ maybe_check(ROOT, mode, env, policy)
892
+ except Exception:
893
+ if mode == "verbose":
894
+ print("update check failed", file=sys.stderr)
895
+ try:
896
+ policy_code = _enforce_tool_policy()
897
+ except ParseError as exc:
898
+ report = render_error_report(ROOT, exc.path, exc.message)
899
+ print(report, end="")
900
+ raise SystemExit(2)
901
+ if policy_code != 0:
902
+ raise SystemExit(policy_code)
903
+ strict = False
904
+ if os.environ.get("CI", "").lower() == "true":
905
+ strict = True
906
+ if args.strict is True:
907
+ strict = True
908
+ if args.no_strict is True:
909
+ strict = False
910
+ code = run_init(
911
+ Path.cwd(),
912
+ write=args.write,
913
+ out_dir=args.out_dir,
914
+ force=args.force,
915
+ print_agents_template=args.print_agents_template,
916
+ strict=strict,
917
+ )
918
+ raise SystemExit(code)
919
+ elif args.cmd in ["ops", "doctor"]:
920
+ try:
921
+ policy_code = _enforce_tool_policy()
922
+ except ParseError as exc:
923
+ report = render_error_report(ROOT, exc.path, exc.message)
924
+ print(report, end="")
925
+ raise SystemExit(2)
926
+ if policy_code != 0:
927
+ raise SystemExit(policy_code)
928
+ raise SystemExit(cmd_ops())
929
+ elif args.cmd == "bootstrap":
930
+ raise SystemExit(
931
+ cmd_bootstrap(
932
+ args.allow,
933
+ args.write,
934
+ args.min_tool_version,
935
+ args.max_tool_version,
936
+ args.require_pinned_tool,
937
+ )
938
+ )
939
+ else:
940
+ parser.print_help()
941
+ raise SystemExit(1)
942
+
943
+
944
+ if __name__ == "__main__":
945
+ main()