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 +++++++++++++++++++++ .ai/workflows/INDEX.org | 4 + .ai/workflows/cross-project-broadcast.org | 126 +++++++++++++++++ .ai/workflows/page-signal.org | 87 ++++++++++++ Makefile | 34 +++-- .../.ai/scripts/cross-project-broadcast.py | 155 +++++++++++++++++++++ claude-templates/.ai/workflows/INDEX.org | 4 + .../.ai/workflows/cross-project-broadcast.org | 126 +++++++++++++++++ claude-templates/.ai/workflows/page-signal.org | 87 ++++++++++++ claude-templates/bin/page-signal | 126 +++++++++++++++++ 10 files changed, 889 insertions(+), 15 deletions(-) create mode 100755 .ai/scripts/cross-project-broadcast.py create mode 100644 .ai/workflows/cross-project-broadcast.org create mode 100644 .ai/workflows/page-signal.org create mode 100755 claude-templates/.ai/scripts/cross-project-broadcast.py create mode 100644 claude-templates/.ai/workflows/cross-project-broadcast.org create mode 100644 claude-templates/.ai/workflows/page-signal.org create mode 100755 claude-templates/bin/page-signal 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()) diff --git a/.ai/workflows/INDEX.org b/.ai/workflows/INDEX.org index 17c99cb..aa64e2e 100644 --- a/.ai/workflows/INDEX.org +++ b/.ai/workflows/INDEX.org @@ -80,6 +80,10 @@ This index must list every =.org= file in =.ai/workflows/= except this one and e - =process-meeting-transcript.org= — record → transcript → labeled archive. - Triggers: "process the transcript", "process the recording". Auto: new files in =~/sync/recordings/=. +- =page-signal.org= — send Craig a Signal message via =page-signal= when desktop notifications won't reach him. Defaults to note-to-self. Outbound to other contacts requires explicit =--to <+number>= every call. Auto: long-running task completion, cross-device deliverables, operator-attention events. NOT for routine completions or periodic status pings. + - Triggers: "page me on signal", "signal me when X is done", "send a signal note about X" +- =cross-project-broadcast.org= — fan out a single message to every AI project's inbox via the discovery helper =cross-project-broadcast.py= + the existing =inbox-send.py=. Use sparingly for capability announcements and shared rule changes; not for project-specific handoffs. + - Triggers: "broadcast this to every project", "notify every project about X", "fan out this announcement", "let every project know X is available" - =page-me.org= — set a timed notification. - Triggers: anything containing the word "page" used as a verb ("page me", "page me in 10 minutes", "page me at 3pm") - =status-check.org= — proactive long-running-job updates. diff --git a/.ai/workflows/cross-project-broadcast.org b/.ai/workflows/cross-project-broadcast.org new file mode 100644 index 0000000..3a6294c --- /dev/null +++ b/.ai/workflows/cross-project-broadcast.org @@ -0,0 +1,126 @@ +#+TITLE: Cross-Project Broadcast Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-05-29 + +* Overview + +Fan out a single message to every AI project's inbox in one operation. Discovers projects by fingerprint (any directory with =.ai/protocols.org=) and delivers via the existing =inbox-send.py= per-target. The point is announcing a new capability or shared change once, instead of hand-walking 20+ projects. + +* When to Use This Workflow + +User triggers: + +- "broadcast this to every project" +- "notify every project about " +- "fan out this announcement" +- "let every project know X is available" + +Automatic invocation: + +- *New machine-global capability landed.* A new script in =~/.local/bin/=, a new MCP server registered, a new tool installed (e.g. =signal-cli=, =page-signal=). Projects need to know it's available so their agents can use it. +- *Shared rule or protocol change.* When a change to =claude-rules/= or =claude-templates/.ai/= materially affects how every project's agent should behave, broadcast a heads-up so the next session of each project picks it up explicitly rather than discovering it on rsync. +- *Deprecation notice.* When a script, workflow, or rule is going away, give every project a chance to migrate before the removal lands. + +* When NOT to Use This Workflow + +- *Project-specific work.* A handoff intended for one project goes through =inbox-send= directly, not broadcast. +- *Routine status updates.* The session log and todo.org cover routine work. Broadcast is for capability- or rule-level changes. +- *Bulk noise.* Every broadcast adds N inbox files. Use sparingly; ask whether projects actually need to know. + +* The Workflow + +** Phase A — Discover targets + +Run the discovery helper: + +#+begin_src bash +python3 .ai/scripts/cross-project-broadcast.py --list +#+end_src + +The helper scans =~/code/=, =~/projects/=, =~/.emacs.d= for any directory containing =.ai/protocols.org=. Prints the basename and full path of each, in sender-excluded order (the current project never receives its own broadcast). + +** Phase B — Compose the message + +Write the broadcast body to =/tmp/broadcast-.org=. Required structure: + +#+begin_example +,#+TITLE: +,#+DATE: YYYY-MM-DD +,#+SOURCE: + +,* What's new + + + +,* How to use it + + + +,* Why this matters / when to use + + + +,* Action required + + +- =FYI=, no action required (most broadcasts) +- =Update workflow X= to reference the new capability +- =Deprecate workflow Y= by date Z +#+end_example + +The structure is rigid on purpose. Every project's next session has to read 20+ broadcasts as efficiently as possible. The standard headings let the agent scan in seconds. + +** Phase C — Confirm scope with the user + +Surface the discovered project list and the message inline. Ask: + +#+begin_example +Broadcast scope: +- Target projects: (list) +- Message: <2-line summary> +- Action required: + +1. Send to all targets (recommended) +2. Exclude specific projects (name them) +3. Cancel — message stays at /tmp/broadcast-.org +#+end_example + +** Phase D — Fan out + +Run the broadcast helper with the composed message: + +#+begin_src bash +python3 .ai/scripts/cross-project-broadcast.py \ + --file /tmp/broadcast-.org \ + [--exclude project1 --exclude project2 ...] +#+end_src + +The helper iterates over targets, runs =inbox-send.py --file = per target, captures success/failure per project. The =from-= prefix in the resulting filename traces provenance. + +** Phase E — Report + +Summarize the fan-out: + +- Total targets discovered +- Sent successfully (count) +- Failed (list with reason) +- Excluded (list with reason) + +If any failures, surface them — silent failure on a broadcast means some projects never learn about the change. + +** Phase F — Cleanup + +Delete =/tmp/broadcast-.org=. The content lives in each target's inbox now. + +* Common Mistakes + +1. *Broadcasting project-specific work.* Each broadcast costs N inbox files across the fleet. Routine handoffs go through =inbox-send= directly. +2. *Skipping Phase B's structure.* Free-form broadcasts force every recipient to parse them differently. Use the rigid headings. +3. *Sender-includes-itself.* The discovery helper excludes the sender automatically. Don't override it — broadcasting to your own inbox creates a self-reply loop. +4. *Forgetting Phase E.* A broadcast that partially succeeded is the failure mode you'll never notice. Always check the per-target results. +5. *Broadcasting without an "Action required" line.* Recipients need to know whether this is FYI or whether their project has to do something. The line is non-optional. +6. *Using broadcast as a substitute for documentation.* Capability announcements should be paired with a rule or workflow update so the next-next session can rediscover the capability from canonical docs, not from a stale inbox file. + +* Living Document + +If the discovery roots change (a new top-level directory for AI projects), update =cross-project-broadcast.py='s =SEARCH_ROOTS=. If the per-broadcast structure proves too rigid or too loose, tune Phase B. If the recipient projects start complaining about broadcast noise, the rule is "broadcast less," not "structure broadcasts harder." diff --git a/.ai/workflows/page-signal.org b/.ai/workflows/page-signal.org new file mode 100644 index 0000000..a772603 --- /dev/null +++ b/.ai/workflows/page-signal.org @@ -0,0 +1,87 @@ +#+TITLE: Page Signal Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-05-29 + +* Overview + +Send Craig a Signal message via =page-signal= when desktop notifications won't reach him. Signal fires cross-device, so a page arrives on his phone even when he's away from the desk. The wrapper lives at =~/.local/bin/page-signal= (rulesets-installed; symlinked from =claude-templates/bin/page-signal=). + +Defaults to =--note-to-self= for safety. Outbound (paging another contact) requires an explicit =--to <+number>= every call. No defaults, no batch. + +*Known limitation (2026-05-29):* =signal-cli= is currently registered with Craig's primary number, so messages from it appear as "you sending from a linked device" rather than as an incoming message from a third party. Signal's mobile clients do not fire push notifications for these. Pages land in the conversation but don't ring the phone. The fix is to register =signal-cli= with a separate number (Craig's Google Voice number is the pending plan, see todo.org TODO scheduled 2026-05-29). Until that registration lands, this workflow is best used for *desktop-visible* paging (the notify wrapper, status outputs) rather than for reaching Craig cross-device. The wrapper itself works correctly; only the notification routing is awaiting the second-account registration. + +* When to Use This Workflow + +User triggers (rare; this is usually invoked by other workflows or by Claude autonomously per the rules below): + +- "page me on signal" / "signal me when X is done" +- "send a signal note about " + +Automatic invocation: + +- *Long-running task completion* — when a task estimated >10 min finishes successfully or fails, fire =page-signal= in addition to =notify= so Craig sees it on his phone if he's away. +- *Cross-device deliverables* — when generating a file or screenshot Craig will want on his phone (a quick reference, a meeting prep summary, etc.), attach via =page-signal --file=. +- *Operator-attention events* — when an autonomous run needs Craig's eyes and the desktop notify might miss him. + +* When NOT to Use This Workflow + +- *Routine completions.* The desktop =notify success= covers everything that finishes within a normal session. Signal pages should be the exception, not the default. +- *Periodic status pings.* Long-running-process status updates (per =protocols.org='s 5-minute cadence) go to desktop only. Signal-paging every five minutes is spam. +- *Trivial confirmations.* "Commit landed", "test passed", "file saved" don't earn a Signal page. +- *Outbound to other contacts without explicit instruction.* =--to <+number>= is per-message, not a saved address book. Each outbound page needs Craig's explicit instruction naming the recipient. + +* Mechanics + +** Direct invocation + +#+begin_src bash +# Note-to-self (default): +page-signal "Build finished. 12 min, all tests green." + +# With attachment: +page-signal --file /tmp/report.pdf "Daily prep ready." + +# Outbound (explicit recipient): +page-signal --to +15551234567 "Running 10 minutes late." + +# Quiet mode (suppress stdout success line): +page-signal --quiet "Done." + +# JSON output (for scripted callers): +page-signal --json "Done." +#+end_src + +Exit codes: +- =0= — sent successfully +- =1= — signal-cli reported a send failure (network, rate limit, recipient unreachable) +- =2= — usage error (missing arg, invalid =--to= format) +- =3= — =signal-cli= not installed + +** Composition with =notify= + +If the =notify= script gains a =--signal= flag (currently a separate handoff to the dotfiles project), it can fire desktop notification + Signal page in one call. Until then, run both: + +#+begin_src bash +notify success "Build done" "12 min, all green" --persist +page-signal "Build done — 12 min, all green." +#+end_src + +* Safety Rails + +1. *No outbound without explicit instruction per send.* The Signal protocol doesn't distinguish between "Craig asked me to text Alice" and "I decided Alice should hear from me." The latter is never appropriate. Every =--to <+number>= invocation must trace back to a Craig directive that names the recipient. +2. *No saved address book.* Don't memoize contact numbers in scripts or workflows. The explicit =+number= per call is the rate limiter on accidental sends. +3. *Rate limit awareness.* Signal rate-limits high-volume sends. If a session is firing >5 pages in a few minutes, that's a smell — consolidate into one summary page. +4. *Sensitive content.* Signal is end-to-end encrypted to Craig's devices, but the message body is visible on every device he's logged in on (phone, desktop, web). Don't page secrets, credentials, or anything you wouldn't want shoulder-surfed. +5. *Failure semantics.* A page that didn't land is the failure mode you'll never notice. The wrapper returns non-zero on send failure — callers that depend on delivery should check the exit code, not assume. + +* Common Mistakes + +1. *Signal-paging every notify call.* Defeats the purpose. Signal is the exception for cross-device pages. +2. *Using =--to= with a stored number.* Each outbound needs Craig's instruction. No defaults. +3. *Paging during automated periodic checks.* Use desktop notify for periodic status. Signal is for events Craig wants to react to. +4. *Sending the full task body via Signal.* One sentence, not a wall of text. Signal pages are glanceable; long messages buried on a phone get skimmed past. +5. *Ignoring exit code 1.* The page didn't arrive. Either retry or surface the failure to Craig at the next interactive moment. + +* Living Document + +Tune the "automatic invocation" criteria as the cadence shapes up. If certain long-running task classes consistently warrant Signal pages and others don't, fold the pattern into the heuristic. If a =notify --signal= flag lands in the dotfiles, update the composition section. diff --git a/Makefile b/Makefile index abf28c1..01bd1ee 100644 --- a/Makefile +++ b/Makefile @@ -220,24 +220,28 @@ install: ## Symlink skills and rules into ~/.claude/ fi \ fi @echo "" - @echo "ai launcher:" + @echo "bin scripts:" @mkdir -p "$(LOCAL_BIN)" - @chmod +x "$(AI_LAUNCHER)" - @if [ -L "$(LOCAL_BIN)/ai" ]; then \ - target=$$(readlink "$(LOCAL_BIN)/ai"); \ - if [ "$$target" = "$(AI_LAUNCHER)" ]; then \ - echo " skip ai (already linked)"; \ + @for src in $(CURDIR)/claude-templates/bin/*; do \ + [ -f "$$src" ] || continue; \ + name=$$(basename "$$src"); \ + chmod +x "$$src"; \ + if [ -L "$(LOCAL_BIN)/$$name" ]; then \ + target=$$(readlink "$(LOCAL_BIN)/$$name"); \ + if [ "$$target" = "$$src" ]; then \ + echo " skip $$name (already linked)"; \ + else \ + rm "$(LOCAL_BIN)/$$name"; \ + ln -s "$$src" "$(LOCAL_BIN)/$$name"; \ + echo " relink $$name → $$src (was: $$target)"; \ + fi \ + elif [ -e "$(LOCAL_BIN)/$$name" ]; then \ + echo " WARN $$name exists and is not a symlink — skipping"; \ else \ - rm "$(LOCAL_BIN)/ai"; \ - ln -s "$(AI_LAUNCHER)" "$(LOCAL_BIN)/ai"; \ - echo " relink ai → $(AI_LAUNCHER) (was: $$target)"; \ + ln -s "$$src" "$(LOCAL_BIN)/$$name"; \ + echo " link $$name → $(LOCAL_BIN)/$$name"; \ fi \ - elif [ -e "$(LOCAL_BIN)/ai" ]; then \ - echo " WARN ai exists and is not a symlink — skipping"; \ - else \ - ln -s "$(AI_LAUNCHER)" "$(LOCAL_BIN)/ai"; \ - echo " link ai → $(LOCAL_BIN)/ai"; \ - fi + done @echo "" @echo "done" diff --git a/claude-templates/.ai/scripts/cross-project-broadcast.py b/claude-templates/.ai/scripts/cross-project-broadcast.py new file mode 100755 index 0000000..2c4c690 --- /dev/null +++ b/claude-templates/.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()) diff --git a/claude-templates/.ai/workflows/INDEX.org b/claude-templates/.ai/workflows/INDEX.org index 17c99cb..aa64e2e 100644 --- a/claude-templates/.ai/workflows/INDEX.org +++ b/claude-templates/.ai/workflows/INDEX.org @@ -80,6 +80,10 @@ This index must list every =.org= file in =.ai/workflows/= except this one and e - =process-meeting-transcript.org= — record → transcript → labeled archive. - Triggers: "process the transcript", "process the recording". Auto: new files in =~/sync/recordings/=. +- =page-signal.org= — send Craig a Signal message via =page-signal= when desktop notifications won't reach him. Defaults to note-to-self. Outbound to other contacts requires explicit =--to <+number>= every call. Auto: long-running task completion, cross-device deliverables, operator-attention events. NOT for routine completions or periodic status pings. + - Triggers: "page me on signal", "signal me when X is done", "send a signal note about X" +- =cross-project-broadcast.org= — fan out a single message to every AI project's inbox via the discovery helper =cross-project-broadcast.py= + the existing =inbox-send.py=. Use sparingly for capability announcements and shared rule changes; not for project-specific handoffs. + - Triggers: "broadcast this to every project", "notify every project about X", "fan out this announcement", "let every project know X is available" - =page-me.org= — set a timed notification. - Triggers: anything containing the word "page" used as a verb ("page me", "page me in 10 minutes", "page me at 3pm") - =status-check.org= — proactive long-running-job updates. diff --git a/claude-templates/.ai/workflows/cross-project-broadcast.org b/claude-templates/.ai/workflows/cross-project-broadcast.org new file mode 100644 index 0000000..3a6294c --- /dev/null +++ b/claude-templates/.ai/workflows/cross-project-broadcast.org @@ -0,0 +1,126 @@ +#+TITLE: Cross-Project Broadcast Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-05-29 + +* Overview + +Fan out a single message to every AI project's inbox in one operation. Discovers projects by fingerprint (any directory with =.ai/protocols.org=) and delivers via the existing =inbox-send.py= per-target. The point is announcing a new capability or shared change once, instead of hand-walking 20+ projects. + +* When to Use This Workflow + +User triggers: + +- "broadcast this to every project" +- "notify every project about " +- "fan out this announcement" +- "let every project know X is available" + +Automatic invocation: + +- *New machine-global capability landed.* A new script in =~/.local/bin/=, a new MCP server registered, a new tool installed (e.g. =signal-cli=, =page-signal=). Projects need to know it's available so their agents can use it. +- *Shared rule or protocol change.* When a change to =claude-rules/= or =claude-templates/.ai/= materially affects how every project's agent should behave, broadcast a heads-up so the next session of each project picks it up explicitly rather than discovering it on rsync. +- *Deprecation notice.* When a script, workflow, or rule is going away, give every project a chance to migrate before the removal lands. + +* When NOT to Use This Workflow + +- *Project-specific work.* A handoff intended for one project goes through =inbox-send= directly, not broadcast. +- *Routine status updates.* The session log and todo.org cover routine work. Broadcast is for capability- or rule-level changes. +- *Bulk noise.* Every broadcast adds N inbox files. Use sparingly; ask whether projects actually need to know. + +* The Workflow + +** Phase A — Discover targets + +Run the discovery helper: + +#+begin_src bash +python3 .ai/scripts/cross-project-broadcast.py --list +#+end_src + +The helper scans =~/code/=, =~/projects/=, =~/.emacs.d= for any directory containing =.ai/protocols.org=. Prints the basename and full path of each, in sender-excluded order (the current project never receives its own broadcast). + +** Phase B — Compose the message + +Write the broadcast body to =/tmp/broadcast-.org=. Required structure: + +#+begin_example +,#+TITLE: +,#+DATE: YYYY-MM-DD +,#+SOURCE: + +,* What's new + + + +,* How to use it + + + +,* Why this matters / when to use + + + +,* Action required + + +- =FYI=, no action required (most broadcasts) +- =Update workflow X= to reference the new capability +- =Deprecate workflow Y= by date Z +#+end_example + +The structure is rigid on purpose. Every project's next session has to read 20+ broadcasts as efficiently as possible. The standard headings let the agent scan in seconds. + +** Phase C — Confirm scope with the user + +Surface the discovered project list and the message inline. Ask: + +#+begin_example +Broadcast scope: +- Target projects: (list) +- Message: <2-line summary> +- Action required: + +1. Send to all targets (recommended) +2. Exclude specific projects (name them) +3. Cancel — message stays at /tmp/broadcast-.org +#+end_example + +** Phase D — Fan out + +Run the broadcast helper with the composed message: + +#+begin_src bash +python3 .ai/scripts/cross-project-broadcast.py \ + --file /tmp/broadcast-.org \ + [--exclude project1 --exclude project2 ...] +#+end_src + +The helper iterates over targets, runs =inbox-send.py --file = per target, captures success/failure per project. The =from-= prefix in the resulting filename traces provenance. + +** Phase E — Report + +Summarize the fan-out: + +- Total targets discovered +- Sent successfully (count) +- Failed (list with reason) +- Excluded (list with reason) + +If any failures, surface them — silent failure on a broadcast means some projects never learn about the change. + +** Phase F — Cleanup + +Delete =/tmp/broadcast-.org=. The content lives in each target's inbox now. + +* Common Mistakes + +1. *Broadcasting project-specific work.* Each broadcast costs N inbox files across the fleet. Routine handoffs go through =inbox-send= directly. +2. *Skipping Phase B's structure.* Free-form broadcasts force every recipient to parse them differently. Use the rigid headings. +3. *Sender-includes-itself.* The discovery helper excludes the sender automatically. Don't override it — broadcasting to your own inbox creates a self-reply loop. +4. *Forgetting Phase E.* A broadcast that partially succeeded is the failure mode you'll never notice. Always check the per-target results. +5. *Broadcasting without an "Action required" line.* Recipients need to know whether this is FYI or whether their project has to do something. The line is non-optional. +6. *Using broadcast as a substitute for documentation.* Capability announcements should be paired with a rule or workflow update so the next-next session can rediscover the capability from canonical docs, not from a stale inbox file. + +* Living Document + +If the discovery roots change (a new top-level directory for AI projects), update =cross-project-broadcast.py='s =SEARCH_ROOTS=. If the per-broadcast structure proves too rigid or too loose, tune Phase B. If the recipient projects start complaining about broadcast noise, the rule is "broadcast less," not "structure broadcasts harder." diff --git a/claude-templates/.ai/workflows/page-signal.org b/claude-templates/.ai/workflows/page-signal.org new file mode 100644 index 0000000..a772603 --- /dev/null +++ b/claude-templates/.ai/workflows/page-signal.org @@ -0,0 +1,87 @@ +#+TITLE: Page Signal Workflow +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-05-29 + +* Overview + +Send Craig a Signal message via =page-signal= when desktop notifications won't reach him. Signal fires cross-device, so a page arrives on his phone even when he's away from the desk. The wrapper lives at =~/.local/bin/page-signal= (rulesets-installed; symlinked from =claude-templates/bin/page-signal=). + +Defaults to =--note-to-self= for safety. Outbound (paging another contact) requires an explicit =--to <+number>= every call. No defaults, no batch. + +*Known limitation (2026-05-29):* =signal-cli= is currently registered with Craig's primary number, so messages from it appear as "you sending from a linked device" rather than as an incoming message from a third party. Signal's mobile clients do not fire push notifications for these. Pages land in the conversation but don't ring the phone. The fix is to register =signal-cli= with a separate number (Craig's Google Voice number is the pending plan, see todo.org TODO scheduled 2026-05-29). Until that registration lands, this workflow is best used for *desktop-visible* paging (the notify wrapper, status outputs) rather than for reaching Craig cross-device. The wrapper itself works correctly; only the notification routing is awaiting the second-account registration. + +* When to Use This Workflow + +User triggers (rare; this is usually invoked by other workflows or by Claude autonomously per the rules below): + +- "page me on signal" / "signal me when X is done" +- "send a signal note about " + +Automatic invocation: + +- *Long-running task completion* — when a task estimated >10 min finishes successfully or fails, fire =page-signal= in addition to =notify= so Craig sees it on his phone if he's away. +- *Cross-device deliverables* — when generating a file or screenshot Craig will want on his phone (a quick reference, a meeting prep summary, etc.), attach via =page-signal --file=. +- *Operator-attention events* — when an autonomous run needs Craig's eyes and the desktop notify might miss him. + +* When NOT to Use This Workflow + +- *Routine completions.* The desktop =notify success= covers everything that finishes within a normal session. Signal pages should be the exception, not the default. +- *Periodic status pings.* Long-running-process status updates (per =protocols.org='s 5-minute cadence) go to desktop only. Signal-paging every five minutes is spam. +- *Trivial confirmations.* "Commit landed", "test passed", "file saved" don't earn a Signal page. +- *Outbound to other contacts without explicit instruction.* =--to <+number>= is per-message, not a saved address book. Each outbound page needs Craig's explicit instruction naming the recipient. + +* Mechanics + +** Direct invocation + +#+begin_src bash +# Note-to-self (default): +page-signal "Build finished. 12 min, all tests green." + +# With attachment: +page-signal --file /tmp/report.pdf "Daily prep ready." + +# Outbound (explicit recipient): +page-signal --to +15551234567 "Running 10 minutes late." + +# Quiet mode (suppress stdout success line): +page-signal --quiet "Done." + +# JSON output (for scripted callers): +page-signal --json "Done." +#+end_src + +Exit codes: +- =0= — sent successfully +- =1= — signal-cli reported a send failure (network, rate limit, recipient unreachable) +- =2= — usage error (missing arg, invalid =--to= format) +- =3= — =signal-cli= not installed + +** Composition with =notify= + +If the =notify= script gains a =--signal= flag (currently a separate handoff to the dotfiles project), it can fire desktop notification + Signal page in one call. Until then, run both: + +#+begin_src bash +notify success "Build done" "12 min, all green" --persist +page-signal "Build done — 12 min, all green." +#+end_src + +* Safety Rails + +1. *No outbound without explicit instruction per send.* The Signal protocol doesn't distinguish between "Craig asked me to text Alice" and "I decided Alice should hear from me." The latter is never appropriate. Every =--to <+number>= invocation must trace back to a Craig directive that names the recipient. +2. *No saved address book.* Don't memoize contact numbers in scripts or workflows. The explicit =+number= per call is the rate limiter on accidental sends. +3. *Rate limit awareness.* Signal rate-limits high-volume sends. If a session is firing >5 pages in a few minutes, that's a smell — consolidate into one summary page. +4. *Sensitive content.* Signal is end-to-end encrypted to Craig's devices, but the message body is visible on every device he's logged in on (phone, desktop, web). Don't page secrets, credentials, or anything you wouldn't want shoulder-surfed. +5. *Failure semantics.* A page that didn't land is the failure mode you'll never notice. The wrapper returns non-zero on send failure — callers that depend on delivery should check the exit code, not assume. + +* Common Mistakes + +1. *Signal-paging every notify call.* Defeats the purpose. Signal is the exception for cross-device pages. +2. *Using =--to= with a stored number.* Each outbound needs Craig's instruction. No defaults. +3. *Paging during automated periodic checks.* Use desktop notify for periodic status. Signal is for events Craig wants to react to. +4. *Sending the full task body via Signal.* One sentence, not a wall of text. Signal pages are glanceable; long messages buried on a phone get skimmed past. +5. *Ignoring exit code 1.* The page didn't arrive. Either retry or surface the failure to Craig at the next interactive moment. + +* Living Document + +Tune the "automatic invocation" criteria as the cadence shapes up. If certain long-running task classes consistently warrant Signal pages and others don't, fold the pattern into the heuristic. If a =notify --signal= flag lands in the dotfiles, update the composition section. diff --git a/claude-templates/bin/page-signal b/claude-templates/bin/page-signal new file mode 100755 index 0000000..5a87c67 --- /dev/null +++ b/claude-templates/bin/page-signal @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# +# page-signal — wrap signal-cli send for paging Craig via Signal. +# +# Defaults to --note-to-self for safety. Outbound (to another contact) +# requires an explicit --to <+number> on every call. No defaults, no batch. +# +# Usage: +# page-signal "message" # note-to-self +# page-signal --file path "message" # with attachment +# page-signal --to +15551234567 "msg" # outbound (explicit recipient) +# page-signal --quiet "message" # suppress success output +# page-signal --json "message" # structured output (timestamp + status) +# echo "msg" | page-signal # message from stdin (no positional) +# +# Requires signal-cli installed and registered with an account. +# +# Use this when desktop notifications won't reach the user — page-signal +# fires cross-device. For routine completions and periodic status pings, +# stick with `notify`; Signal is for things that need attention while away +# from the desk. + +set -euo pipefail + +quiet=0 +json=0 +file= +recipient_args=(--note-to-self) +message= + +usage() { + sed -n '3,21p' "$0" | sed 's/^# \{0,1\}//' +} + +while [ "$#" -gt 0 ]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --quiet) + quiet=1 + shift + ;; + --json) + json=1 + shift + ;; + --file) + [ "$#" -ge 2 ] || { echo "page-signal: --file needs a path" >&2; exit 2; } + file="$2" + [ -f "$file" ] || { echo "page-signal: file not found: $file" >&2; exit 2; } + shift 2 + ;; + --to) + [ "$#" -ge 2 ] || { echo "page-signal: --to needs a +E.164 number" >&2; exit 2; } + case "$2" in + +[0-9]*) ;; + *) echo "page-signal: --to expects +E.164 (e.g. +15551234567)" >&2; exit 2 ;; + esac + recipient_args=("$2") + shift 2 + ;; + --) + shift + break + ;; + -*) + echo "page-signal: unknown flag: $1 (use --help)" >&2 + exit 2 + ;; + *) + message="$1" + shift + ;; + esac +done + +if ! command -v signal-cli >/dev/null 2>&1; then + echo "page-signal: signal-cli not found in PATH" >&2 + exit 3 +fi + +# Message: from positional arg, or from stdin if none given. +if [ -z "$message" ]; then + if [ -t 0 ]; then + echo "page-signal: no message (pass as arg or pipe via stdin)" >&2 + exit 2 + fi + message="$(cat)" +fi + +# Build the send command. +cmd=(signal-cli send -m "$message") +if [ -n "$file" ]; then + cmd+=(-a "$file") +fi +cmd+=("${recipient_args[@]}") + +# Send. Capture stderr so we can surface clean failure messages. +timestamp="$(date '+%Y-%m-%dT%H:%M:%S%z')" +if err="$("${cmd[@]}" 2>&1 >/dev/null)"; then + status=ok +else + status=fail +fi + +if [ "$json" -eq 1 ]; then + printf '{"status":"%s","timestamp":"%s","recipient":"%s"' \ + "$status" "$timestamp" "${recipient_args[0]}" + if [ -n "$file" ]; then + printf ',"attachment":"%s"' "$file" + fi + if [ "$status" = "fail" ]; then + printf ',"error":"%s"' "$(echo "$err" | head -1 | sed 's/"/\\"/g')" + fi + printf '}\n' +elif [ "$quiet" -eq 0 ]; then + if [ "$status" = "ok" ]; then + echo "page-signal: sent to ${recipient_args[*]} at $timestamp" + else + echo "page-signal: FAIL — $err" >&2 + fi +fi + +[ "$status" = "ok" ] -- cgit v1.2.3