diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-29 14:51:53 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-29 14:51:53 -0500 |
| commit | 664bf01ceaccf730cb636463cc8587cd1d966192 (patch) | |
| tree | e964f6c88d986454c5a2acfc99dfb55964fbba2b /.ai/scripts | |
| parent | c3cf9a592ea6779ad59f0d79577e29777fce49f6 (diff) | |
| download | rulesets-664bf01ceaccf730cb636463cc8587cd1d966192.tar.gz rulesets-664bf01ceaccf730cb636463cc8587cd1d966192.zip | |
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.
Diffstat (limited to '.ai/scripts')
| -rwxr-xr-x | .ai/scripts/cross-project-broadcast.py | 155 |
1 files changed, 155 insertions, 0 deletions
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 <path> [--exclude <name> ...] [--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()) |
