sync: auto-sync from GURU-5070 at 2026-06-04 08:09:17
Author: Mike Swanson Machine: GURU-5070 Timestamp: 2026-06-04 08:09:17
This commit is contained in:
60
.claude/skills/coord/SKILL.md
Normal file
60
.claude/skills/coord/SKILL.md
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: coord
|
||||
description: >
|
||||
Talk to the ClaudeTools coordination API (inter-session messaging, fleet todos,
|
||||
resource locks, component/status) without re-deriving the schema each time. Use
|
||||
for: sending a message to another machine's Claude session or BROADCASTING to the
|
||||
whole fleet; checking/reading your own unread coord messages; creating/listing/
|
||||
completing coord todos; claiming/releasing work locks; reading coord status.
|
||||
Invoke on: "send a coord message", "message <machine>/<user>", "broadcast to the
|
||||
fleet", "tell the other sessions", "coord todo", "claim a lock", "coord status",
|
||||
"any unread coord messages".
|
||||
---
|
||||
|
||||
# coord — coordination API helper
|
||||
|
||||
One command for the coord API. The script auto-derives everything machine-specific
|
||||
from `identity.json` (the API base `coord_api`, this session id `<MACHINE>/claude-main`,
|
||||
and the `user`/`machine` for attribution) and bakes in the fleet conventions, so you
|
||||
never hand-build the JSON again.
|
||||
|
||||
```
|
||||
py "$CLAUDETOOLS_ROOT/.claude/skills/coord/scripts/coord.py" <command> ...
|
||||
```
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `status` | Coord status (locks, components, workflows). |
|
||||
| `msg send <to> "<subject>" "<body>"` | Send a message. `<to>` = `ALL` (broadcast), a machine name (`HOWARD-HOME`), or a full session id (`HOWARD-HOME/claude-main`). |
|
||||
| `msg send <to> "<subject>" --body-file <path>` | Same, body from a file (use for long/multi-line bodies — avoids shell-quoting breakage). |
|
||||
| `msg inbox [--all]` | Your unread messages (or `--all`). Print these prominently per the coord rule. |
|
||||
| `msg read <id>` | Mark a message read. |
|
||||
| `todo add "<text>" [--user U] [--project KEY] [--parent ID] [--source TXT] [--auto]` | Create a todo (auto-fills `created_by_user`/`created_by_machine`). `--text-file` for long text. |
|
||||
| `todo list [--user U] [--project KEY] [--status pending\|done\|all]` | List todos. |
|
||||
| `todo done <id>` | Mark a todo done (sets `completed_by`). |
|
||||
| `lock claim <project> <resource> "<desc>" [--ttl HOURS]` | Claim a work lock (default ttl 2h). |
|
||||
| `lock release <id>` | Release a lock. |
|
||||
| `lock list [--project KEY]` | List active locks. |
|
||||
|
||||
## Conventions it handles for you
|
||||
|
||||
- **Broadcast = `to_session: "ALL_SESSIONS"`** (NOT an omitted/`null` target — that does not reach sessions). Just pass `ALL`/`fleet`/`broadcast` as `<to>`.
|
||||
- **Session id = `<MACHINE>/claude-main`.** Passing a bare machine name to `msg send` expands to that; passing `user`/anything with `/` is used verbatim.
|
||||
- **Attribution auto-filled** from identity.json (`created_by_user`, `created_by_machine`, `from_session`, lock `session_id`).
|
||||
- **Long bodies via `--body-file`/`--text-file`** (the same lesson as grok `--prompt-file`: don't fight shell quoting for multi-line content).
|
||||
|
||||
## How delivery works (important)
|
||||
|
||||
- A **message** is surfaced to the target session(s) by the session-start hook as
|
||||
"UNREAD COORD MESSAGES" — i.e. it reaches a machine the next time a Claude session
|
||||
starts there. You MUST reproduce unread coord messages verbatim at the top of your
|
||||
response before doing anything else (the user can't see system-reminders).
|
||||
- A **todo** is durable and queryable until marked done — use it as a backstop for
|
||||
fleet rollouts (a message can be read-and-forgotten; a todo persists).
|
||||
- Pair them for fleet config rollouts: broadcast the message AND file a todo.
|
||||
|
||||
## Notes
|
||||
|
||||
- API base comes from `identity.json` `coord_api` (default `http://172.16.3.30:8001`); the script appends `/api/coord`.
|
||||
- Softfail: if the API is unreachable the command errors (exit 1). Per the coord protocol, queue failed writes to `.claude/coord-queue.jsonl` and drain on `/sync` if doing this manually.
|
||||
- Full protocol + endpoint detail: `.claude/COORDINATION_PROTOCOL.md` and CLAUDE.md "Live State Tracking".
|
||||
226
.claude/skills/coord/scripts/coord.py
Normal file
226
.claude/skills/coord/scripts/coord.py
Normal file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""coord.py - ClaudeTools coordination API helper (messages, todos, locks, status).
|
||||
|
||||
Removes the recurring "how do I call coord again?" friction. Auto-derives from
|
||||
identity.json: the API base (coord_api), this session id (<machine>/claude-main),
|
||||
and the user/machine for attribution. Bakes in the fleet conventions:
|
||||
- broadcast to the whole fleet = to_session "ALL_SESSIONS"
|
||||
- session id = "<MACHINE>/claude-main"
|
||||
- long message bodies via --body-file (avoids shell-quoting breakage)
|
||||
|
||||
Usage:
|
||||
coord.py status
|
||||
coord.py msg send <to> <subject> [body] [--body-file PATH] [--project KEY]
|
||||
<to>: ALL | <machine> | <machine>/claude-main
|
||||
coord.py msg inbox [--all] # messages to me (unread by default)
|
||||
coord.py msg read <id>
|
||||
coord.py todo add <text> [--text-file PATH] [--user U] [--project KEY] [--parent ID] [--source TEXT]
|
||||
coord.py todo list [--user U] [--project KEY] [--status pending|done|all]
|
||||
coord.py todo done <id>
|
||||
coord.py lock claim <project> <resource> <desc> [--ttl HOURS]
|
||||
coord.py lock release <id>
|
||||
coord.py lock list [--project KEY]
|
||||
"""
|
||||
import sys, os, json, argparse, urllib.request, urllib.error, urllib.parse
|
||||
|
||||
|
||||
def find_identity():
|
||||
cands = []
|
||||
env = os.environ.get("CLAUDETOOLS_ROOT")
|
||||
if env:
|
||||
cands.append(os.path.join(env, ".claude", "identity.json"))
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
cands.append(os.path.normpath(os.path.join(here, "..", "..", "..", "identity.json")))
|
||||
for c in cands:
|
||||
if os.path.isfile(c):
|
||||
try:
|
||||
return json.load(open(c, encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
IDENT = find_identity()
|
||||
API = (IDENT.get("coord_api") or "http://172.16.3.30:8001").rstrip("/") + "/api/coord"
|
||||
MACHINE = IDENT.get("machine") or os.environ.get("COMPUTERNAME") or "unknown"
|
||||
USER = IDENT.get("user") or "unknown"
|
||||
SESSION = f"{MACHINE}/claude-main"
|
||||
|
||||
|
||||
def call(method, path, body=None, query=None):
|
||||
url = API + path
|
||||
if query:
|
||||
q = {k: v for k, v in query.items() if v is not None}
|
||||
if q:
|
||||
url += "?" + urllib.parse.urlencode(q)
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
headers = {"Content-Type": "application/json"} if data is not None else {}
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=25) as r:
|
||||
raw = r.read().decode("utf-8", "replace")
|
||||
return r.status, (json.loads(raw) if raw.strip() else None)
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode("utf-8", "replace")
|
||||
try:
|
||||
return e.code, json.loads(raw)
|
||||
except Exception:
|
||||
return e.code, {"error": raw[:400]}
|
||||
except Exception as e:
|
||||
return 0, {"error": str(e)}
|
||||
|
||||
|
||||
def die(st, resp, ok=(200, 201)):
|
||||
if st not in ok:
|
||||
print(f"[coord] ERROR HTTP {st}: {json.dumps(resp)[:500]}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def resolve_to(to):
|
||||
t = (to or "").strip()
|
||||
if t.lower() in ("all", "broadcast", "fleet", "all_sessions", "*"):
|
||||
return "ALL_SESSIONS"
|
||||
return t if "/" in t else f"{t}/claude-main"
|
||||
|
||||
|
||||
def listify(r, key):
|
||||
if isinstance(r, dict):
|
||||
return r.get(key, []) or []
|
||||
return r or []
|
||||
|
||||
|
||||
# --- commands ---
|
||||
def c_status(a):
|
||||
st, r = call("GET", "/status"); die(st, r, ok=(200,))
|
||||
print(json.dumps(r, indent=2)[:3000])
|
||||
|
||||
|
||||
def c_msg_send(a):
|
||||
body = a.body
|
||||
if a.body_file:
|
||||
body = open(a.body_file, encoding="utf-8").read()
|
||||
if body is None:
|
||||
print("[coord] need a body (positional or --body-file)", file=sys.stderr); sys.exit(2)
|
||||
to = resolve_to(a.to)
|
||||
payload = {"from_session": SESSION, "to_session": to, "subject": a.subject, "body": body}
|
||||
if a.project:
|
||||
payload["project_key"] = a.project
|
||||
st, r = call("POST", "/messages", payload); die(st, r)
|
||||
print(f"[coord] message sent id={r.get('id')} to={to}")
|
||||
|
||||
|
||||
def c_msg_inbox(a):
|
||||
q = {"to_session": SESSION}
|
||||
if not a.all:
|
||||
q["unread_only"] = "true"
|
||||
st, r = call("GET", "/messages", query=q); die(st, r, ok=(200,))
|
||||
msgs = listify(r, "messages")
|
||||
print(f"[coord] {len(msgs)} message(s) for {SESSION}{'' if a.all else ' (unread)'}")
|
||||
for m in msgs:
|
||||
print(f"--- {m.get('id')} | from {m.get('from_session')} | {m.get('created_at','')}")
|
||||
print(f" SUBJECT: {m.get('subject')}")
|
||||
for line in (m.get("body") or "").rstrip().splitlines():
|
||||
print(f" {line}")
|
||||
|
||||
|
||||
def c_msg_read(a):
|
||||
st, r = call("PUT", f"/messages/{a.id}/read"); die(st, r, ok=(200, 204))
|
||||
print(f"[coord] marked read: {a.id}")
|
||||
|
||||
|
||||
def c_todo_add(a):
|
||||
text = a.text
|
||||
if a.text_file:
|
||||
text = open(a.text_file, encoding="utf-8").read().strip()
|
||||
payload = {"text": text, "created_by_user": USER, "created_by_machine": MACHINE,
|
||||
"auto_created": bool(a.auto)}
|
||||
if a.user:
|
||||
payload["assigned_to_user"] = a.user
|
||||
if a.project:
|
||||
payload["project_key"] = a.project
|
||||
if a.parent:
|
||||
payload["parent_id"] = a.parent
|
||||
if a.source:
|
||||
payload["source_context"] = a.source
|
||||
st, r = call("POST", "/todos", payload); die(st, r)
|
||||
print(f"[coord] todo created id={r.get('id')}")
|
||||
|
||||
|
||||
def c_todo_list(a):
|
||||
q = {"status_filter": a.status}
|
||||
if a.user:
|
||||
q["for_user"] = a.user
|
||||
if a.project:
|
||||
q["project_key"] = a.project
|
||||
st, r = call("GET", "/todos", query=q); die(st, r, ok=(200,))
|
||||
todos = listify(r, "todos")
|
||||
print(f"[coord] {len(todos)} todo(s)")
|
||||
for t in todos:
|
||||
extra = ""
|
||||
if t.get("assigned_to_user"):
|
||||
extra += f" @{t['assigned_to_user']}"
|
||||
if t.get("project_key"):
|
||||
extra += f" ({t['project_key']})"
|
||||
print(f" [{t.get('status'):<7}] {str(t.get('id',''))[:8]} {str(t.get('text',''))[:88]}{extra}")
|
||||
|
||||
|
||||
def c_todo_done(a):
|
||||
st, r = call("PUT", f"/todos/{a.id}", {"status": "done", "completed_by": USER})
|
||||
die(st, r, ok=(200,))
|
||||
print(f"[coord] todo done: {a.id}")
|
||||
|
||||
|
||||
def c_lock_claim(a):
|
||||
st, r = call("POST", "/locks", {"project_key": a.project, "session_id": SESSION,
|
||||
"resource": a.resource, "description": a.desc,
|
||||
"ttl_hours": a.ttl}); die(st, r)
|
||||
print(f"[coord] lock id={r.get('id')} {a.project}:{a.resource}")
|
||||
|
||||
|
||||
def c_lock_release(a):
|
||||
st, r = call("DELETE", f"/locks/{a.id}", query={"session_id": SESSION})
|
||||
die(st, r, ok=(200, 204))
|
||||
print(f"[coord] lock released: {a.id}")
|
||||
|
||||
|
||||
def c_lock_list(a):
|
||||
st, r = call("GET", "/locks", query={"project_key": a.project} if a.project else None)
|
||||
die(st, r, ok=(200,))
|
||||
locks = listify(r, "locks")
|
||||
print(f"[coord] {len(locks)} lock(s)")
|
||||
for l in locks:
|
||||
print(f" {str(l.get('id',''))[:8]} {l.get('project_key')}:{l.get('resource')} by {l.get('session_id')} exp {l.get('expires_at')}")
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(prog="coord.py", description="ClaudeTools coordination API helper")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("status").set_defaults(fn=c_status)
|
||||
|
||||
m = sub.add_parser("msg").add_subparsers(dest="sub", required=True)
|
||||
s = m.add_parser("send"); s.add_argument("to"); s.add_argument("subject"); s.add_argument("body", nargs="?")
|
||||
s.add_argument("--body-file"); s.add_argument("--project"); s.set_defaults(fn=c_msg_send)
|
||||
ib = m.add_parser("inbox"); ib.add_argument("--all", action="store_true"); ib.set_defaults(fn=c_msg_inbox)
|
||||
rd = m.add_parser("read"); rd.add_argument("id"); rd.set_defaults(fn=c_msg_read)
|
||||
|
||||
t = sub.add_parser("todo").add_subparsers(dest="sub", required=True)
|
||||
ta = t.add_parser("add"); ta.add_argument("text", nargs="?"); ta.add_argument("--text-file")
|
||||
ta.add_argument("--user"); ta.add_argument("--project"); ta.add_argument("--parent")
|
||||
ta.add_argument("--source"); ta.add_argument("--auto", action="store_true"); ta.set_defaults(fn=c_todo_add)
|
||||
tl = t.add_parser("list"); tl.add_argument("--user"); tl.add_argument("--project")
|
||||
tl.add_argument("--status", default="pending"); tl.set_defaults(fn=c_todo_list)
|
||||
td = t.add_parser("done"); td.add_argument("id"); td.set_defaults(fn=c_todo_done)
|
||||
|
||||
lk = sub.add_parser("lock").add_subparsers(dest="sub", required=True)
|
||||
lc = lk.add_parser("claim"); lc.add_argument("project"); lc.add_argument("resource")
|
||||
lc.add_argument("desc"); lc.add_argument("--ttl", type=int, default=2); lc.set_defaults(fn=c_lock_claim)
|
||||
lr = lk.add_parser("release"); lr.add_argument("id"); lr.set_defaults(fn=c_lock_release)
|
||||
ll = lk.add_parser("list"); ll.add_argument("--project"); ll.set_defaults(fn=c_lock_list)
|
||||
|
||||
a = p.parse_args()
|
||||
a.fn(a)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user