beadhub 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 (54) hide show
  1. beadhub/__init__.py +12 -0
  2. beadhub/api.py +260 -0
  3. beadhub/auth.py +101 -0
  4. beadhub/aweb_context.py +65 -0
  5. beadhub/aweb_introspection.py +70 -0
  6. beadhub/beads_sync.py +514 -0
  7. beadhub/cli.py +330 -0
  8. beadhub/config.py +65 -0
  9. beadhub/db.py +129 -0
  10. beadhub/defaults/invariants/01-tracking-bdh-only.md +11 -0
  11. beadhub/defaults/invariants/02-communication-mail-first.md +36 -0
  12. beadhub/defaults/invariants/03-communication-chat.md +60 -0
  13. beadhub/defaults/invariants/04-identity-no-impersonation.md +17 -0
  14. beadhub/defaults/invariants/05-collaborate.md +12 -0
  15. beadhub/defaults/roles/backend.md +55 -0
  16. beadhub/defaults/roles/coordinator.md +44 -0
  17. beadhub/defaults/roles/frontend.md +77 -0
  18. beadhub/defaults/roles/implementer.md +73 -0
  19. beadhub/defaults/roles/reviewer.md +56 -0
  20. beadhub/defaults/roles/startup-expert.md +93 -0
  21. beadhub/defaults.py +262 -0
  22. beadhub/events.py +704 -0
  23. beadhub/internal_auth.py +121 -0
  24. beadhub/jsonl.py +68 -0
  25. beadhub/logging.py +62 -0
  26. beadhub/migrations/beads/001_initial.sql +70 -0
  27. beadhub/migrations/beads/002_search_indexes.sql +20 -0
  28. beadhub/migrations/server/001_initial.sql +279 -0
  29. beadhub/names.py +33 -0
  30. beadhub/notifications.py +275 -0
  31. beadhub/pagination.py +125 -0
  32. beadhub/presence.py +495 -0
  33. beadhub/rate_limit.py +152 -0
  34. beadhub/redis_client.py +11 -0
  35. beadhub/roles.py +35 -0
  36. beadhub/routes/__init__.py +1 -0
  37. beadhub/routes/agents.py +303 -0
  38. beadhub/routes/bdh.py +655 -0
  39. beadhub/routes/beads.py +778 -0
  40. beadhub/routes/claims.py +141 -0
  41. beadhub/routes/escalations.py +471 -0
  42. beadhub/routes/init.py +348 -0
  43. beadhub/routes/mcp.py +338 -0
  44. beadhub/routes/policies.py +833 -0
  45. beadhub/routes/repos.py +538 -0
  46. beadhub/routes/status.py +568 -0
  47. beadhub/routes/subscriptions.py +362 -0
  48. beadhub/routes/workspaces.py +1642 -0
  49. beadhub/workspace_config.py +202 -0
  50. beadhub-0.1.0.dist-info/METADATA +254 -0
  51. beadhub-0.1.0.dist-info/RECORD +54 -0
  52. beadhub-0.1.0.dist-info/WHEEL +4 -0
  53. beadhub-0.1.0.dist-info/entry_points.txt +2 -0
  54. beadhub-0.1.0.dist-info/licenses/LICENSE +21 -0
beadhub/cli.py ADDED
@@ -0,0 +1,330 @@
1
+ import json
2
+ import os
3
+
4
+ import httpx
5
+ import typer
6
+ import uvicorn
7
+
8
+ from .config import get_settings
9
+ from .workspace_config import get_workspace_id
10
+
11
+ app = typer.Typer(help="BeadHub OSS Core CLI")
12
+
13
+
14
+ def _resolve_workspace_id(override: str | None) -> str:
15
+ """Resolve workspace_id from override or .beadhub file.
16
+
17
+ Args:
18
+ override: Explicit workspace_id from --workspace-id flag.
19
+
20
+ Returns:
21
+ workspace_id string.
22
+
23
+ Raises:
24
+ typer.Exit: If no workspace_id is available.
25
+ """
26
+ workspace_id = get_workspace_id(override=override)
27
+ if not workspace_id:
28
+ typer.echo(
29
+ "Error: No workspace_id available.\n"
30
+ "Either:\n"
31
+ " - Run 'bdh :init' to create a .beadhub file\n"
32
+ " - Pass --workspace-id explicitly",
33
+ err=True,
34
+ )
35
+ raise typer.Exit(1)
36
+ return workspace_id
37
+
38
+
39
+ def _resolve_api_key(override: str | None) -> str | None:
40
+ if override:
41
+ return override
42
+ return os.getenv("BEADHUB_API_KEY")
43
+
44
+
45
+ def _handle_api_call(
46
+ method: str,
47
+ url: str,
48
+ allow_statuses: set[int] | None = None,
49
+ api_key: str | None = None,
50
+ **kwargs,
51
+ ) -> httpx.Response:
52
+ """
53
+ Execute an HTTP request with proper error handling.
54
+ Handles network errors, timeouts, and HTTP status errors gracefully.
55
+
56
+ Args:
57
+ method: HTTP method (GET, POST, DELETE)
58
+ url: Request URL
59
+ allow_statuses: Set of status codes to allow through (e.g., {404, 409})
60
+ api_key: API key to include as Authorization: Bearer header
61
+ **kwargs: Additional arguments passed to httpx
62
+ """
63
+ # Build headers with Authorization if provided (or from BEADHUB_API_KEY env var).
64
+ headers = kwargs.pop("headers", {})
65
+ api_key = api_key or os.getenv("BEADHUB_API_KEY")
66
+ if api_key:
67
+ headers["Authorization"] = f"Bearer {api_key}"
68
+
69
+ try:
70
+ if method == "GET":
71
+ resp = httpx.get(url, timeout=30, headers=headers, **kwargs)
72
+ elif method == "POST":
73
+ resp = httpx.post(url, timeout=30, headers=headers, **kwargs)
74
+ elif method == "DELETE":
75
+ resp = httpx.delete(url, timeout=30, headers=headers, **kwargs)
76
+ else:
77
+ raise ValueError(f"Unsupported method: {method}")
78
+
79
+ # Allow specific status codes through for caller handling
80
+ if allow_statuses and resp.status_code in allow_statuses:
81
+ return resp
82
+
83
+ # Handle common HTTP errors with friendly messages
84
+ if resp.status_code == 401:
85
+ typer.echo("Error: Unauthorized - API key required", err=True)
86
+ raise typer.Exit(1)
87
+ elif resp.status_code == 403:
88
+ typer.echo("Error: Forbidden - insufficient permissions", err=True)
89
+ raise typer.Exit(1)
90
+ elif resp.status_code >= 500:
91
+ typer.echo(f"Error: Server error ({resp.status_code})", err=True)
92
+ raise typer.Exit(1)
93
+
94
+ return resp
95
+
96
+ except httpx.ConnectError:
97
+ api_base = _get_api_base()
98
+ typer.echo(f"Error: Cannot connect to BeadHub API at {api_base}", err=True)
99
+ typer.echo("Is the server running? Try: beadhub serve", err=True)
100
+ raise typer.Exit(1)
101
+ except httpx.TimeoutException:
102
+ typer.echo("Error: Request timed out", err=True)
103
+ raise typer.Exit(1)
104
+ except httpx.RequestError as e:
105
+ typer.echo(f"Error: Network error - {e}", err=True)
106
+ raise typer.Exit(1)
107
+
108
+
109
+ escalations_app = typer.Typer(help="Manage escalations")
110
+ beads_app = typer.Typer(help="Beads integration")
111
+ app.add_typer(escalations_app, name="escalations")
112
+ app.add_typer(beads_app, name="beads")
113
+
114
+
115
+ @app.command()
116
+ def serve(
117
+ host: str | None = typer.Option(None, help="Host interface to bind"),
118
+ port: int | None = typer.Option(None, help="Port to bind"),
119
+ reload: bool | None = typer.Option(None, help="Enable auto-reload (development only)"),
120
+ log_level: str | None = typer.Option(None, help="Log level for the server"),
121
+ ) -> None:
122
+ """
123
+ Start the BeadHub API server.
124
+ """
125
+ settings = get_settings()
126
+
127
+ uvicorn.run(
128
+ "beadhub.api:create_app",
129
+ host=host or settings.host,
130
+ port=port or settings.port,
131
+ reload=reload if reload is not None else settings.reload,
132
+ log_level=log_level or settings.log_level,
133
+ factory=True,
134
+ )
135
+
136
+
137
+ @app.command()
138
+ def status(
139
+ workspace_id: str | None = typer.Option(
140
+ None, "--workspace-id", help="Workspace ID (reads from .beadhub if not provided)"
141
+ ),
142
+ json_output: bool = typer.Option(False, "--json", help="Output JSON"),
143
+ ) -> None:
144
+ """
145
+ Show BeadHub workspace status (agent presence and escalations).
146
+ """
147
+ resolved_workspace_id = _resolve_workspace_id(workspace_id)
148
+ params = {"workspace_id": resolved_workspace_id}
149
+ resp = _handle_api_call("GET", f"{_get_api_base()}/v1/status", params=params)
150
+ data = resp.json()
151
+
152
+ if json_output:
153
+ typer.echo(json.dumps(data, indent=2))
154
+ return
155
+
156
+ workspace = data.get("workspace", {})
157
+ typer.echo(f"Workspace: {workspace.get('workspace_id')}")
158
+ typer.echo(f"Timestamp: {data.get('timestamp')}")
159
+ typer.echo("")
160
+
161
+ agents = data.get("agents", [])
162
+ if agents:
163
+ typer.echo("Agents:")
164
+ for agent in agents:
165
+ typer.echo(
166
+ f" {agent.get('alias', '?'):15} status={agent.get('status','?'):8} "
167
+ f"issue={agent.get('current_issue') or '-'}"
168
+ )
169
+ else:
170
+ typer.echo("Agents: (none)")
171
+
172
+ typer.echo("")
173
+ typer.echo(f"Escalations pending: {data.get('escalations_pending', 0)}")
174
+
175
+
176
+ def _get_api_base() -> str:
177
+ return os.getenv("BEADHUB_API_URL", "http://localhost:8000")
178
+
179
+
180
+ @escalations_app.command("list")
181
+ def escalations_list(
182
+ workspace_id: str | None = typer.Option(
183
+ None, "--workspace-id", help="Workspace ID (reads from .beadhub if not provided)"
184
+ ),
185
+ status: str | None = typer.Option(None, help="Filter by status"),
186
+ agent: str | None = typer.Option(None, help="Filter by agent name"),
187
+ json_output: bool = typer.Option(False, "--json", help="Output JSON"),
188
+ ) -> None:
189
+ """
190
+ List escalations from the API.
191
+ """
192
+ resolved_workspace_id = _resolve_workspace_id(workspace_id)
193
+ params: dict[str, str] = {"workspace_id": resolved_workspace_id}
194
+ if status:
195
+ params["status"] = status
196
+ if agent:
197
+ params["alias"] = agent
198
+
199
+ resp = _handle_api_call("GET", f"{_get_api_base()}/v1/escalations", params=params)
200
+ data = resp.json()
201
+
202
+ if json_output:
203
+ typer.echo(json.dumps(data, indent=2))
204
+ return
205
+
206
+ for esc in data.get("escalations", []):
207
+ typer.echo(
208
+ f"{esc['escalation_id']} {esc['status']:9} " f"{esc['alias']:15} {esc['subject']}"
209
+ )
210
+
211
+
212
+ @escalations_app.command("view")
213
+ def escalations_view(escalation_id: str) -> None:
214
+ """
215
+ View escalation details.
216
+ """
217
+ resp = _handle_api_call(
218
+ "GET", f"{_get_api_base()}/v1/escalations/{escalation_id}", allow_statuses={404}
219
+ )
220
+ if resp.status_code == 404:
221
+ typer.echo("Error: Escalation not found", err=True)
222
+ raise typer.Exit(1)
223
+ typer.echo(json.dumps(resp.json(), indent=2))
224
+
225
+
226
+ @beads_app.command("issues")
227
+ def beads_issues(
228
+ workspace_id: str | None = typer.Option(
229
+ None, "--workspace-id", help="Workspace ID (reads from .beadhub if not provided)"
230
+ ),
231
+ api_key: str | None = typer.Option(
232
+ None, "--api-key", help="BeadHub API key (defaults to BEADHUB_API_KEY)"
233
+ ),
234
+ status: str | None = typer.Option(None, help="Filter by status"),
235
+ assignee: str | None = typer.Option(None, help="Filter by assignee"),
236
+ label: str | None = typer.Option(None, help="Filter by label"),
237
+ json_output: bool = typer.Option(False, "--json", help="Output JSON"),
238
+ ) -> None:
239
+ """
240
+ List Beads issues from BeadHub.
241
+ """
242
+ resolved_workspace_id = _resolve_workspace_id(workspace_id)
243
+ resolved_api_key = _resolve_api_key(api_key)
244
+ if not resolved_api_key:
245
+ typer.echo("Error: BEADHUB_API_KEY not set (run `bdh :init` or pass --api-key)", err=True)
246
+ raise typer.Exit(1)
247
+ params: dict[str, str] = {"workspace_id": resolved_workspace_id}
248
+ if status:
249
+ params["status"] = status
250
+ if assignee:
251
+ params["assignee"] = assignee
252
+ if label:
253
+ params["label"] = label
254
+
255
+ resp = _handle_api_call(
256
+ "GET",
257
+ f"{_get_api_base()}/v1/beads/issues",
258
+ params=params,
259
+ api_key=resolved_api_key,
260
+ )
261
+ data = resp.json()
262
+
263
+ if json_output:
264
+ typer.echo(json.dumps(data, indent=2))
265
+ return
266
+
267
+ for issue in data.get("issues", []):
268
+ typer.echo(
269
+ f"{issue['bead_id']} P{issue['priority']} " f"{issue['status']:10} {issue['title']}"
270
+ )
271
+
272
+
273
+ @beads_app.command("ready")
274
+ def beads_ready(
275
+ workspace_id: str | None = typer.Option(
276
+ None, "--workspace-id", help="Workspace ID (reads from .beadhub if not provided)"
277
+ ),
278
+ api_key: str | None = typer.Option(
279
+ None, "--api-key", help="BeadHub API key (defaults to BEADHUB_API_KEY)"
280
+ ),
281
+ json_output: bool = typer.Option(False, "--json", help="Output JSON"),
282
+ ) -> None:
283
+ """
284
+ Show Beads issues that are ready to work on.
285
+ """
286
+ resolved_workspace_id = _resolve_workspace_id(workspace_id)
287
+ resolved_api_key = _resolve_api_key(api_key)
288
+ if not resolved_api_key:
289
+ typer.echo("Error: BEADHUB_API_KEY not set (run `bdh :init` or pass --api-key)", err=True)
290
+ raise typer.Exit(1)
291
+ params = {"workspace_id": resolved_workspace_id}
292
+ resp = _handle_api_call(
293
+ "GET",
294
+ f"{_get_api_base()}/v1/beads/ready",
295
+ params=params,
296
+ api_key=resolved_api_key,
297
+ )
298
+ data = resp.json()
299
+
300
+ if json_output:
301
+ typer.echo(json.dumps(data, indent=2))
302
+ return
303
+
304
+ for issue in data.get("issues", []):
305
+ typer.echo(f"{issue['bead_id']} P{issue['priority']} {issue['title']}")
306
+
307
+
308
+ @escalations_app.command("respond")
309
+ def escalations_respond(
310
+ escalation_id: str,
311
+ choice: str = typer.Option(..., "--choice", help="Chosen response"),
312
+ note: str | None = typer.Option(None, "--note", help="Optional note"),
313
+ ) -> None:
314
+ """
315
+ Respond to an escalation.
316
+ """
317
+ payload = {"response": choice}
318
+ if note:
319
+ payload["note"] = note
320
+
321
+ resp = _handle_api_call(
322
+ "POST",
323
+ f"{_get_api_base()}/v1/escalations/{escalation_id}/respond",
324
+ allow_statuses={404},
325
+ json=payload,
326
+ )
327
+ if resp.status_code == 404:
328
+ typer.echo("Error: Escalation not found", err=True)
329
+ raise typer.Exit(1)
330
+ typer.echo(json.dumps(resp.json(), indent=2))
beadhub/config.py ADDED
@@ -0,0 +1,65 @@
1
+ import os
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class Settings:
7
+ host: str
8
+ port: int
9
+ log_level: str
10
+ reload: bool
11
+ redis_url: str
12
+ database_url: str
13
+ presence_ttl_seconds: int
14
+ dashboard_human: str
15
+
16
+
17
+ def get_settings() -> Settings:
18
+ """
19
+ Load settings from environment at call time.
20
+
21
+ Accepts both prefixed (BEADHUB_*) and unprefixed env vars for flexibility.
22
+ """
23
+ database_url = os.getenv("BEADHUB_DATABASE_URL") or os.getenv("DATABASE_URL")
24
+ if not database_url:
25
+ raise ValueError(
26
+ "DATABASE_URL or BEADHUB_DATABASE_URL environment variable is required. "
27
+ "Example: postgresql://user:pass@localhost:5432/beadhub"
28
+ )
29
+
30
+ redis_url = (
31
+ os.getenv("BEADHUB_REDIS_URL") or os.getenv("REDIS_URL") or "redis://localhost:6379/0"
32
+ )
33
+
34
+ port_str = os.getenv("BEADHUB_PORT", "8000")
35
+ try:
36
+ port = int(port_str)
37
+ if not 1 <= port <= 65535:
38
+ raise ValueError(f"BEADHUB_PORT must be between 1 and 65535, got {port}")
39
+ except ValueError as e:
40
+ if "invalid literal" in str(e):
41
+ raise ValueError(f"BEADHUB_PORT must be a valid integer, got '{port_str}'")
42
+ raise
43
+
44
+ presence_ttl_str = os.getenv("BEADHUB_PRESENCE_TTL_SECONDS", "1800")
45
+ try:
46
+ presence_ttl = int(presence_ttl_str)
47
+ if presence_ttl < 10:
48
+ raise ValueError("BEADHUB_PRESENCE_TTL_SECONDS must be at least 10")
49
+ except ValueError as e:
50
+ if "invalid literal" in str(e):
51
+ raise ValueError(
52
+ f"BEADHUB_PRESENCE_TTL_SECONDS must be a valid integer, got '{presence_ttl_str}'"
53
+ )
54
+ raise
55
+
56
+ return Settings(
57
+ host=os.getenv("BEADHUB_HOST", "0.0.0.0"),
58
+ port=port,
59
+ log_level=os.getenv("BEADHUB_LOG_LEVEL", "info"),
60
+ reload=os.getenv("BEADHUB_RELOAD", "false").lower() == "true",
61
+ redis_url=redis_url,
62
+ database_url=database_url,
63
+ presence_ttl_seconds=presence_ttl,
64
+ dashboard_human=os.getenv("BEADHUB_DASHBOARD_HUMAN", "admin"),
65
+ )
beadhub/db.py ADDED
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from typing import Any, Dict, Optional
6
+
7
+ from fastapi import Request
8
+ from pgdbm import AsyncDatabaseManager, DatabaseConfig
9
+ from pgdbm.migrations import AsyncMigrationManager
10
+
11
+ from .config import get_settings
12
+
13
+
14
+ class DatabaseInfra:
15
+ """
16
+ Shared pgdbm infrastructure for BeadHub.
17
+
18
+ Creates a single shared pool and schema-specific managers
19
+ for core modules (server, beads).
20
+ """
21
+
22
+ def __init__(self) -> None:
23
+ self._shared_pool: Optional[Any] = None
24
+ self._managers: Dict[str, AsyncDatabaseManager] = {}
25
+ self._initialized: bool = False
26
+ self._init_lock: asyncio.Lock = asyncio.Lock()
27
+ self._owns_pool: bool = True
28
+
29
+ async def initialize(self, *, shared_pool: Optional[Any] = None) -> None:
30
+ if self._initialized:
31
+ return
32
+
33
+ async with self._init_lock:
34
+ # Double-check after acquiring lock (another coroutine may have initialized)
35
+ if self._initialized:
36
+ return # type: ignore[unreachable] # Valid double-checked locking
37
+
38
+ if shared_pool is None:
39
+ settings = get_settings()
40
+ config = DatabaseConfig(connection_string=settings.database_url)
41
+ shared_pool = await AsyncDatabaseManager.create_shared_pool(config)
42
+ self._owns_pool = True
43
+ else:
44
+ # Host application owns lifecycle of the pool.
45
+ self._owns_pool = False
46
+
47
+ self._shared_pool = shared_pool
48
+
49
+ self._managers["server"] = AsyncDatabaseManager(
50
+ pool=shared_pool,
51
+ schema="server",
52
+ )
53
+ self._managers["beads"] = AsyncDatabaseManager(
54
+ pool=shared_pool,
55
+ schema="beads",
56
+ )
57
+ # BeadHub implements the aweb protocol; keep identity/coordination tables
58
+ # in a dedicated schema for clean extraction.
59
+ self._managers["aweb"] = AsyncDatabaseManager(
60
+ pool=shared_pool,
61
+ schema="aweb",
62
+ )
63
+
64
+ base_dir = Path(__file__).resolve().parent
65
+ migrations_root = base_dir / "migrations"
66
+
67
+ for name, db in self._managers.items():
68
+ # Ensure schema exists before applying migrations
69
+ await db.execute(f'CREATE SCHEMA IF NOT EXISTS "{db.schema}"')
70
+
71
+ if name == "aweb":
72
+ # Apply aweb protocol migrations using the same module_name as the
73
+ # standalone aweb server so migration history is shared.
74
+ import aweb as aweb_pkg
75
+
76
+ aweb_root = Path(aweb_pkg.__file__).resolve().parent
77
+ module_migrations = aweb_root / "migrations" / "aweb"
78
+ module_name = "aweb-aweb"
79
+ else:
80
+ module_migrations = migrations_root / name
81
+ module_name = f"beadhub-{name}"
82
+
83
+ if module_migrations.is_dir():
84
+ manager = AsyncMigrationManager(
85
+ db,
86
+ migrations_path=str(module_migrations),
87
+ module_name=module_name,
88
+ )
89
+ await manager.apply_pending_migrations()
90
+
91
+ self._initialized = True
92
+
93
+ async def close(self) -> None:
94
+ if self._shared_pool is not None and self._owns_pool:
95
+ await self._shared_pool.close()
96
+
97
+ self._managers.clear()
98
+ self._shared_pool = None
99
+ self._initialized = False
100
+ self._owns_pool = True
101
+
102
+ @property
103
+ def is_initialized(self) -> bool:
104
+ """Check if the database infrastructure is initialized."""
105
+ return self._initialized
106
+
107
+ def get_manager(self, name: str = "server") -> AsyncDatabaseManager:
108
+ if not self._initialized:
109
+ raise RuntimeError(
110
+ "DatabaseInfra is not initialized. " "Call 'await db_infra.initialize()' first."
111
+ )
112
+ manager = self._managers.get(name)
113
+ if manager is None:
114
+ available = ", ".join(sorted(self._managers.keys())) or "(none)"
115
+ raise RuntimeError(
116
+ f"Unknown database manager '{name}'. Available managers: {available}"
117
+ )
118
+ return manager
119
+
120
+
121
+ db_infra = DatabaseInfra()
122
+
123
+
124
+ def get_db_infra(request: Request) -> DatabaseInfra:
125
+ """FastAPI dependency that returns the DatabaseInfra from app state.
126
+
127
+ Works in both standalone and library modes since both set app.state.db.
128
+ """
129
+ return request.app.state.db
@@ -0,0 +1,11 @@
1
+ ---
2
+ id: tracking.bdh-only
3
+ title: Use bdh for tracking
4
+ ---
5
+
6
+ Track all tasks and issues with `bdh`. Do not create markdown TODO lists or external trackers.
7
+
8
+ - Run `bdh :policy` for full command reference and workflow details
9
+ - Use `bdh ready` to find unblocked work
10
+ - Link discovered work with `--deps discovered-from:<parent-id>`
11
+ - Store planning docs in `history/` if needed
@@ -0,0 +1,36 @@
1
+ ---
2
+ id: communication.mail-first
3
+ title: Mail-first communication
4
+ ---
5
+
6
+ Default to mail (`bdh :aweb mail`) for coordination.
7
+
8
+ Use mail for:
9
+ - Status updates and progress reports
10
+ - Review requests and feedback
11
+ - FYI notifications
12
+ - Non-blocking questions
13
+
14
+ ## Sending Messages
15
+
16
+ ```bash
17
+ bdh :aweb mail send <agent> "Status update: completed bd-42"
18
+ bdh :aweb mail send <agent> "Review request: PR #123 ready" --subject "Review needed"
19
+ ```
20
+
21
+ ## Checking Your Inbox
22
+
23
+ ```bash
24
+ bdh :aweb mail list # Show unread messages
25
+ bdh :aweb mail list --all # Include read messages
26
+ ```
27
+
28
+ ## Reading Messages
29
+
30
+ ```bash
31
+ bdh :aweb mail open <sender> # Read unread mail from sender (acknowledges)
32
+ ```
33
+
34
+ Use chat (`bdh :aweb chat`) only when you need a synchronous answer to proceed. See the **communication.chat** invariant for chat details.
35
+
36
+ **Respond immediately to WAITING notifications** — someone is blocked waiting for your reply.
@@ -0,0 +1,60 @@
1
+ ---
2
+ id: communication.chat
3
+ title: Chat for synchronous coordination
4
+ ---
5
+
6
+ Use chat (`bdh :aweb chat`) when you need a **synchronous answer** to proceed. Sessions are persistent and messages are never lost.
7
+
8
+ ## Subcommands
9
+
10
+ | Subcommand | Purpose |
11
+ |------------|---------|
12
+ | `chat send <alias> "msg"` | Send a message (60s default wait) |
13
+ | `chat send <alias> "msg" --start-conversation` | Start a new exchange (5 min default wait) |
14
+ | `chat send <alias> "msg" --leave-conversation` | Send final message and exit |
15
+ | `chat open <alias>` | Read unread messages (marks as read) |
16
+ | `chat pending` | List chat sessions with unread messages |
17
+ | `chat history <alias>` | Show conversation history |
18
+ | `chat hang-on <alias> "msg"` | Request more time before replying |
19
+
20
+ ## Starting vs Continuing Conversations
21
+
22
+ **Starting a new exchange** — initiates and waits for the target to notice:
23
+ ```bash
24
+ bdh :aweb chat send <agent> "Can we discuss the API design?" --start-conversation
25
+ ```
26
+
27
+ **Continuing a conversation** — reply and wait briefly:
28
+ ```bash
29
+ bdh :aweb chat send <agent> "What about the error handling?"
30
+ ```
31
+
32
+ **Signing off** — send final message, exit immediately:
33
+ ```bash
34
+ bdh :aweb chat send <agent> "Got it, thanks!" --leave-conversation
35
+ ```
36
+
37
+ ## Wait Behavior
38
+
39
+ `--start-conversation` waits 5 minutes by default; a plain `send` waits 60 seconds. Override with `--wait N` (seconds).
40
+
41
+ ## Receiving Messages
42
+
43
+ Check for pending conversations:
44
+ ```bash
45
+ bdh :aweb chat pending
46
+ ```
47
+
48
+ Notifications appear on any bdh command:
49
+ ```
50
+ WAITING: agent-p1 is waiting for you
51
+ "Is project_id nullable?"
52
+ → Reply: bdh :aweb chat send agent-p1 "your reply"
53
+ ```
54
+
55
+ **WAITING** means the sender is actively waiting — reply promptly.
56
+
57
+ If you need more time before replying:
58
+ ```bash
59
+ bdh :aweb chat hang-on agent-p1 "Looking into it, give me a few minutes"
60
+ ```
@@ -0,0 +1,17 @@
1
+ ---
2
+ id: identity.no-impersonation
3
+ title: No workspace impersonation
4
+ ---
5
+
6
+ Never run `bdh` from another workspace or worktree.
7
+
8
+ `bdh` derives your identity from the `.beadhub` file in the current worktree. Running `bdh` from another repo or worktree can impersonate that workspace's agent, causing:
9
+
10
+ - Messages sent as the wrong agent
11
+ - Work claimed under the wrong identity
12
+ - Confusion in coordination
13
+
14
+ **Always verify** you're in the correct worktree before running bdh commands:
15
+ ```bash
16
+ bdh :aweb whoami # Check your identity
17
+ ```
@@ -0,0 +1,12 @@
1
+ ---
2
+ id: teamwork.collaborate
3
+ title: Collaborate toward the goal
4
+ ---
5
+
6
+ You are part of a team working toward a shared goal. Optimize for the project outcome, not your individual activity.
7
+
8
+ - Help teammates when they're blocked — respond promptly to messages
9
+ - Pick up work that advances the goal, even if it's not "yours"
10
+ - Escalate blockers early rather than spinning alone
11
+ - Keep changes small and reviewable so others can build on your work
12
+