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
grudge/cli/demo.py ADDED
@@ -0,0 +1,136 @@
1
+ """Run the deterministic, offline five-beat demonstration."""
2
+
3
+ # Typer's declarative Option defaults are intentional here.
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import webbrowser
10
+ from pathlib import Path
11
+ from threading import Event
12
+ from typing import Any, cast
13
+
14
+ import typer
15
+
16
+ from grudge.cli._common import command, finish
17
+ from grudge.config import DEFAULTS, SCARS_DIR
18
+ from grudge.demo.events import EventBus
19
+ from grudge.demo.orchestrator import run_demo as orchestrate_demo
20
+ from grudge.demo.server import DemoServer
21
+
22
+
23
+ def run_demo(
24
+ live: bool = typer.Option(False, "--live", help="Reserved for a live provider."),
25
+ port: int = typer.Option(8765, "--port", help="Local viewer server port."),
26
+ no_browser: bool = typer.Option(
27
+ False, "--no-browser", help="Do not open a browser."
28
+ ),
29
+ runs: int = typer.Option(DEFAULTS.n_runs, "--runs", min=1),
30
+ fast: bool = typer.Option(False, "--fast", help="Disable presenter pacing."),
31
+ guard: bool = typer.Option(
32
+ False, "--guard", help="Run the offline two-session hook proof."
33
+ ),
34
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
35
+ debug: bool = typer.Option(False, "--debug", help="Show a traceback on errors."),
36
+ ) -> None:
37
+ """Play the offline proof against the fixed and vulnerable demo agents."""
38
+
39
+ def run() -> dict[str, Any]:
40
+ if live:
41
+ raise RuntimeError(
42
+ "live demo mode is reserved for a future provider integration"
43
+ )
44
+ if guard:
45
+ node = shutil.which("node")
46
+ if node is None:
47
+ raise RuntimeError("--guard requires Node.js >= 18 on PATH")
48
+ script = Path(__file__).resolve().parents[2] / "scripts" / "e2e_guard.mjs"
49
+ completed = subprocess.run(
50
+ [node, str(script)],
51
+ cwd=script.parent.parent,
52
+ check=False,
53
+ capture_output=True,
54
+ text=True,
55
+ env={**os.environ, "GRUDGE_DISABLE": ""},
56
+ )
57
+ if completed.returncode != 0:
58
+ raise RuntimeError(completed.stderr.strip() or "guard demo failed")
59
+ payload = cast(dict[str, Any], json.loads(completed.stdout))
60
+ payload["command"] = "demo --guard"
61
+ if json_output:
62
+ return payload
63
+ typer.echo("Session 1: leak captured, proposed, and accepted")
64
+ typer.echo("Session 2: leak blocked before the HTTP call")
65
+ typer.echo(payload["reason"])
66
+ return payload
67
+ if not fast and not json_output:
68
+ bus = EventBus()
69
+ finished = Event()
70
+ failure: list[BaseException] = []
71
+
72
+ def run_viewer_demo() -> None:
73
+ try:
74
+ orchestrate_demo(bus=bus, runs=runs)
75
+ except BaseException as exc:
76
+ failure.append(exc)
77
+ finally:
78
+ finished.set()
79
+
80
+ server = DemoServer(
81
+ port=port,
82
+ bus=bus,
83
+ runner=run_viewer_demo,
84
+ ).start(run_demo=True)
85
+ url = f"http://{server.address[0]}:{server.address[1]}/"
86
+ if not no_browser:
87
+ webbrowser.open(url)
88
+ typer.echo(f"Grudge viewer: {url}")
89
+ finished.wait()
90
+ server.close()
91
+ if failure:
92
+ raise RuntimeError("viewer demo failed") from failure[0]
93
+ return {"command": "demo", "mode": "viewer", "url": url}
94
+ events = orchestrate_demo(fast=fast, runs=runs)
95
+ event_payloads = [event.model_dump(mode="json") for event in events]
96
+ gates = [event for event in event_payloads if event["type"] == "gate.result"]
97
+ vulnerable = next(
98
+ event
99
+ for event in gates
100
+ if event["agent"] == "vulnerable" and not event["passed"]
101
+ )
102
+ fixed = next(event for event in gates if event["agent"] == "fixed")
103
+ return {
104
+ "command": "demo",
105
+ "mode": "offline/deterministic",
106
+ "scar": str(SCARS_DIR / "sample_leak.scar.json"),
107
+ "events": event_payloads,
108
+ "fixed": {
109
+ "passed": fixed["passed"],
110
+ "successes": fixed["passes"],
111
+ "runs": fixed["n"],
112
+ "lower_bound": fixed["wilson"],
113
+ },
114
+ "vulnerable": {
115
+ "passed": vulnerable["passed"],
116
+ "successes": vulnerable["passes"],
117
+ "runs": vulnerable["n"],
118
+ "lower_bound": vulnerable["wilson"],
119
+ },
120
+ }
121
+
122
+ result = command(run, json_output=json_output, debug=debug)
123
+ if result.get("mode") == "viewer":
124
+ return
125
+ if guard:
126
+ if json_output:
127
+ finish(result, json_output=True)
128
+ return
129
+ if json_output:
130
+ finish(result, json_output=True)
131
+ else:
132
+ for event in result["events"]:
133
+ typer.echo(
134
+ f"{event['type']} "
135
+ f"{json.dumps(event, ensure_ascii=False, sort_keys=True)}"
136
+ )
grudge/cli/main.py ADDED
@@ -0,0 +1,54 @@
1
+ """Typer command surface for Grudge's deterministic pipeline."""
2
+
3
+ from typing import Any, NoReturn
4
+
5
+ import typer
6
+
7
+ from grudge._stubs import roadmap
8
+ from grudge.cli._common import command
9
+ from grudge.cli.admit import admit_cassette
10
+ from grudge.cli.capture import capture_agent
11
+ from grudge.cli.compile import compile_command
12
+ from grudge.cli.demo import run_demo
13
+ from grudge.cli.run import run_scars
14
+ from grudge.cli.scars import scars_app
15
+ from grudge.cli.shrink import shrink_trace
16
+ from grudge.errors import GrudgeError
17
+
18
+ app = typer.Typer(
19
+ name="grudge",
20
+ help="Compile agent failures into deterministic regression scars.",
21
+ no_args_is_help=True,
22
+ )
23
+
24
+
25
+ def _future(command: str) -> NoReturn:
26
+ roadmap(f"cli.{command}")
27
+
28
+
29
+ app.command("capture")(capture_agent)
30
+ app.command("compile")(compile_command)
31
+ app.command("shrink")(shrink_trace)
32
+ app.command("admit")(admit_cassette)
33
+ app.command("run")(run_scars)
34
+ app.command("demo")(run_demo)
35
+
36
+
37
+ @app.command()
38
+ def viewer(
39
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
40
+ debug: bool = typer.Option(False, "--debug", help="Show a traceback on errors."),
41
+ ) -> None:
42
+ """Start the local viewer server."""
43
+
44
+ def run() -> dict[str, Any]:
45
+ try:
46
+ _future("viewer")
47
+ except NotImplementedError as exc:
48
+ raise GrudgeError(str(exc)) from exc
49
+ raise AssertionError("unreachable")
50
+
51
+ command(run, json_output=json_output, debug=debug)
52
+
53
+
54
+ app.add_typer(scars_app, name="scars")
grudge/cli/run.py ADDED
@@ -0,0 +1,60 @@
1
+ """Run admitted Scars through the deterministic statistical gate."""
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, read_text
12
+ from grudge.config import DEFAULTS
13
+ from grudge.gate.report import render_terminal
14
+ from grudge.gate.runner import run_gate
15
+ from grudge.schema.scar import Scar
16
+
17
+
18
+ def run_scars(
19
+ scars: Path = typer.Option(..., "--scars", help="Scar file or directory."),
20
+ agent: str = typer.Option(..., "--agent", help="Agent module to gate."),
21
+ runs: int = typer.Option(DEFAULTS.n_runs, "--runs", min=1),
22
+ threshold: float = typer.Option(DEFAULTS.gate_threshold, "--threshold"),
23
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
24
+ debug: bool = typer.Option(False, "--debug", help="Show a traceback on errors."),
25
+ ) -> None:
26
+ """Run all admitted Scars in a directory, or one Scar file."""
27
+
28
+ def run() -> dict[str, Any]:
29
+ paths = [scars] if scars.is_file() else sorted(scars.glob("*.scar.json"))
30
+ if not paths:
31
+ raise FileNotFoundError(f"no Scar files found at {scars}")
32
+ agent_fn = load_callable(agent)
33
+ results: list[dict[str, Any]] = []
34
+ for path in paths:
35
+ scar = Scar.model_validate_json(read_text(path, label="Scar"))
36
+ result = run_gate(scar, agent_fn, runs=runs, threshold=threshold)
37
+ if not json_output:
38
+ typer.echo(render_terminal(result))
39
+ results.append(
40
+ {
41
+ "scar_id": result.scar_id,
42
+ "passed": result.passed,
43
+ "exit_code": result.exit_code,
44
+ "successes": result.successes,
45
+ "runs": result.runs,
46
+ "pass_rate": result.pass_rate,
47
+ "lower_bound": result.lower_bound,
48
+ "threshold": result.threshold,
49
+ "reasons": result.reasons,
50
+ "observations": result.observations,
51
+ }
52
+ )
53
+ payload = {"command": "run", "results": results}
54
+ return payload
55
+
56
+ payload = command(run, json_output=json_output, debug=debug)
57
+ if json_output:
58
+ finish(payload, json_output=True)
59
+ if any(not item["passed"] for item in payload["results"]):
60
+ raise typer.Exit(code=1)
grudge/cli/scars.py ADDED
@@ -0,0 +1,91 @@
1
+ """List and inspect saved Scars; retirement remains a roadmap stub."""
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._stubs import roadmap
12
+ from grudge.cli._common import command, finish, read_text
13
+ from grudge.config import SCARS_DIR
14
+ from grudge.errors import GrudgeError
15
+ from grudge.schema.scar import Scar
16
+
17
+ scars_app = typer.Typer(help="Browse saved regression Scars.")
18
+
19
+
20
+ @scars_app.command("list")
21
+ def list_scars(
22
+ scars_dir: Path = typer.Option(SCARS_DIR, "--scars", "--scars-dir"),
23
+ json_output: bool = typer.Option(False, "--json"),
24
+ debug: bool = typer.Option(False, "--debug"),
25
+ ) -> None:
26
+ """List saved Scar summaries."""
27
+
28
+ def run() -> dict[str, Any]:
29
+ paths = sorted(scars_dir.glob("*.scar.json"))
30
+ entries = []
31
+ for path in paths:
32
+ scar = Scar.model_validate_json(read_text(path, label="Scar"))
33
+ entries.append(
34
+ {
35
+ "id": scar.id,
36
+ "path": str(path),
37
+ "admitted": bool(scar.admission and scar.admission.admitted),
38
+ "fingerprint": scar.fingerprint,
39
+ }
40
+ )
41
+ payload = {"command": "scars list", "scars": entries}
42
+ if not json_output:
43
+ for item in entries:
44
+ status = "admitted" if item["admitted"] else "unadmitted"
45
+ typer.echo(f"{item['id']} {status}")
46
+ return payload
47
+
48
+ finish(command(run, json_output=json_output, debug=debug), json_output=json_output)
49
+
50
+
51
+ @scars_app.command("show")
52
+ def show_scar(
53
+ scar: str,
54
+ scars_dir: Path = typer.Option(SCARS_DIR, "--scars", "--scars-dir"),
55
+ json_output: bool = typer.Option(False, "--json"),
56
+ debug: bool = typer.Option(False, "--debug"),
57
+ ) -> None:
58
+ """Show one Scar by path or id."""
59
+
60
+ def run() -> dict[str, Any]:
61
+ path = Path(scar)
62
+ if not path.is_file():
63
+ filename = scar if scar.endswith(".scar.json") else f"{scar}.scar.json"
64
+ path = scars_dir / filename
65
+ loaded = Scar.model_validate_json(read_text(path, label="Scar"))
66
+ return {"command": "scars show", "scar": loaded.model_dump(mode="json")}
67
+
68
+ result = command(run, json_output=json_output, debug=debug)
69
+ if not json_output:
70
+ typer.echo(Scar.model_validate(result["scar"]).model_dump_json(indent=2))
71
+ else:
72
+ finish(result, json_output=True)
73
+
74
+
75
+ @scars_app.command("retire")
76
+ def retire_scar(
77
+ scar: str,
78
+ json_output: bool = typer.Option(False, "--json"),
79
+ debug: bool = typer.Option(False, "--debug"),
80
+ ) -> None:
81
+ """Retire a Scar (registry support is a later roadmap item)."""
82
+
83
+ del scar
84
+
85
+ def run() -> None:
86
+ try:
87
+ roadmap("cli.scars.retire")
88
+ except NotImplementedError as exc:
89
+ raise GrudgeError(str(exc)) from exc
90
+
91
+ command(run, json_output=json_output, debug=debug)
grudge/cli/shrink.py ADDED
@@ -0,0 +1,108 @@
1
+ """Shrink a captured trace into a minimal replay cassette."""
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, read_text, write_text
12
+ from grudge.predicates.compiler import compile_predicate
13
+ from grudge.predicates.dsl import parse
14
+ from grudge.predicates.fallback_rules import demo_rule
15
+ from grudge.replay.sandbox import world_from_trace
16
+ from grudge.replay.scripted_llm import ScriptedLLM
17
+ from grudge.schema.trace import ToolResult, Trace
18
+ from grudge.shrinker.cassette_builder import build_minimal_cassette
19
+ from grudge.shrinker.oracle import OracleResult, PredicateOracle
20
+
21
+
22
+ class _ReplayCompleteOracle:
23
+ """Keep CLI cassettes admissible by rejecting incomplete replays."""
24
+
25
+ def __init__(self, inner: PredicateOracle, seed: Any) -> None:
26
+ self.inner = inner
27
+ self.checker = compile_predicate(seed)
28
+ self.calls = 0
29
+ self.cache_hits = 0
30
+
31
+ def reproduces(self, cassette: Any) -> OracleResult:
32
+ self.calls = self.inner.calls
33
+ self.cache_hits = self.inner.cache_hits
34
+ result = self.inner.reproduces(cassette)
35
+ self.calls = self.inner.calls
36
+ self.cache_hits = self.inner.cache_hits
37
+ if not result.reproduced or result.trace is None:
38
+ return result
39
+ errors = [
40
+ step.error
41
+ for step in result.trace.steps
42
+ if isinstance(step, ToolResult) and step.error is not None
43
+ ]
44
+ if result.trace.outcome != "ok" or errors:
45
+ reason = errors[0] if errors else "failed or truncated trace"
46
+ return OracleResult(False, result.trace, f"replay failed: {reason}")
47
+ checked = self.checker.check(result.trace)
48
+ if not checked.violated:
49
+ return OracleResult(False, result.trace, "seed predicate did not reproduce")
50
+ return result
51
+
52
+
53
+ def shrink_trace(
54
+ trace: Path,
55
+ failure: str = typer.Option(
56
+ ..., "--failure", help="Seed failure rule in Grudge DSL."
57
+ ),
58
+ out: Path = typer.Option(..., "--out", help="Destination cassette JSON."),
59
+ agent: str = typer.Option(
60
+ "examples.leaky_agent.agent",
61
+ "--agent",
62
+ help="Agent module used to evaluate candidate cassettes.",
63
+ ),
64
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
65
+ debug: bool = typer.Option(False, "--debug", help="Show a traceback on errors."),
66
+ ) -> None:
67
+ """Shrink a failing trace while retaining the supplied seed failure."""
68
+
69
+ def run() -> dict[str, Any]:
70
+ source = Trace.from_jsonl(read_text(trace, label="trace"))
71
+ cassette = world_from_trace(source)
72
+ seed = demo_rule() if failure.strip().lower() == "demo" else parse(failure)
73
+ result = build_minimal_cassette(
74
+ cassette,
75
+ _ReplayCompleteOracle(
76
+ PredicateOracle(
77
+ load_callable(agent),
78
+ seed,
79
+ llm=ScriptedLLM(),
80
+ max_oracle_calls=5000,
81
+ ),
82
+ seed,
83
+ ),
84
+ )
85
+ write_text(
86
+ out,
87
+ result.minimal.model_dump_json(indent=2) + "\n",
88
+ label="cassette",
89
+ )
90
+ if not json_output:
91
+ for event in result.history:
92
+ typer.echo(
93
+ f"shrink: {event['reducer']} "
94
+ f"{event['size_before']} -> {event['size_after']} "
95
+ f"({'kept' if event['kept'] else 'discarded'})"
96
+ )
97
+ return {
98
+ "command": "shrink",
99
+ "cassette": str(out),
100
+ "original_entries": result.stats["original_entries"],
101
+ "minimal_entries": result.stats["minimal_entries"],
102
+ "original_bytes": result.stats["original_bytes"],
103
+ "minimal_bytes": result.stats["minimal_bytes"],
104
+ "oracle_calls": result.stats["oracle_calls"],
105
+ "history_events": result.stats["history_events"],
106
+ }
107
+
108
+ finish(command(run, json_output=json_output, debug=debug), json_output=json_output)
grudge/config.py ADDED
@@ -0,0 +1,35 @@
1
+ """Bootstrap configuration shared by every pipeline stage.
2
+
3
+ Phase 0 keeps defaults deterministic and offline-first; later phases may add
4
+ validated environment loading without changing these stable names.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ N_RUNS: int = 20
11
+ WILSON_Z: float = 1.96
12
+ GATE_THRESHOLD: float = 0.80
13
+ OFFLINE: bool = True
14
+ DEFAULT_MODEL: str = "offline-scripted"
15
+
16
+ PACKAGE_DIR: Path = Path(__file__).resolve().parent
17
+ PROJECT_ROOT: Path = PACKAGE_DIR.parent
18
+ CACHE_DIR: Path = PROJECT_ROOT / ".grudge" / "cache"
19
+ SCARS_DIR: Path = PROJECT_ROOT / "scars"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Settings:
24
+ """Immutable application defaults used by the Phase 0 command surface."""
25
+
26
+ n_runs: int = N_RUNS
27
+ wilson_z: float = WILSON_Z
28
+ gate_threshold: float = GATE_THRESHOLD
29
+ offline: bool = OFFLINE
30
+ model: str = DEFAULT_MODEL
31
+ cache_dir: Path = CACHE_DIR
32
+ scars_dir: Path = SCARS_DIR
33
+
34
+
35
+ DEFAULTS = Settings()
@@ -0,0 +1 @@
1
+ """Demo package; the five-beat offline story is implemented in a later phase."""