agentleFS
AdityaRonAdityaRon/claude-code-harness/skills/comms-hub/SKILL.md

comms-hub

Skill14 starsChanged 14 days agoMITView on GitHub

The comms session's recurring tick, and how every other session sends Slack. Use in the one long-lived comms session on each tick (GitHub queue, Slack replies, routing events to peer sessions, gating and sending their drafts, resolving answered-and-approved threads on my PRs), and in any other session about to send, answer or schedule a Slack message, which hands the draft to the comms session instead.

The file

Show its rules and text

We split this skill into the rules it gives an agent

14 rules. About 0 are common, 5 are distinctive, and 9 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈5

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 9

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  • could be automatic: a setting could enforce it
  1. Distinctive:

    Do not send, schedule or edit a Slack message yourself.

    line 17
  2. Very specific:

    Start the comms session with the command PY B/hub.py launch prints (claude --settings '…'): a 600k-token compact window and a 2-minute MCP …

    line 62
  3. Very specific:

    Keep the machine awake through active hours: one that slept ran no tick until 09:55, so nothing re-gated the messages scheduled for 08:30, …

    line 87
  4. Distinctive:

    Leave a key empty when its tool is missing, and tell the user: doctor fails on it, and the tick cannot run without it.

    line 131
  5. Very specific:

    Before anything, PY B/hub.py window.

    line 143
  6. Very specific:

    When it prints scan: full, run CronList too, then PY B/hub.py trigger --live "<the tick's expression>": a MISMATCH, RENEW, ZONE or NONE …

    line 147
  7. Very specific:

    Fail fast: If the tick's first call out (the scan, or the first Slack read) fails, times out, or gets no permission verdict, run PY …

    line 154
  8. Very specific:

    Route: every event: a review, a comment, a Slack reply.

    line 313
  9. Very specific:

    Verify every claim against its source now: the PR's state, head, checks and review bodies (gh pr view), the ticket, the page and its …

    line 338
  10. Very specific:

    Write it to <job tmp>/draft-<n>.md and run PY B/check_draft.py <file> --reply <ts> (or --top-level), with --asks <github login> for whoever …

    line 348
  11. Very specific:

    Add reason: <why, in words> to the header, and needs: user when it waits for the user's own go: the panel shows those under Needs you, the …

    line 388
  12. Distinctive:

    Never approve a PR. Never merge someone else's PR.

    line 475
  13. Distinctive:

    Never cite a session-local task id in shared text.

    line 485
  14. Distinctive:

    No secrets, tokens or customer identifiers in any message or file.

    line 486

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

comms-hub: one session speaks on Slack for all of them

One long-lived session, the comms session, runs this tick on a recurring trigger. It is the only session that sends Slack messages. Every other session (a peer) drafts the message and its target and hands both over with SendMessage; the comms session checks every claim, gates the draft and sends it. It never approves a PR and never merges someone else's: approval is the human's.

If you are a peer

Do not send, schedule or edit a Slack message yourself. Send the comms session (find it with ListAgents; ask the user if none is clearly it) one message for all the drafts you hold:

comms-hub draft
to: <channel id> reply <thread ts>      (or: to: <channel id> top-level)
from: <your session's name, as ListAgents lists it>
kind: reply | react
claims:
- <each fact the message states> | <its source: PR URL, ticket key, page URL and version>
user-approved: <the user's words, quoted> | no
fold-in: expected | none
---
<the message, exactly as it should go out; nothing for react>

kind: react acknowledges a request GitHub already answers, such as a review request your posted review covers: no message, and to: names the request's ts as for a reply. The review on GitHub is the answer; Slack carries only what GitHub can't (a decision, merge order, a hold, a question not on the PR, a review not posted yet). fold-in: expected means more is coming for the same thread (a CI run, an answer you are waiting on). The comms session replies with the sent ts, the scheduled id, the reaction, or why it held the draft. Once it sends, you own that thread's replies for two weeks: the comms session routes each one back to you.

Setup, once per machine

Needs gh signed in as you, Python 3.10+ with a TOML reader, jq, and a Slack MCP server registered with Claude Code under a name containing slack (the Slack connector from /mcp, or any server added with claude mcp add) whose tools read a channel, read a thread, search, send and schedule a message. Nothing here depends on which server it is: step 0 records the tool names.

  1. bash install.sh from the clone puts this skill in ~/.claude/skills/comms-hub/.
  2. PY B/hub.py init writes ~/.config/comms-hub/config.toml from the example with your GitHub login, that login as the one owner searched, and this machine's time zone filled in; it refuses to touch an existing file. Fill in each CHANGE-ME it lists (the Slack ids; the example says where Slack shows them) and add your org to github.owners. That file holds the ids and names a public repo cannot, and never goes in a repo. State goes to ~/.local/state/comms-hub/ unless state_dir says otherwise.
  3. From the clone: bash doctor.sh --skill comms-hub. Every line but the session preflight can read ok before the comms session exists.
  4. Start the comms session with the command PY B/hub.py launch prints (claude --settings '…'): a 600k-token compact window and a 2-minute MCP timeout for this session only. A compaction leaves the system prompt, tools, rules and memory behind: 77-94k tokens on one machine, so a 150k window compacted ten times a day. PY B/hub.py config prints what the latest compaction left and warns when the window leaves under 100k to work in; launch --check fails a session started with any other window, so restart one started before the change. Exported in the shell instead, the window loses to the one ~/.claude/settings.json sets. The command also loads the panel (panel/, --plugin-dir): a pane with what needs the user, where their PRs stand (per repo, from the last scan, with its age), what goes out and when, what is with other sessions, what is held, reviewed, approved and merged over the last five active days, and the last two log days' events with a dot on each since the user last looked, from PY B/hub.py panel as each turn ends and each minute while it is drawn, so it costs no tokens and its ages stay true while the session idles (/comms opens it again and counts as a look). Its foot gives the next tick with how late ticks have started lately, and the last scan with the ticks due since that did not run. A +k more button opens its section alone, and fewer in its place closes it; a look closes it too. Expanded view (e with the pane focused, or the expand button) shows every row and each message whole, with compact at the top and at the end. The phone app docks no pane: there /comms answers with the same state as text, and while a phone is attached the counts ride the status line. Keep the machine awake through active hours: one that slept ran no tick until 09:55, so nothing re-gated the messages scheduled for 08:30, and it woke to a lost login and a DNS failure. On macOS, on power: sudo pmset -c sleep 0. caffeinate -is in front of a command keeps the machine awake only while that command runs, so it serves a session launched in the foreground; a background launch returns at once, so use caffeinate -is -w <the session's pid> there. Then step 0 below, then the trigger: /loop with the cron interval PY B/hub.py cron prints (from [active_hours]: weekdays, 08:07 to 17:37, by default) and the prompt comms-hub tick, then PY B/hub.py trigger --created "<that interval>". Never a self-paced /loop: a wakeup cannot sleep past an hour, so it would tick all weekend. Then doctor again.

In the commands here and below, PY is the interpreter that bash ~/.claude/skills/comms-hub/bin/python.sh prints and B is ~/.claude/skills/comms-hub/bin. Write both out in full in every command (/usr/bin/python3.12 ~/.claude/skills/comms-hub/bin/queue.py scan): shell variables do not survive between calls. Dumps, listings and drafts go in the directory PY B/hub.py config prints as tmp: (the session's job tmp dir when Claude Code set CLAUDE_JOB_DIR, else under the state dir), never /tmp, which every job shares. Each script's --help gives its flags and exit codes.

Step 0: preflight

At session start, and when doctor calls the record old. Load each tool with ToolSearch: the Slack MCP's read channel, read thread, search, send message and schedule message tools, and its add-reaction tool if it has one; ListAgents; SendMessage; CronList, for the tick's trigger; optionally PushNotification and a Jira or Confluence tool. Then write <state dir>/preflight.json with the Write tool (PY B/hub.py config prints the state dir, the tmp dir, the Slack ids and the routes):

{"checked_at": "2026-10-21T17:00:00Z",
 "slack_read_channel": "<tool name>", "slack_read_thread": "<tool name>",
 "slack_search": "<tool name>", "slack_send": "<tool name>", "slack_schedule": "<tool name>",
 "list_agents": "ListAgents", "send_message": "SendMessage",
 "trigger": "<the tick's cron expression, from CronList>", "tickets": "<tool name, or empty>",
 "slack_delete_scheduled": "<tool name, or empty>", "slack_react": "<tool name, or empty>",
 "push_notification": "<tool name, or empty>"}

Leave a key empty when its tool is missing, and tell the user: doctor fails on it, and the tick cannot run without it. Then PY B/hub.py launch --check: a failure means this session was not started with hub.py launch's command, so tell the user to restart it with that command. slack_delete_scheduled, slack_react, push_notification and tickets are optional: without the first, a scheduled message whose claims moved can only be reported, not withdrawn; without slack_react, a draft that should go as a reaction is held, and the user told; without push_notification, the report alone carries the reviews to approve.

The tick

Before anything, PY B/hub.py window. Exit 1 means outside [active_hours]: end the tick there, with no log line and no report, since nobody can act on what it finds (the trigger's jitter can fire one a few minutes after close). A peer's draft that arrives then is still handled when it arrives (step 4). When it prints scan: full, run CronList too, then PY B/hub.py trigger --live "<the tick's expression>": a MISMATCH, RENEW, ZONE or NONE line says what to delete and create (Claude Code deletes a recurring task 7 days after it was created, so without RENEW the loop dies weekly). With scan: quick, step 2 reads the DM and runs the mention searches; the watched channels wait for a full scan.

Fail fast. If the tick's first call out (the scan, or the first Slack read) fails, times out, or gets no permission verdict, run PY B/hub.py log --kind tick "ended: <why>" and end the tick. Never retry inside the tick: the next fire is the retry, and every call re-reads this session's whole context.

  1. A scan still running from an earlier tick is killed by the next queue.py scan, which says so. A scan that slept with the laptop ends BLOCKED on its wall-clock watchdog: report it, do not retry in a loop.
  2. GitHub: PY B/queue.py scan. Read INBOUND, REVIEWED, WATCHED, AWAITING, OUTBOUND, ROUTE, DRIFT and UNKNOWN, and the header's search-quota and my-open-xcheck. If it prints BLOCKED, the queue is unknown, not empty: say so, with the reason, and never present the sections as complete.

    • REVIEWED is anyone's open PR whose head moved since my last review: a re-look for step 1c, with the compare range. Four PRs once got commits after my reviews and only the closing report noticed. A re-look read with nothing to post: PY B/hub.py relooked owner/repo#N <head> "<why>", the head the compare range ends with, all 40 characters. That head then counts as reviewed (two PRs stayed flagged "new commits" after re-looks done by hand): the todo closes, the PR leaves REVIEWED and the panel's flags, and its review row under Needs you moves to that head, still waiting on the user's approval. A review posted at the head any other way moves the row at the next scan.
    • ROUTE is each item on my PRs that asks something: a [consider], [question] or [blocking] tag, a question, or changes requested, an approval's body and inline threads included (an approval carrying a [consider] was once logged as status and routed nothing until the user asked twice). A bare approval or "LGTM" is not in it. Route each at step 3.
    • The scan logs the tick's GitHub work before you start it, a todo row per ROUTE item and per review or re-look (logged:), so a compaction mid-tick resumes from disk: in-flight: lists what an earlier tick left, each with its id, what it is and its link. Finish each. The scan closes a todo once GitHub shows it done.
    • morning-unchecked: above 0 names scheduled messages that posted with no re-gate since the window opened (the machine slept): re-check each claim now and tell the user what moved. The scan logged each under Needs you.
    • missed-ticks: above 0 names scheduled ticks since the last scan that left no scan or tick row: the machine slept, or a fire failed before this session could act (three "Not logged in" fires once logged nothing, and the panel read them as quiet). The scan logged them; say so in the report.
    • prs: says whether the panel's PR file was written; a BLOCKED scan keeps the last one, which the panel shows with its age.
    • full-scan: recorded follows a full scan that did not end BLOCKED, so the next tick is quick; the scan records it, as a session that skipped window --full-done scanned in full on every tick.
    • parked: names the PRs the user parked. When the user asks to drop a PR from the panel: PY B/hub.py park owner/repo#N "<why>". It leaves the panel and REVIEWED, WATCHED, AWAITING and DRIFT from the next scan on; INBOUND, OUTBOUND and ROUTE still show it, since each asks something new. PY B/hub.py unpark owner/repo#N brings it back, and PY B/hub.py park lists them. Never filter the PR file after a scan: it flashes back. 1b. PY B/resolve_threads.py (a dry run); read every RESOLVE and SKIP. Then PY B/resolve_threads.py --apply. It re-reads each thread, prints what GitHub reports, and logs what was resolved. 1c. Reviews, for each INBOUND PR and each REVIEWED re-look: first PY B/hub.py window --review. Exit 1 means the window closes before a review could finish with someone there to read it, and a subagent left running while the laptop sleeps re-writes its whole cache on every wake: log review of <PR> waits for the next window and spawn nothing. The PR stays in its section, so a later tick reviews it. Otherwise PY B/pr_transcript.py owner/repo#N -o <job tmp>/pr-N.md (it prints head: and review-cap:), then spawn a reviewer with the Agent tool: the pr-review skill, the transcript, the diff, and the ask to write <job tmp>/review-N.json and hand back at most 5 lines, since the findings stay in the file, not in this session's context:
    {"head": "<the head: line, all 40 characters>", "body": "<findings only, no verdict>",
     "comments": [{"path": "src/a.py", "line": 3, "side": "RIGHT", "body": "<one finding>"}],
     "findings": [{"kind": "nit"}, {"kind": "question"}], "escalate": "<why the user must read it; leave out if nothing>"}

    One findings entry per finding, its kind nit, consider, question or blocking. The body and each comment at most the review-cap: characters (3 of 9 bodies once ran over and were trimmed by hand), leaving out what another reviewer already raised (the transcript has every review and thread). For an INBOUND PR the diff is gh pr diff N --repo owner/repo. For a re-look it is the REVIEWED line's range, gh api repos/owner/repo/compare/<my sha>...<head>, read against my own open threads in the transcript: each fixed, not fixed, or a new issue. A re-look with every thread fixed and nothing new has no findings; with nothing to post, record it with hub.py relooked (REVIEWED above). A 404 means a force-push dropped my sha: review the whole diff instead. Then PY B/review_lint.py --post <job tmp>/review-N.json --pr owner/repo#N. It lints the body and each comment, re-reads the head and the open state, posts through the reviews API with the inline comments, and logs the review under Needs you with its verdict (logged <id>: reviewed <verdict>): approvable when every finding is a nit or a consider and nothing is escalated, else needs your read. It closes the review's todo. Exit 1 is a refusal, and the output says whether the file or the PR is the problem: fix the file and run it again, but a moved head means review again, and a closed or draft PR means no review. Exit 3: nothing posted or logged, so the review is still to do. Never gh pr review by hand: it drops the inline comments, and a lint chained with ; once let a refused body go out. COMMENT only: this session never approves or requests changes, whatever the findings say.

  3. Slack:
    • The primary DM: read its first page in full, without oldest. Write <job tmp>/dm.txt with the Write tool, one line per top-level message on the page: its ts, then N replies where the read shows a reply count (1761000100.000200 4 replies), and the page's next_cursor: <value> line when it has one. Not the read itself: about 100 KB a channel to write out, so ticks skipped this step and the snapshots went stale. Run PY B/slack_listing.py <job tmp>/dm.txt -o <job tmp>/dm.tsv, then PY B/slack_diff.py <job tmp>/dm.tsv --channel <primary_dm>. Read every NEW and MOVED thread in full. Only then PY B/slack_diff.py <job tmp>/dm.tsv --channel <primary_dm> --commit --read <every NEW and MOVED ts>. The first run takes --commit --baseline instead; say nothing before it was checked. UNREADABLE, UNTRUSTED or STALE means the read failed: no commit, say so. The listing refuses a page with no reply count on any line, which is what a short (concise) read gives (--no-threads once you have read that no message on it has a thread), and a page read with oldest; read again in full. The diff refuses a listing older than 15 minutes: last tick's page diffs as nothing new. Several pages of one read go to one listing (DUMP...).
    • With scan: full only, each watched channel: the same as the DM, with its own page file, listing and --channel <id>: the first page in full, never with oldest (with it, Slack returns the oldest messages first, and a DM once looked quiet with four unread). Read in full every NEW and MOVED thread, not only those that name the user: a reply count that moved is a reply someone may be waiting on. A top-level post that names nobody is an event for the routing entry whose channels holds the channel (step 3).
    • Mentions, on every tick, quick ones too: three searches, not one, each sorted by time: the three query lines PY B/hub.py queries prints (<@my_user_id>, to:<@my_user_id> and with:<@my_user_id> is:thread, each after: two days back, or the day before the reach when that is earlier). Each of the last two once caught a reply the others missed, one under a week-old thread. A quick tick that read only the DM once missed six replies, none naming the user, in two threads the hub had posted in, which the with: search found, and only a search finds a reply under a DM thread too old for the DM's first page. Send each query line whole as the search's query text (the Slack MCP's natural_language_query), never in a filters field: a bare <@my_user_id> sent as a filter ran an empty search, which listed the workspace's newest posts, alerts and webhooks, none naming the user. Rows prove the search returned something, not that it ran as asked: every hit of the bare search must hold <@my_user_id>, which shows the query went through the query field, and the other two go the same way. A hit without it means mentions are unknown. The result's header proves nothing: one connector leaves it empty (Search Results for:) on every search, the right ones too, so a check on it failed every tick. Use the date filter: an epoch filter once returned 0, and an empty result is ambiguous. An empty search counts as quiet only when the same query from PY B/hub.py queries --control (a week further back) returns rows (for the bare search, rows holding <@my_user_id>): a misfired control returns rows too. Ask the control for the smallest page, since one such row proves it. Empty too, the search is not working, so say mentions are unknown, never quiet. Then check each search reached far enough: one page of the with: search once held only the last hour, and nothing said the rest was unread. The reach: line queries prints is where the last searches that all got back started, less 5 minutes. Read each search's pages, newest first, until its oldest hit is at or before the reach, or it has no next page. Then run the then: line with each search's oldest hit read (its ts or permalink), in the printed order, or end for one that ran out: the reach moves up for the next tick only when every search got back to it. TRUNCATED means one stopped short: read its next page, and if there is none to read, log --kind blocked --needs-user "mention search truncated" and say mentions are truncated, never quiet. A tick that ends before this records nothing, so the next tick searches the gap too.
  4. Route every event: a review, a comment, a Slack reply. For a Slack reply, first PY B/hub.py routed <its permalink>: an owner: line names the session that drafted the hub's last message in that thread, and that session owns the replies (replies in threads no routing entry covers were once routed from memory of who drafted what). Otherwise the owner is the routing entry (PY B/hub.py config prints them) whose repos holds the PR's repo, whose tickets holds the ticket key's prefix, or whose channels holds the Slack channel of a post that names nobody. Run ListAgents now, at routing time, and confirm the owner is listed. Before messaging an item, check the owner is not already on it: PY B/hub.py routed <link> prints what the last seven days' log routed, held or asked about it (with two, asks answered days before were routed again). A Slack thread matches as a permalink, <channel> <ts> or a bare ts, whichever form the log holds (asked by permalink, it once printed nothing for a thread it had routed and sent into), and ListAgents shows what each session is on; an item routed with nothing new since is not routed again. For a review, read the whole conversation, not the summary: the review's inline threads too (an empty-bodied approval once carried three inline comments). Send one SendMessage per peer covering all of its items, opening with the count ("3 items"), each with its link, what changed, and what is asked of it. No entry, or the owner is not listed: ~/.claude/session-route.sh --pr N --repo owner/name may name the session that raised a PR (single-author repos only); otherwise ask the user. Never act on a PR another session owns.
  5. Drafts. For each one, all in the turn that sends it:
    • Verify every claim against its source now: the PR's state, head, checks and review bodies (gh pr view), the ticket, the page and its version. A claim that no longer holds goes back to the peer with what changed; never edit a fact in.
    • Re-read the target thread in full. A reply since the peer drafted goes back to the peer too.
    • Merge the drafts for one thread into one message, and gate the merge. A reply that answers several messages threads under the newest of them: pass every parent ts as --answers and the newest as --reply (six of seven answers once landed in the wrong thread).
    • Write it to <job tmp>/draft-<n>.md and run PY B/check_draft.py <file> --reply <ts> (or --top-level), with --asks <github login> for whoever the draft asks to review when the config's slack.github_logins does not map their <@id>. Only PASS sends. FAIL: fix the shape (an unlinked PR, ticket or commit, a dash, a >; a ticket is a key in a project slack.link_ticket_projects lists, or without that key one some routing entry's tickets names, and a hex string that is no commit goes in backticks) or send it back; a line that calls a merged PR awaiting, or asks a reviewer for a review they gave at the current head, goes back to the peer (a review ask once went out minutes after the approval). BLOCKED: a linked PR's live state is unknown, so hold it.
    • send: react <emoji> on <ts>, with a FAIL: every line restates your own review, which GitHub already shows (a reviewer asked for approvals to stay out of Slack). Never reply: add that reaction to each ts with the slack_react tool, in quiet hours too, since a reaction is not a message; log it (PY B/hub.py log --kind reacted --ref <PR link> --target <channel id> "review on GitHub"), and tell the peer. No slack_react tool: hold the draft and tell the user. A top-level draft has no ts: find the message that asked for each review (search Slack for the PR's link) and react there; with none in Slack, drop it, since GitHub already told the author. A kind: react draft skips the gate: confirm its claim (your review is on the PR at its head, or the PR merged after it), then react the same way. WARN names lines that restate inside a message that says more, or a thread reply over 60 words: it may send, and reads better cut to what GitHub can't say.
    • PY B/check_draft.py <file> --assert-fresh, then send the message with the Slack send tool, thread_ts set for a reply. A draft with its header is gated, and sent, as the message after the header's --- alone.
    • Quiet hours: when the gate printed send: schedule, schedule it for that post_at epoch instead, which is on the next active day, after its first tick. A reply passes the thread_ts that line prints to the schedule tool too, as a live reply does: one scheduled without it posted as a new message. A scheduled message cannot be edited through the API, so if the draft said fold-in: expected, or another draft for that thread is due, hold it and send it live, gated again, at the first tick after post_at.
    • A held draft (a fold-in expected, a quiet-hours hold, a claim sent back) is written with its header to <state dir>/held/<peer>-<thread ts>.md with the Write tool, never kept in this session's memory: a compaction loses memory, and step 1's header counts the files (held-drafts:). Add reason: <why, in words> to the header, and needs: user when it waits for the user's own go: the panel shows those under Needs you, the rest under Held. --record-sent moves each held file the send covers (its thread, a message it answers, or the same message) to sent/ and says so (a held reply once stayed under Held after it went out); delete one returned to the peer.
    • While step 1's header prints scheduled-pending: above 0, every tick: PY B/check_draft.py --scheduled, then gate each pending message's copy again (check_draft.py <copy> --reply <ts>, with the same --asks). A message can be withdrawn only before its post_at; the config refuses a post_at at or before the first tick, so that tick is the last chance. A FAIL means a claim moved (the panel shows it): withdraw it with the delete-scheduled tool step 0 recorded, else tell the user now, before post_at, so they delete it in Slack; either way send the draft back to the peer with what changed. Once it is deleted, by you or the user, PY B/check_draft.py --withdrawn <scheduled id> --context "<why>", so no tick gates it again. When the header prints morning:, what was scheduled has posted: read each target thread, confirm it landed (a reply: the thread's reply count moved since it was scheduled), and log --kind sent for each, or report one that did not land. A top-level one has a ts now: PY B/hub.py owner <channel id> <its ts> <peer>, so its replies route back to the peer that drafted it.
    • Confirm the send: the send tool says OK either way. For a reply, re-read the parent thread and compare its reply count with the one read before the send; for a top-level message, re-read the channel's first page and find the ts. Then PY B/check_draft.py <file> --record-sent <message ts or scheduled id> --replies <before> <after> --context "<who asked what>" --peer <the session that sent the draft> keeps a copy and logs it; it refuses a count that did not move. A scheduled reply cannot move the count before it posts: record it without --replies, and with --post-at HH:MM when you scheduled it later than the gate's post_at. --context is the panel's text for it: 80 characters, names in words. --peer (the header's from: when left out) makes that session the owner of the thread's replies for two weeks (step 3), and its owner: line says so; leave it out for a message the user asked for themselves. The owner comes from the header's to: channel: without one, owner: names the hub.py owner command to run. Answer the peer with the ts or id.
  6. Log: one event per item, which the panel shows as one line: PY B/hub.py log --kind <kind> [--ref <PR or ticket link>] [--target <channel id>] [--who <person or session>] [--detail "<the rest>"] "<what, in words>". The text is at most 80 characters with no ids, ts, hashes, links or time: the link is --ref, the channel --target (named from slack.channel_names), everything else --detail. Sends and resolves log themselves, as do the scan's todo, done and blocked rows and review_lint.py --post's reviewed. Log each item routed (--kind routed --who <session>, with --corrects <its todo id> for a ROUTE item, so it moves from in-flight to with-sessions), each draft held and why (--kind held), each question for the user and each BLOCKED scan or unknown mention search (--kind asked or --kind blocked, with --needs-user). The command prints the row's id. An entry made after the fact passes --at HH:MM, never a time in the text. A row with --corrects <id> replaces that row in the panel: use it for a correction, for the user's answer to a question (--kind answered), and for a session's reply to an item routed to it (--kind returned). Step 1's header lists what is still open (needs-you:, with-sessions:). Before reporting a question as waiting on the user, read its thread for the user's own reply: the mention searches skip the user's messages, and a question answered at 17:40 was reported open three ticks running. Answered: log it with --corrects instead. A tick that found nothing new anywhere, and holds and schedules nothing, reports one line. On the window's first tick (scan: full (the window's first)), also python3 ~/.claude/spend-report.py --since 24h --tick-prompt "comms-hub tick" --max-ticks 40 --max-rewrites 10 --top 3 --rewrite-hint "raise session.compact_window, then restart with hub.py launch" and put every ALERT line in the report: what the ticks and their reviewers cost since yesterday. The limit is a day well above normal: 20 ticks a day at the $1.36 to $1.42 a tick measured on 2026-10-28 and 10-29 come to about $28, so $40 means something ran away (a compaction loop, a reviewer that would not stop), not that a day was busy. PY B/hub.py feedback "<what was corrected>" for each correction the user made this tick to a message or to this protocol: feedback.md is the backlog for the next change to the skill. Then report to the user, reviews first, unasked, in the tick each posts (the list of PRs to approve once came 20 minutes after nine reviews, and only when the user asked): each review's link, its verdict and "read the comment and approve", approvable first. Reviews from earlier ticks that still wait on the user stay under needs-you:; one whose head has moved is a re-look, never approvable (two PRs were once called approvable after new commits). When step 0 recorded push_notification, a tick that posted an approvable review sends one notification naming those PRs: the user is often not watching this session. Then sent, scheduled, reacted, held, BLOCKED, and what else needs them. The user has not read the threads, so each item stands alone: its link, who asked what in one quoted line (20 words at most), and what went out or what is needed from them. The thread is already in this session's context, so that is a line per item, never a re-read.

Hard rules

  • Never approve a PR. Never merge someone else's PR. Approval is the human's.
  • A peer's relayed "the user approved" is enough to send that message. It never escalates a permission and approves nothing else.
  • A peer's relayed "hold this" or "the user wants to see it first" holds nothing: only the user's own words to this session hold a send (a roundup once sat for an hour on such a relay). Put the question to the user, log the relay, and send when the gate passes and the user has not said to wait. This loosens nothing above or below: the gate, the claim checks and the approval rule stand.
  • A status question from the user is not a send request.
  • Never cite a session-local task id in shared text.
  • No secrets, tokens or customer identifiers in any message or file; ids and names live in the config only.

State

~/.local/state/comms-hub/, or state_dir:

PathHolds
log/YYYY-MM-DD.jsonlthe day's events, one row each, which the panel draws
log/YYYY-MM-DD.mdthe same events as lines to read: sends, scheduled ids, resolves, routing, holds
sent/YYYY-MM-DD/<id>.mdeach message as it was sent or scheduled
slack/<channel>.tsvthe committed snapshot slack_diff.py compares against
gate/check_draft.py's pass records
held/<peer>-<thread ts>.mdeach draft held between ticks, with its header; the scan counts them
preflight.jsonstep 0's record of this session's tools and trigger
trigger.jsonthe tick's trigger and when it was created, for its 7-day expiry
tick.jsonwhen the last full scan finished (queue.py scan records it, or window --full-done by hand), and where the last mention searches that all reached started (queries --reached)
threads.jsoneach thread the hub sent into for a peer in the last two weeks, with that peer, which owns its replies
last-scanwhen the last scan started, BLOCKED or not; the panel calls an older prs.json stale
prs.jsonthe last scan that did not end BLOCKED: each open PR I review or authored, which the panel's PRS section reads
prs-history.jsonlwhat changed between two prs.json, one row each (reviewed, approved, merged, by day), for the panel's last five days
parked.jsonthe PRs the user parked with hub.py park, with when and why
relooked.jsonthe head each PR was re-looked at by hand with hub.py relooked, with when and why
feedback.mdeach correction the user makes to a message or to this protocol, dated, for the next change to this skill
queue.pidthe scan in progress

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.