From 664bf01ceaccf730cb636463cc8587cd1d966192 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Fri, 29 May 2026 14:51:53 -0500 Subject: feat(signal): page-signal CLI wrapper + workflows + cross-project broadcast helper Three coupled additions ship together. claude-templates/bin/page-signal is a bash wrapper around signal-cli send. It defaults to --note-to-self for safety. The wrapper supports --file for attachments, --to <+number> for outbound (explicit per call, no defaults, no batch), --quiet, and --json. Exit codes: 0 sent, 1 signal-cli failure, 2 usage error, 3 signal-cli not installed. claude-templates/.ai/workflows/page-signal.org carries the discrimination rules and safety rails. When desktop notify covers it, don't reach for Signal. Long-running task completion is the canonical case. Outbound to other contacts requires explicit Craig instruction per send. A known-limitation note covers the current notification gap. signal-cli registered on Craig's primary number means messages don't fire notifications until the pending Google Voice registration lands. claude-templates/.ai/workflows/cross-project-broadcast.org and its helper cross-project-broadcast.py fan out a single message file to every AI project's inbox in one operation. Discovery is fingerprint-based: any directory under ~/code, ~/projects, ~/.emacs.d with both .ai/protocols.org and a top-level inbox/ is broadcastable. Senders are auto-excluded. Verified discovery against 23 broadcastable targets. Makefile's install target gains a general bin/ loop. The previous version hardcoded bin/ai. The new version iterates over every executable under claude-templates/bin/ and symlinks each into ~/.local/bin/. install-hooks (existing Claude hook installer) is unchanged. install-githooks (sync-check pre-commit hook setup, added earlier today) is unchanged. The bin/ loop now picks up bin/page-signal automatically. INDEX entries for both new workflows landed under Tools and meta. No bats tests on the new scripts. page-signal was smoke-tested with a live send. The send succeeded. The notification gap is covered by the workflow's known-limitation note. cross-project-broadcast.py was smoke-tested via --list against the live project set. Tests can be added when the broadcast pattern proves out across multiple use cases. --- .ai/scripts/cross-project-broadcast.py | 155 +++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100755 .ai/scripts/cross-project-broadcast.py (limited to '.ai/scripts') diff --git a/.ai/scripts/cross-project-broadcast.py b/.ai/scripts/cross-project-broadcast.py new file mode 100755 index 0000000..2c4c690 --- /dev/null +++ b/.ai/scripts/cross-project-broadcast.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Fan out a message file to every AI project's inbox/. + +Discovers AI projects by fingerprint — any directory under SEARCH_ROOTS +whose .ai/protocols.org exists. Uses the existing inbox-send.py helper to +deliver per-target. + +Usage: + cross-project-broadcast.py --list + cross-project-broadcast.py --file [--exclude ...] [--dry-run] +""" +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +SEARCH_ROOTS = [ + Path.home() / "code", + Path.home() / "projects", + Path.home() / ".emacs.d", +] + + +def is_broadcastable(path: Path) -> bool: + """A project is broadcastable if it has both .ai/ and a top-level inbox/. + + Matches inbox-send.py's fingerprint so the broadcast only targets + projects that can actually receive (inbox-send rejects targets without + inbox/). A project that has .ai/protocols.org but no inbox/ is an AI + project that hasn't been bootstrapped for inbox messaging yet. + """ + return (path / ".ai" / "protocols.org").is_file() and (path / "inbox").is_dir() + + +def discover() -> list[Path]: + """Return every broadcastable AI project, deduplicated and sorted.""" + seen: dict[str, Path] = {} + for root in SEARCH_ROOTS: + if not root.is_dir(): + continue + # The root itself may be a project (~/.emacs.d). + if is_broadcastable(root): + seen.setdefault(root.name, root) + continue + # Otherwise scan one level down. + for sub in sorted(root.iterdir()): + if sub.is_dir() and is_broadcastable(sub): + seen.setdefault(sub.name, sub) + return [seen[name] for name in sorted(seen)] + + +def sender_project() -> str | None: + """Return the AI-project basename of the current working dir, if any.""" + cwd = Path.cwd() + for ancestor in [cwd, *cwd.parents]: + if (ancestor / ".ai" / "protocols.org").is_file(): + return ancestor.name + return None + + +def inbox_send_path() -> Path: + """Locate the inbox-send.py helper in the current project.""" + cwd = Path.cwd() + for ancestor in [cwd, *cwd.parents]: + candidate = ancestor / ".ai" / "scripts" / "inbox-send.py" + if candidate.is_file(): + return candidate + raise SystemExit("cross-project-broadcast: inbox-send.py not found in current project") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Broadcast a message file to every AI project's inbox/.", + ) + parser.add_argument( + "--list", action="store_true", + help="List discovered AI projects and exit (sender-excluded).", + ) + parser.add_argument( + "--file", + help="Path to the broadcast message file.", + ) + parser.add_argument( + "--exclude", action="append", default=[], + help="Project basename to skip. Repeatable.", + ) + parser.add_argument( + "--dry-run", action="store_true", + help="Show what would be sent without invoking inbox-send.", + ) + args = parser.parse_args() + + projects = discover() + sender = sender_project() + excluded = set(args.exclude) + if sender: + excluded.add(sender) + + targets = [p for p in projects if p.name not in excluded] + + if args.list: + print(f"Discovered {len(projects)} AI projects " + f"(sender '{sender or '?'}' excluded, " + f"{len(args.exclude)} explicit excludes):") + for p in projects: + mark = " -" if p.name in excluded else " +" + print(f"{mark} {p.name:30s} {p}") + print(f"\nWould broadcast to {len(targets)} target(s).") + return 0 + + if not args.file: + parser.error("--file is required unless --list is given") + + msg_path = Path(args.file).resolve() + if not msg_path.is_file(): + print(f"cross-project-broadcast: file not found: {msg_path}", file=sys.stderr) + return 2 + + inbox_send = inbox_send_path() + + print(f"Broadcasting {msg_path.name} to {len(targets)} project(s):") + if args.dry_run: + for target in targets: + print(f" dry {target.name}") + return 0 + + sent = 0 + failed = [] + for target in targets: + cmd = ["python3", str(inbox_send), target.name, "--file", str(msg_path)] + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode == 0: + print(f" ok {target.name}") + sent += 1 + else: + err = (res.stderr or res.stdout).strip().splitlines()[-1][:120] + print(f" FAIL {target.name}: {err}") + failed.append((target.name, err)) + + print(f"\nSummary: {sent} sent, {len(failed)} failed, " + f"{len(projects) - len(targets)} excluded.") + + if failed: + print("\nFailures (re-run --file targeting these individually if needed):") + for name, err in failed: + print(f" {name}: {err}") + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) -- cgit v1.2.3