grudge-cli 0.1.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.
Files changed (89) hide show
  1. examples/__init__.py +1 -0
  2. examples/leaky_agent/__init__.py +1 -0
  3. examples/leaky_agent/agent.py +250 -0
  4. examples/leaky_agent/agent_fixed.py +183 -0
  5. examples/leaky_agent/tools.py +153 -0
  6. grudge/__init__.py +5 -0
  7. grudge/__main__.py +6 -0
  8. grudge/_stubs.py +8 -0
  9. grudge/admission/__init__.py +6 -0
  10. grudge/admission/court.py +239 -0
  11. grudge/admission/verdict.py +26 -0
  12. grudge/cli/__init__.py +1 -0
  13. grudge/cli/_common.py +126 -0
  14. grudge/cli/admit.py +93 -0
  15. grudge/cli/capture.py +39 -0
  16. grudge/cli/compile.py +37 -0
  17. grudge/cli/demo.py +136 -0
  18. grudge/cli/main.py +54 -0
  19. grudge/cli/run.py +60 -0
  20. grudge/cli/scars.py +91 -0
  21. grudge/cli/shrink.py +108 -0
  22. grudge/config.py +35 -0
  23. grudge/demo/__init__.py +1 -0
  24. grudge/demo/events.py +228 -0
  25. grudge/demo/orchestrator.py +326 -0
  26. grudge/demo/server.py +222 -0
  27. grudge/errors.py +25 -0
  28. grudge/fuzzer/__init__.py +1 -0
  29. grudge/fuzzer/fingerprint_guard.py +10 -0
  30. grudge/fuzzer/mutators.py +10 -0
  31. grudge/gate/__init__.py +5 -0
  32. grudge/gate/report.py +125 -0
  33. grudge/gate/runner.py +323 -0
  34. grudge/gate/selector.py +10 -0
  35. grudge/gate/wilson.py +36 -0
  36. grudge/guard/__init__.py +5 -0
  37. grudge/guard/compile.py +251 -0
  38. grudge/guard/import_session.py +106 -0
  39. grudge/integrations/__init__.py +1 -0
  40. grudge/integrations/github_comment.py +10 -0
  41. grudge/integrations/pytest_plugin.py +10 -0
  42. grudge/llm/__init__.py +6 -0
  43. grudge/llm/cache.py +92 -0
  44. grudge/llm/client.py +82 -0
  45. grudge/locator/__init__.py +1 -0
  46. grudge/locator/fork.py +10 -0
  47. grudge/locator/point_of_no_return.py +10 -0
  48. grudge/predicates/__init__.py +25 -0
  49. grudge/predicates/call_shape.py +116 -0
  50. grudge/predicates/compiler.py +116 -0
  51. grudge/predicates/dsl.py +295 -0
  52. grudge/predicates/fallback_rules.py +10 -0
  53. grudge/predicates/ordering.py +33 -0
  54. grudge/predicates/outcome.py +14 -0
  55. grudge/predicates/prompts/synthesize.md +15 -0
  56. grudge/predicates/synthesizer.py +127 -0
  57. grudge/predicates/taint_flow.py +84 -0
  58. grudge/recorder/__init__.py +5 -0
  59. grudge/recorder/adapters/__init__.py +1 -0
  60. grudge/recorder/adapters/langgraph.py +8 -0
  61. grudge/recorder/adapters/mcp_proxy.py +8 -0
  62. grudge/recorder/adapters/raw_sdk.py +159 -0
  63. grudge/recorder/recorder.py +374 -0
  64. grudge/recorder/redact.py +105 -0
  65. grudge/registry/__init__.py +1 -0
  66. grudge/registry/db.py +10 -0
  67. grudge/registry/fingerprint.py +10 -0
  68. grudge/registry/flakiness.py +10 -0
  69. grudge/registry/retirement.py +10 -0
  70. grudge/replay/__init__.py +19 -0
  71. grudge/replay/cassette_player.py +159 -0
  72. grudge/replay/sandbox.py +340 -0
  73. grudge/replay/scripted_llm.py +382 -0
  74. grudge/schema/__init__.py +45 -0
  75. grudge/schema/cassette.py +206 -0
  76. grudge/schema/predicate.py +263 -0
  77. grudge/schema/scar.py +113 -0
  78. grudge/schema/trace.py +243 -0
  79. grudge/shrinker/__init__.py +12 -0
  80. grudge/shrinker/cassette_builder.py +328 -0
  81. grudge/shrinker/ddmin.py +243 -0
  82. grudge/shrinker/oracle.py +230 -0
  83. grudge/shrinker/reducers.py +331 -0
  84. grudge_cli-0.1.0.dist-info/LICENSE +21 -0
  85. grudge_cli-0.1.0.dist-info/METADATA +313 -0
  86. grudge_cli-0.1.0.dist-info/RECORD +89 -0
  87. grudge_cli-0.1.0.dist-info/WHEEL +5 -0
  88. grudge_cli-0.1.0.dist-info/entry_points.txt +2 -0
  89. grudge_cli-0.1.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,239 @@
1
+ """Fail-closed, deterministic admission proof for regression scars.
2
+
3
+ Empty sanity means that an empty trace is not failure evidence. Safety rules
4
+ may safely hold on empty; non-vacuity additionally requires witnessed violations
5
+ on every successful bad-agent run, alongside clean fixed and control runs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import re
12
+ from collections.abc import Callable, Sequence
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from grudge.admission.verdict import AdmissionVerdict
18
+ from grudge.predicates.compiler import Checker, PredicateResult, compile_predicate
19
+ from grudge.replay.sandbox import run_agent
20
+ from grudge.replay.scripted_llm import ScriptedLLM
21
+ from grudge.schema.scar import AdmissionRecord, Scar
22
+ from grudge.schema.trace import ToolResult, Trace
23
+
24
+ DEFAULT_SCARS_DIR = Path(__file__).resolve().parents[2] / "scars"
25
+ _EMPTY_SANITY = (
26
+ "Empty sanity: empty is not failure evidence; safety rules may hold on empty, "
27
+ "and non-vacuity requires witnessed bad-run violations."
28
+ )
29
+
30
+
31
+ def _trace_snapshot(trace: Trace, *, allow_empty: bool = False) -> Trace:
32
+ """Revalidate mutable models, including nested steps, before trusting them."""
33
+
34
+ if not isinstance(trace, Trace):
35
+ raise TypeError("expected a Trace")
36
+ snapshot = Trace.model_validate(trace.model_dump(warnings="error"), strict=True)
37
+ if snapshot.outcome != "ok":
38
+ details = [
39
+ str(step.meta["failure"])
40
+ for step in snapshot.steps
41
+ if "failure" in step.meta
42
+ ]
43
+ suffix = ": " + "; ".join(details) if details else ""
44
+ raise ValueError("failed or truncated trace" + suffix)
45
+ if not allow_empty and not snapshot.steps:
46
+ raise ValueError("trace must be nonempty")
47
+ for step in snapshot.steps:
48
+ if isinstance(step, ToolResult) and step.error is not None:
49
+ raise ValueError(f"tool replay failed: {step.error}")
50
+ if step.meta.get("failure") or step.meta.get("truncated"):
51
+ raise ValueError("trace contains a failure or truncation marker")
52
+ return snapshot
53
+
54
+
55
+ def _check(checker: Checker, trace: Trace) -> PredicateResult:
56
+ """Reject invalid checker output and evidence absent from the checked trace."""
57
+
58
+ result = checker.check(trace)
59
+ if not isinstance(result, PredicateResult):
60
+ raise TypeError("checker did not return a PredicateResult")
61
+ validated = PredicateResult.model_validate(
62
+ result.model_dump(warnings="error"), strict=True
63
+ )
64
+ if any(step not in trace.steps for step in validated.evidence):
65
+ raise ValueError("checker evidence is not present in the trace")
66
+ return validated
67
+
68
+
69
+ def admit(
70
+ scar_candidate: Scar,
71
+ bad_agent: Callable[..., Any],
72
+ good_agent: Callable[..., Any],
73
+ *,
74
+ runs: int,
75
+ controls: Sequence[Trace],
76
+ output_dir: Path | str | None = None,
77
+ timeout: float = 2.0,
78
+ max_steps: int = 128,
79
+ ) -> AdmissionVerdict:
80
+ """Prove all admission gates and save a detached, proof-bearing Scar.
81
+
82
+ A new policy is constructed outside guarded replay for *each* agent run.
83
+ The fixed agent may set its supplied policy to defended mode without
84
+ contaminating another run. Rejections never persist a candidate.
85
+ """
86
+
87
+ if isinstance(runs, bool) or not isinstance(runs, int) or runs <= 0:
88
+ raise ValueError("runs must be a positive integer")
89
+ if (
90
+ not math.isfinite(timeout)
91
+ or timeout <= 0
92
+ or isinstance(max_steps, bool)
93
+ or not isinstance(max_steps, int)
94
+ or max_steps <= 0
95
+ ):
96
+ raise ValueError("timeout and max_steps must be positive finite limits")
97
+
98
+ verdict = AdmissionVerdict(admitted=False)
99
+ try:
100
+ # Round-tripping also checks models altered through model_copy/construct
101
+ # or in-place mutations, which assignment validation alone cannot catch.
102
+ candidate = Scar.model_validate(
103
+ scar_candidate.model_dump(warnings="error"), strict=True
104
+ ).model_copy(deep=True)
105
+ if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", candidate.id) is None:
106
+ raise ValueError("scar id must be a safe filename component")
107
+ checker = compile_predicate(candidate.predicate)
108
+ except Exception as exc:
109
+ verdict.reasons.append(
110
+ f"Invalid candidate or predicate: {type(exc).__name__}: {exc}"
111
+ )
112
+ return verdict
113
+
114
+ empty = Trace(
115
+ run_id="admission-empty",
116
+ agent_version="admission",
117
+ task=candidate.cassette.task,
118
+ steps=[],
119
+ outcome="ok",
120
+ )
121
+ try:
122
+ verdict.empty_result = _check(checker, empty)
123
+ if verdict.empty_result.violated:
124
+ verdict.reasons.append(
125
+ "Empty sanity failed: predicate violates on empty trace."
126
+ )
127
+ except Exception as exc:
128
+ verdict.reasons.append(f"Empty checker exception: {type(exc).__name__}: {exc}")
129
+
130
+ if not controls:
131
+ verdict.reasons.append("At least one nonempty clean control trace is required.")
132
+ for index, control in enumerate(controls, start=1):
133
+ try:
134
+ trace = _trace_snapshot(control)
135
+ result = _check(checker, trace)
136
+ verdict.control_results.append(result)
137
+ if result.violated:
138
+ verdict.reasons.append(
139
+ f"Control {index} violated: {result.explanation}"
140
+ )
141
+ except Exception as exc:
142
+ verdict.reasons.append(
143
+ f"Control {index} failed: {type(exc).__name__}: {exc}"
144
+ )
145
+
146
+ for index in range(1, runs + 1):
147
+ for label, agent, results in (
148
+ ("Bad", bad_agent, verdict.bad_results),
149
+ ("Good", good_agent, verdict.good_results),
150
+ ):
151
+ try:
152
+ policy = ScriptedLLM()
153
+ replay = run_agent(
154
+ agent,
155
+ candidate.cassette.model_copy(deep=True),
156
+ policy,
157
+ timeout=timeout,
158
+ max_steps=max_steps,
159
+ )
160
+ trace = _trace_snapshot(replay)
161
+ result = _check(checker, trace)
162
+ results.append(result)
163
+ if label == "Bad":
164
+ if not result.violated or not result.evidence:
165
+ verdict.reasons.append(
166
+ f"Bad run {index} needs a violation "
167
+ "with nonempty evidence: " + result.explanation
168
+ )
169
+ else:
170
+ verdict.evidence.extend(result.evidence)
171
+ elif result.violated:
172
+ verdict.reasons.append(
173
+ f"Good run {index} violated: {result.explanation}"
174
+ )
175
+ except Exception as exc:
176
+ verdict.reasons.append(
177
+ f"{label} run {index} replay/checker failed: "
178
+ f"{type(exc).__name__}: {exc}"
179
+ )
180
+
181
+ if verdict.reasons:
182
+ return verdict
183
+
184
+ verdict.reasons.extend(
185
+ [
186
+ f"All {runs} bad runs violated with nonempty evidence.",
187
+ f"All {runs} good runs and {len(controls)} controls "
188
+ "were successful and clean.",
189
+ _EMPTY_SANITY,
190
+ ]
191
+ )
192
+ candidate.admission = AdmissionRecord.model_validate(
193
+ {
194
+ "admitted": True,
195
+ "reasons": list(verdict.reasons),
196
+ "bad_runs": len(verdict.bad_results),
197
+ "good_runs": len(verdict.good_results),
198
+ "checked_at": datetime.now(UTC),
199
+ "control_runs": len(verdict.control_results),
200
+ "empty_sanity": _EMPTY_SANITY,
201
+ "evidence": [step.model_dump(mode="json") for step in verdict.evidence],
202
+ "bad_results": [
203
+ result.model_dump(mode="json") for result in verdict.bad_results
204
+ ],
205
+ "good_results": [
206
+ result.model_dump(mode="json") for result in verdict.good_results
207
+ ],
208
+ "control_results": [
209
+ result.model_dump(mode="json") for result in verdict.control_results
210
+ ],
211
+ "empty_result": (
212
+ verdict.empty_result.model_dump(mode="json")
213
+ if verdict.empty_result is not None
214
+ else None
215
+ ),
216
+ "timeout": timeout,
217
+ "max_steps": max_steps,
218
+ }
219
+ )
220
+ try:
221
+ directory = (
222
+ DEFAULT_SCARS_DIR if output_dir is None else Path(output_dir)
223
+ ).resolve()
224
+ target = directory / f"{candidate.id}.scar.json"
225
+ payload = candidate.model_dump_json(indent=2) + "\n"
226
+ directory.mkdir(parents=True, exist_ok=True)
227
+ # Exclusive creation prevents symlink escapes and overwriting an existing
228
+ # proof, including a target swapped between path checking and opening.
229
+ with target.open("x", encoding="utf-8") as handle:
230
+ handle.write(payload)
231
+ except Exception as exc:
232
+ verdict.reasons.append(f"Persistence failed: {type(exc).__name__}: {exc}")
233
+ return verdict
234
+ verdict.admitted = True
235
+ verdict.saved_path = target
236
+ return verdict
237
+
238
+
239
+ __all__ = ["admit"]
@@ -0,0 +1,26 @@
1
+ """Structured proof and diagnostics returned by the admission court."""
2
+
3
+ from pathlib import Path
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from grudge.predicates.compiler import PredicateResult
8
+ from grudge.schema.trace import Step
9
+
10
+
11
+ class AdmissionVerdict(BaseModel):
12
+ """Admission succeeds only after every proof gate and persistence succeed."""
13
+
14
+ model_config = ConfigDict(extra="forbid", validate_assignment=True)
15
+
16
+ admitted: bool
17
+ reasons: list[str] = Field(default_factory=list)
18
+ evidence: list[Step] = Field(default_factory=list)
19
+ bad_results: list[PredicateResult] = Field(default_factory=list)
20
+ good_results: list[PredicateResult] = Field(default_factory=list)
21
+ control_results: list[PredicateResult] = Field(default_factory=list)
22
+ empty_result: PredicateResult | None = None
23
+ saved_path: Path | None = None
24
+
25
+
26
+ __all__ = ["AdmissionVerdict"]
grudge/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Command-line package for the Phase 0 Grudge surface."""
grudge/cli/_common.py ADDED
@@ -0,0 +1,126 @@
1
+ """Shared helpers for the Phase 9 scriptable command surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import json
7
+ import sys
8
+ import traceback
9
+ from collections.abc import Callable, Mapping
10
+ from pathlib import Path
11
+ from typing import Any, NoReturn, TypeVar, cast
12
+
13
+ import typer
14
+
15
+ from grudge.errors import GrudgeError
16
+
17
+ T = TypeVar("T")
18
+
19
+
20
+ def finish(payload: Mapping[str, Any], *, json_output: bool) -> None:
21
+ """Print a command result as JSON or a compact human-readable summary."""
22
+
23
+ if json_output:
24
+ typer.echo(json.dumps(dict(payload), ensure_ascii=False, sort_keys=True))
25
+ return
26
+ for key, value in payload.items():
27
+ if isinstance(value, list):
28
+ for item in value:
29
+ typer.echo(str(item))
30
+ else:
31
+ typer.echo(f"{key}: {value}")
32
+
33
+
34
+ def fail(exc: Exception, *, json_output: bool, debug: bool) -> NoReturn:
35
+ """Render an expected command failure and terminate with status 2."""
36
+
37
+ if debug:
38
+ traceback.print_exc(file=sys.stderr)
39
+ message = str(exc) or type(exc).__name__
40
+ if json_output:
41
+ typer.echo(
42
+ json.dumps(
43
+ {"error": message, "error_type": type(exc).__name__},
44
+ ensure_ascii=False,
45
+ sort_keys=True,
46
+ )
47
+ )
48
+ else:
49
+ typer.echo(f"Error: {message}", err=True)
50
+ raise typer.Exit(code=2)
51
+
52
+
53
+ def command(action: Callable[[], T], *, json_output: bool, debug: bool) -> T:
54
+ """Run a command body with the common Grudge error contract."""
55
+
56
+ try:
57
+ return action()
58
+ except typer.Exit:
59
+ raise
60
+ except Exception as exc:
61
+ fail(exc, json_output=json_output, debug=debug)
62
+ raise AssertionError("unreachable")
63
+
64
+
65
+ def load_callable(module_name: str) -> Callable[..., Any]:
66
+ """Import an agent module and select its conventional run entry point."""
67
+
68
+ try:
69
+ module = importlib.import_module(module_name)
70
+ except Exception as exc:
71
+ raise GrudgeError(
72
+ f"could not import agent module {module_name!r}: {exc}"
73
+ ) from exc
74
+ for name in ("run_agent", "run"):
75
+ candidate = getattr(module, name, None)
76
+ if callable(candidate):
77
+ return cast(Callable[..., Any], candidate)
78
+ raise GrudgeError(
79
+ f"agent module {module_name!r} must expose a callable run_agent or run"
80
+ )
81
+
82
+
83
+ def require_file(path: Path, *, label: str) -> Path:
84
+ """Validate a required input file without leaking a raw OS exception."""
85
+
86
+ if not path.is_file():
87
+ raise GrudgeError(f"{label} file does not exist: {path}")
88
+ return path
89
+
90
+
91
+ def read_text(path: Path, *, label: str) -> str:
92
+ """Read a required UTF-8 file as a Grudge-friendly operation."""
93
+
94
+ require_file(path, label=label)
95
+ try:
96
+ return path.read_text(encoding="utf-8")
97
+ except (OSError, UnicodeError) as exc:
98
+ raise GrudgeError(f"could not read {label} file {path}: {exc}") from exc
99
+
100
+
101
+ def write_text(path: Path, text: str, *, label: str) -> None:
102
+ """Write a command artifact, creating its parent directory when needed."""
103
+
104
+ try:
105
+ path.parent.mkdir(parents=True, exist_ok=True)
106
+ path.write_text(text, encoding="utf-8")
107
+ except OSError as exc:
108
+ raise GrudgeError(f"could not write {label} file {path}: {exc}") from exc
109
+
110
+
111
+ def model_json(model: Any) -> dict[str, Any]:
112
+ """Return a Pydantic model as JSON-compatible data."""
113
+
114
+ return cast(dict[str, Any], model.model_dump(mode="json"))
115
+
116
+
117
+ __all__ = [
118
+ "command",
119
+ "fail",
120
+ "finish",
121
+ "load_callable",
122
+ "model_json",
123
+ "read_text",
124
+ "require_file",
125
+ "write_text",
126
+ ]
grudge/cli/admit.py ADDED
@@ -0,0 +1,93 @@
1
+ """Build and prove a Scar from a cassette using the admission court."""
2
+
3
+ # Typer's declarative Option defaults are intentional here.
4
+ # ruff: noqa: B008
5
+
6
+ import hashlib
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import typer
12
+
13
+ from grudge.admission import admit
14
+ from grudge.cli._common import command, finish, load_callable, read_text
15
+ from grudge.errors import GrudgeError
16
+ from grudge.predicates.dsl import parse
17
+ from grudge.predicates.fallback_rules import demo_rule
18
+ from grudge.schema.cassette import Cassette
19
+ from grudge.schema.scar import Scar
20
+ from grudge.schema.trace import Trace
21
+
22
+
23
+ def admit_cassette(
24
+ cassette: Path,
25
+ bad: str = typer.Option(..., "--bad", help="Failing agent module."),
26
+ good: str = typer.Option(..., "--good", help="Fixed agent module."),
27
+ out: Path = typer.Option(..., "--out", help="Directory for admitted Scars."),
28
+ scar_id: str | None = typer.Option(None, "--id", help="Scar filename/id."),
29
+ predicate: str = typer.Option(
30
+ "demo",
31
+ "--predicate",
32
+ help="Predicate DSL, or 'demo' for the hand-verified rule.",
33
+ ),
34
+ controls: Path = typer.Option(
35
+ Path("examples/leaky_agent/controls"),
36
+ "--controls",
37
+ help="Directory containing benign control JSONL traces.",
38
+ ),
39
+ runs: int = typer.Option(3, "--runs", min=1, help="Proof runs per agent."),
40
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
41
+ debug: bool = typer.Option(False, "--debug", help="Show a traceback on errors."),
42
+ ) -> None:
43
+ """Prove a cassette red on the bad agent and green on the good agent."""
44
+
45
+ def run() -> dict[str, Any]:
46
+ payload = read_text(cassette, label="cassette")
47
+ try:
48
+ candidate_cassette = Cassette.model_validate_json(payload)
49
+ except Exception as exc:
50
+ raise GrudgeError(f"invalid cassette file {cassette}: {exc}") from exc
51
+ rule = demo_rule() if predicate.strip().lower() == "demo" else parse(predicate)
52
+ identifier = scar_id or candidate_cassette.content_hash[:12]
53
+ fingerprint = hashlib.sha256(
54
+ (candidate_cassette.content_hash + rule.model_dump_json()).encode("utf-8")
55
+ ).hexdigest()
56
+ candidate = Scar(
57
+ id=identifier,
58
+ cassette=candidate_cassette,
59
+ predicate=rule,
60
+ seed_predicate=rule.model_copy(deep=True),
61
+ source_trace_hash=candidate_cassette.content_hash,
62
+ agent_version_failed=bad,
63
+ agent_version_fixed=good,
64
+ created_at=datetime.now(UTC),
65
+ fingerprint=fingerprint,
66
+ )
67
+ control_paths = sorted(controls.glob("*.jsonl"))
68
+ if not control_paths:
69
+ raise GrudgeError(f"no control JSONL files found in {controls}")
70
+ control_traces = [
71
+ Trace.from_jsonl(read_text(path, label="control")) for path in control_paths
72
+ ]
73
+ verdict = admit(
74
+ candidate,
75
+ load_callable(bad),
76
+ load_callable(good),
77
+ runs=runs,
78
+ controls=control_traces,
79
+ output_dir=out,
80
+ )
81
+ result = {
82
+ "command": "admit",
83
+ "admitted": verdict.admitted,
84
+ "scar_id": candidate.id,
85
+ "saved_path": str(verdict.saved_path) if verdict.saved_path else None,
86
+ "reasons": verdict.reasons,
87
+ }
88
+ if not verdict.admitted:
89
+ finish(result, json_output=json_output)
90
+ raise typer.Exit(code=1)
91
+ return result
92
+
93
+ finish(command(run, json_output=json_output, debug=debug), json_output=json_output)
grudge/cli/capture.py ADDED
@@ -0,0 +1,39 @@
1
+ """Capture an agent module's typed trace as canonical JSONL."""
2
+
3
+ # Typer's declarative Option defaults are intentional here.
4
+ # ruff: noqa: B008
5
+
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import typer
10
+
11
+ from grudge.cli._common import command, finish, load_callable, write_text
12
+ from grudge.errors import GrudgeError
13
+ from grudge.schema.trace import Trace
14
+
15
+
16
+ def capture_agent(
17
+ agent_module: str,
18
+ out: Path = typer.Option(..., "--out", help="Destination JSONL trace."),
19
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
20
+ debug: bool = typer.Option(False, "--debug", help="Show a traceback on errors."),
21
+ ) -> None:
22
+ """Capture an agent run into a trace."""
23
+
24
+ def run() -> dict[str, Any]:
25
+ trace = load_callable(agent_module)()
26
+ if not isinstance(trace, Trace):
27
+ raise GrudgeError(
28
+ f"agent {agent_module!r} returned {type(trace).__name__}, not a Trace"
29
+ )
30
+ write_text(out, trace.to_jsonl(), label="trace")
31
+ return {
32
+ "command": "capture",
33
+ "trace": str(out),
34
+ "run_id": trace.run_id,
35
+ "steps": len(trace.steps),
36
+ "outcome": trace.outcome,
37
+ }
38
+
39
+ finish(command(run, json_output=json_output, debug=debug), json_output=json_output)
grudge/cli/compile.py ADDED
@@ -0,0 +1,37 @@
1
+ """CLI command for compiling admitted Scars for the local guard runtime."""
2
+
3
+ # Typer requires option factories in function defaults; this is the project's
4
+ # established command-module pattern.
5
+ # ruff: noqa: B008
6
+
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import typer
11
+
12
+ from grudge.config import SCARS_DIR
13
+ from grudge.guard.compile import compile_scars, load_scars, write_compiled
14
+
15
+
16
+ def compile_command(
17
+ scars: Path = typer.Option(SCARS_DIR, "--scars", help="Scar directory."),
18
+ out: Path = typer.Option(
19
+ Path(".grudge/compiled.json"), "--out", help="Compiled rule output."
20
+ ),
21
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
22
+ ) -> None:
23
+ """Compile admitted Scar files into the flat hook contract."""
24
+
25
+ payload = compile_scars(load_scars(scars), created_from=str(scars))
26
+ target = write_compiled(payload, out)
27
+ result: dict[str, Any] = {
28
+ "command": "compile",
29
+ "scars": str(scars),
30
+ "out": str(target),
31
+ "rules": len(payload["rules"]),
32
+ "version": payload["version"],
33
+ }
34
+ if json_output:
35
+ typer.echo(__import__("json").dumps(result, sort_keys=True))
36
+ else:
37
+ typer.echo(f"compiled {result['rules']} rule(s) to {target}")