caucus 0.2.0__tar.gz → 0.2.1__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.
Files changed (44) hide show
  1. {caucus-0.2.0 → caucus-0.2.1}/PKG-INFO +1 -1
  2. {caucus-0.2.0 → caucus-0.2.1}/config.example.yaml +11 -2
  3. {caucus-0.2.0 → caucus-0.2.1}/examples/trading-robinhood/config.yaml +4 -2
  4. {caucus-0.2.0 → caucus-0.2.1}/pyproject.toml +1 -1
  5. {caucus-0.2.0 → caucus-0.2.1}/src/caucus/__init__.py +1 -1
  6. {caucus-0.2.0 → caucus-0.2.1}/src/caucus/cli.py +11 -11
  7. {caucus-0.2.0 → caucus-0.2.1}/src/caucus/config.py +35 -2
  8. caucus-0.2.1/src/caucus/notify.py +66 -0
  9. {caucus-0.2.0 → caucus-0.2.1}/tests/test_briefing.py +3 -1
  10. caucus-0.2.1/tests/test_notify.py +111 -0
  11. {caucus-0.2.0 → caucus-0.2.1}/uv.lock +1 -1
  12. {caucus-0.2.0 → caucus-0.2.1}/.github/codex/prompts/review.md +0 -0
  13. {caucus-0.2.0 → caucus-0.2.1}/.github/codex/review-config.json +0 -0
  14. {caucus-0.2.0 → caucus-0.2.1}/.github/codex/review-rules.md +0 -0
  15. {caucus-0.2.0 → caucus-0.2.1}/.github/codex/scripts/build-review-prompt.mjs +0 -0
  16. {caucus-0.2.0 → caucus-0.2.1}/.github/codex/scripts/normalize-review-output.mjs +0 -0
  17. {caucus-0.2.0 → caucus-0.2.1}/.github/codex/scripts/post-review.cjs +0 -0
  18. {caucus-0.2.0 → caucus-0.2.1}/.github/pull_request_template.md +0 -0
  19. {caucus-0.2.0 → caucus-0.2.1}/.github/workflows/ci.yml +0 -0
  20. {caucus-0.2.0 → caucus-0.2.1}/.github/workflows/codex-review.yml +0 -0
  21. {caucus-0.2.0 → caucus-0.2.1}/.github/workflows/keep-codex-auth-fresh.yml +0 -0
  22. {caucus-0.2.0 → caucus-0.2.1}/.github/workflows/release.yml +0 -0
  23. {caucus-0.2.0 → caucus-0.2.1}/.gitignore +0 -0
  24. {caucus-0.2.0 → caucus-0.2.1}/AGENT_SETUP.md +0 -0
  25. {caucus-0.2.0 → caucus-0.2.1}/DISCLAIMER.md +0 -0
  26. {caucus-0.2.0 → caucus-0.2.1}/LICENSE +0 -0
  27. {caucus-0.2.0 → caucus-0.2.1}/README.md +0 -0
  28. {caucus-0.2.0 → caucus-0.2.1}/SPEC.md +0 -0
  29. {caucus-0.2.0 → caucus-0.2.1}/examples/trading-robinhood/README.md +0 -0
  30. {caucus-0.2.0 → caucus-0.2.1}/examples/trading-robinhood/evidence.sample.json +0 -0
  31. {caucus-0.2.0 → caucus-0.2.1}/examples/trading-robinhood/import_campaigns.py +0 -0
  32. {caucus-0.2.0 → caucus-0.2.1}/examples/trading-robinhood/mcp.example.json +0 -0
  33. {caucus-0.2.0 → caucus-0.2.1}/src/caucus/backends.py +0 -0
  34. {caucus-0.2.0 → caucus-0.2.1}/src/caucus/briefing.py +0 -0
  35. {caucus-0.2.0 → caucus-0.2.1}/src/caucus/engine.py +0 -0
  36. {caucus-0.2.0 → caucus-0.2.1}/src/caucus/evidence.py +0 -0
  37. {caucus-0.2.0 → caucus-0.2.1}/src/caucus/intents.py +0 -0
  38. {caucus-0.2.0 → caucus-0.2.1}/src/caucus/record.py +0 -0
  39. {caucus-0.2.0 → caucus-0.2.1}/tests/test_cli.py +0 -0
  40. {caucus-0.2.0 → caucus-0.2.1}/tests/test_config.py +0 -0
  41. {caucus-0.2.0 → caucus-0.2.1}/tests/test_engine.py +0 -0
  42. {caucus-0.2.0 → caucus-0.2.1}/tests/test_evidence.py +0 -0
  43. {caucus-0.2.0 → caucus-0.2.1}/tests/test_intents.py +0 -0
  44. {caucus-0.2.0 → caucus-0.2.1}/tests/test_record.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: caucus
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Your AI agents, deliberating on the record. MCP-grounded multi-agent consensus with recorded dissent and an auditable decision log.
5
5
  Project-URL: Repository, https://github.com/srinath-jukanti/caucus
6
6
  Author: Srinath Jukanti
@@ -46,5 +46,14 @@ panel:
46
46
  # - "Which open intent, if any, is due for action today?"
47
47
  # - "Any risks that warrant deviating from the standing plans?"
48
48
 
49
- # Delivery: run after the briefing renders, with the markdown path as argument.
50
- # notify_command: bash send_briefing.sh
49
+ # Delivery after the briefing renders. Email credentials come from the NAMED
50
+ # environment variables — never put secrets in this file.
51
+ # notify:
52
+ # type: email # Gmail-friendly SMTP defaults
53
+ # to: you@example.com
54
+ # address_env: GMAIL_ADDRESS # env var holding the sending address
55
+ # password_env: GMAIL_APP_PASSWORD
56
+ # ...or run anything executable with the briefing path as its argument:
57
+ # notify:
58
+ # type: command
59
+ # command: bash send_briefing.sh
@@ -35,5 +35,7 @@ agenda:
35
35
  - "Given the open intents, live positions, and current prices: which intent, if any, is due for its next action today, and what exactly should be done?"
36
36
  - "Does current market regime or any position's behavior warrant a defensive action (hedge, trim, or pause of an intent) beyond the standing plans?"
37
37
 
38
- # Delivery hook receives the rendered briefing path as its argument.
39
- # notify_command: bash /path/to/send_briefing.sh
38
+ # Delivery — Gmail via an app password; the env vars hold the credentials.
39
+ # notify:
40
+ # type: email
41
+ # to: you@example.com
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "caucus"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Your AI agents, deliberating on the record. MCP-grounded multi-agent consensus with recorded dissent and an auditable decision log."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Caucus — AI agents deliberating on the record."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.1"
@@ -207,16 +207,14 @@ def briefing(
207
207
  ) -> None:
208
208
  """Deliberate every agenda subject from the config and render one briefing.
209
209
 
210
- Writes OUT (markdown) and its .json sibling, then runs the configured
211
- notify_command with the markdown path as its argument — email scripts,
212
- webhooks, anything executable.
210
+ Writes OUT (markdown) and its .json sibling, then delivers it via the
211
+ configured notifier 'email' (SMTP, Gmail-friendly, credentials from
212
+ env vars) or 'command' (any executable, briefing path as argument).
213
213
  """
214
- import shlex
215
- import subprocess
216
-
217
214
  from caucus.briefing import run_agenda
218
215
  from caucus.config import Config, ConfigError
219
216
  from caucus.engine import Deliberation
217
+ from caucus.notify import NotifyError
220
218
 
221
219
  config_path = (
222
220
  config if config else Path("config.yaml") if Path("config.yaml").exists() else None
@@ -247,11 +245,13 @@ def briefing(
247
245
  for record in result.records:
248
246
  typer.echo(f"- {record.subject[:70]} → {record.decision[:90]} ({record.confidence:.0%})")
249
247
 
250
- if loaded.notify_command:
251
- completed = subprocess.run(f"{loaded.notify_command} {shlex.quote(str(out))}", shell=True)
252
- if completed.returncode != 0:
253
- typer.echo(f"notify command exited {completed.returncode}", err=True)
254
- raise typer.Exit(1)
248
+ if loaded.notify is not None:
249
+ subject = f"[Caucus] briefing {result.generated_at[:10]} {len(result.records)} decisions"
250
+ try:
251
+ loaded.notify.send(subject, result.to_markdown(), attachment_path=out)
252
+ except NotifyError as err:
253
+ typer.echo(str(err), err=True)
254
+ raise typer.Exit(1) from err
255
255
  typer.echo("notified")
256
256
 
257
257
 
@@ -14,6 +14,7 @@ import yaml
14
14
  from caucus.backends import Backend, ClaudeCodeBackend, OpenAICompatibleBackend
15
15
  from caucus.engine import DEFAULT_PANEL, Analyst
16
16
  from caucus.evidence import EvidenceSource
17
+ from caucus.notify import CommandNotifier, EmailNotifier
17
18
 
18
19
 
19
20
  class ConfigError(ValueError):
@@ -28,7 +29,7 @@ class Config:
28
29
  intents: Path | None = None
29
30
  evidence_sources: list[EvidenceSource] = field(default_factory=list)
30
31
  agenda: list[str] = field(default_factory=list)
31
- notify_command: str | None = None
32
+ notify: EmailNotifier | CommandNotifier | None = None
32
33
 
33
34
  @classmethod
34
35
  def load(cls, path: Path) -> Config:
@@ -64,10 +65,14 @@ class Config:
64
65
  ):
65
66
  raise ConfigError("'agenda' must be a non-empty list of subject strings")
66
67
  config.agenda = agenda
68
+ if "notify" in raw and "notify_command" in raw:
69
+ raise ConfigError("use either 'notify' or 'notify_command', not both")
70
+ if "notify" in raw:
71
+ config.notify = _build_notifier(raw["notify"])
67
72
  if "notify_command" in raw:
68
73
  if not isinstance(raw["notify_command"], str) or not raw["notify_command"].strip():
69
74
  raise ConfigError("'notify_command' must be a non-empty string")
70
- config.notify_command = raw["notify_command"]
75
+ config.notify = CommandNotifier(command=raw["notify_command"])
71
76
  return config
72
77
 
73
78
 
@@ -100,6 +105,34 @@ def _build_backend(raw: object) -> Backend:
100
105
  raise ConfigError(f"unknown backend type {kind!r} (expected 'claude' or 'openai')")
101
106
 
102
107
 
108
+ def _build_notifier(raw: object) -> EmailNotifier | CommandNotifier:
109
+ if not isinstance(raw, dict):
110
+ raise ConfigError("'notify' must be a mapping with a 'type'")
111
+ kind = raw.get("type")
112
+ if kind == "email":
113
+ to = raw.get("to")
114
+ if not isinstance(to, str) or not to.strip():
115
+ raise ConfigError("email notify requires a string 'to' address")
116
+ options = {}
117
+ for key in ("smtp_host", "address_env", "password_env"):
118
+ if key in raw:
119
+ if not isinstance(raw[key], str) or not raw[key].strip():
120
+ raise ConfigError(f"'{key}' must be a non-empty string")
121
+ options[key] = raw[key]
122
+ if "smtp_port" in raw:
123
+ port = raw["smtp_port"]
124
+ if isinstance(port, bool) or not isinstance(port, int) or not 0 < port < 65536:
125
+ raise ConfigError("'smtp_port' must be a valid port number")
126
+ options["smtp_port"] = port
127
+ return EmailNotifier(to=to, **options)
128
+ if kind == "command":
129
+ command = raw.get("command")
130
+ if not isinstance(command, str) or not command.strip():
131
+ raise ConfigError("command notify requires a string 'command'")
132
+ return CommandNotifier(command=command)
133
+ raise ConfigError(f"unknown notify type {kind!r} (expected 'email' or 'command')")
134
+
135
+
103
136
  def _build_evidence_sources(raw: object) -> list[EvidenceSource]:
104
137
  if not isinstance(raw, list):
105
138
  raise ConfigError("'evidence_sources' must be a list")
@@ -0,0 +1,66 @@
1
+ """Notification: how a finished briefing reaches a human.
2
+
3
+ Config-driven, two kinds:
4
+ - email: SMTP (Gmail-friendly defaults) using stdlib smtplib. Credentials are
5
+ read from environment variables named in the config — never from the config
6
+ itself.
7
+ - command: run any executable with the briefing path as its argument.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import shlex
14
+ import smtplib
15
+ import subprocess
16
+ from dataclasses import dataclass
17
+ from email.mime.text import MIMEText
18
+ from pathlib import Path
19
+
20
+
21
+ class NotifyError(RuntimeError):
22
+ """Raised when a notification cannot be delivered."""
23
+
24
+
25
+ @dataclass
26
+ class EmailNotifier:
27
+ """SMTP delivery; defaults suit a Gmail account with an app password."""
28
+
29
+ to: str
30
+ smtp_host: str = "smtp.gmail.com"
31
+ smtp_port: int = 465
32
+ address_env: str = "GMAIL_ADDRESS"
33
+ password_env: str = "GMAIL_APP_PASSWORD"
34
+ smtp_factory: object | None = None # injectable for tests
35
+
36
+ def send(self, subject: str, body: str, attachment_path: Path | None = None) -> None:
37
+ address = os.environ.get(self.address_env)
38
+ password = os.environ.get(self.password_env)
39
+ if not address or not password:
40
+ raise NotifyError(
41
+ f"email notifier needs ${self.address_env} and ${self.password_env} set"
42
+ )
43
+ message = MIMEText(body, "plain", "utf-8")
44
+ message["Subject"] = subject
45
+ message["From"] = address
46
+ message["To"] = self.to
47
+ factory = self.smtp_factory or smtplib.SMTP_SSL
48
+ try:
49
+ with factory(self.smtp_host, self.smtp_port) as server:
50
+ server.login(address, password)
51
+ server.sendmail(address, [self.to], message.as_string())
52
+ except OSError as err:
53
+ raise NotifyError(f"email delivery failed: {err}") from err
54
+
55
+
56
+ @dataclass
57
+ class CommandNotifier:
58
+ """Runs a user-configured command with the briefing path appended."""
59
+
60
+ command: str
61
+
62
+ def send(self, subject: str, body: str, attachment_path: Path | None = None) -> None:
63
+ target = shlex.quote(str(attachment_path)) if attachment_path else ""
64
+ completed = subprocess.run(f"{self.command} {target}".strip(), shell=True)
65
+ if completed.returncode != 0:
66
+ raise NotifyError(f"notify command exited {completed.returncode}")
@@ -62,7 +62,9 @@ notify_command: bash send_briefing.sh
62
62
  )
63
63
  config = Config.load(path)
64
64
  assert config.agenda == ["Question one?", "Question two?"]
65
- assert config.notify_command == "bash send_briefing.sh"
65
+ from caucus.notify import CommandNotifier
66
+
67
+ assert config.notify == CommandNotifier(command="bash send_briefing.sh")
66
68
 
67
69
 
68
70
  @pytest.mark.parametrize(
@@ -0,0 +1,111 @@
1
+ from types import SimpleNamespace
2
+
3
+ import pytest
4
+
5
+ from caucus.config import Config, ConfigError
6
+ from caucus.notify import CommandNotifier, EmailNotifier, NotifyError
7
+
8
+
9
+ class FakeSMTP:
10
+ """Records logins and sends; usable as a context manager factory."""
11
+
12
+ instances = []
13
+
14
+ def __init__(self, host, port):
15
+ self.host, self.port = host, port
16
+ self.logins, self.sent = [], []
17
+ FakeSMTP.instances.append(self)
18
+
19
+ def __enter__(self):
20
+ return self
21
+
22
+ def __exit__(self, *args):
23
+ return False
24
+
25
+ def login(self, address, password):
26
+ self.logins.append((address, password))
27
+
28
+ def sendmail(self, sender, recipients, message):
29
+ self.sent.append(SimpleNamespace(sender=sender, recipients=recipients, message=message))
30
+
31
+
32
+ @pytest.fixture(autouse=True)
33
+ def reset_fake():
34
+ FakeSMTP.instances = []
35
+
36
+
37
+ def test_email_notifier_sends_via_smtp(monkeypatch):
38
+ monkeypatch.setenv("GMAIL_ADDRESS", "me@example.com")
39
+ monkeypatch.setenv("GMAIL_APP_PASSWORD", "app-pass")
40
+ notifier = EmailNotifier(to="you@example.com", smtp_factory=FakeSMTP)
41
+ notifier.send("subject line", "body text")
42
+ server = FakeSMTP.instances[0]
43
+ assert server.host == "smtp.gmail.com"
44
+ assert server.logins == [("me@example.com", "app-pass")]
45
+ sent = server.sent[0]
46
+ assert sent.recipients == ["you@example.com"]
47
+ assert "subject line" in sent.message
48
+
49
+
50
+ def test_email_notifier_requires_env(monkeypatch):
51
+ monkeypatch.delenv("GMAIL_ADDRESS", raising=False)
52
+ monkeypatch.delenv("GMAIL_APP_PASSWORD", raising=False)
53
+ notifier = EmailNotifier(to="you@example.com", smtp_factory=FakeSMTP)
54
+ with pytest.raises(NotifyError, match="GMAIL_ADDRESS"):
55
+ notifier.send("s", "b")
56
+ assert FakeSMTP.instances == []
57
+
58
+
59
+ def test_command_notifier_runs_and_fails_loudly(tmp_path):
60
+ marker = tmp_path / "ran.txt"
61
+ CommandNotifier(command=f"touch {marker}").send("s", "b")
62
+ assert marker.exists()
63
+ with pytest.raises(NotifyError, match="exited 3"):
64
+ CommandNotifier(command="exit 3").send("s", "b")
65
+
66
+
67
+ def test_config_parses_email_notifier(tmp_path):
68
+ path = tmp_path / "config.yaml"
69
+ path.write_text(
70
+ """
71
+ notify:
72
+ type: email
73
+ to: you@example.com
74
+ address_env: MY_FROM
75
+ password_env: MY_PASS
76
+ smtp_port: 587
77
+ """
78
+ )
79
+ notify = Config.load(path).notify
80
+ assert isinstance(notify, EmailNotifier)
81
+ assert notify.to == "you@example.com"
82
+ assert notify.address_env == "MY_FROM"
83
+ assert notify.smtp_port == 587
84
+
85
+
86
+ def test_config_legacy_notify_command_still_works(tmp_path):
87
+ path = tmp_path / "config.yaml"
88
+ path.write_text("notify_command: bash send.sh\n")
89
+ notify = Config.load(path).notify
90
+ assert isinstance(notify, CommandNotifier)
91
+ assert notify.command == "bash send.sh"
92
+
93
+
94
+ @pytest.mark.parametrize(
95
+ "text",
96
+ [
97
+ "notify: email\n",
98
+ "notify:\n type: email\n",
99
+ "notify:\n type: email\n to: ''\n",
100
+ "notify:\n type: email\n to: a@b.c\n smtp_port: -5\n",
101
+ "notify:\n type: email\n to: a@b.c\n smtp_port: true\n",
102
+ "notify:\n type: command\n",
103
+ "notify:\n type: slack\n",
104
+ "notify:\n type: command\n command: x\nnotify_command: y\n",
105
+ ],
106
+ )
107
+ def test_config_rejects_invalid_notify(tmp_path, text):
108
+ path = tmp_path / "config.yaml"
109
+ path.write_text(text)
110
+ with pytest.raises(ConfigError):
111
+ Config.load(path)
@@ -34,7 +34,7 @@ wheels = [
34
34
 
35
35
  [[package]]
36
36
  name = "caucus"
37
- version = "0.2.0"
37
+ version = "0.2.1"
38
38
  source = { editable = "." }
39
39
  dependencies = [
40
40
  { name = "pyyaml" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes