diff --git a/.claude/skills/coord/SKILL.md b/.claude/skills/coord/SKILL.md new file mode 100644 index 0000000..da56d16 --- /dev/null +++ b/.claude/skills/coord/SKILL.md @@ -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 /", "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 `/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 | What it does | +|---|---| +| `status` | Coord status (locks, components, workflows). | +| `msg send "" ""` | Send a message. `` = `ALL` (broadcast), a machine name (`HOWARD-HOME`), or a full session id (`HOWARD-HOME/claude-main`). | +| `msg send "" --body-file ` | 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 ` | Mark a message read. | +| `todo add "" [--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 ` | Mark a todo done (sets `completed_by`). | +| `lock claim "" [--ttl HOURS]` | Claim a work lock (default ttl 2h). | +| `lock release ` | 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 ``. +- **Session id = `/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". diff --git a/.claude/skills/coord/scripts/coord.py b/.claude/skills/coord/scripts/coord.py new file mode 100644 index 0000000..22ea233 --- /dev/null +++ b/.claude/skills/coord/scripts/coord.py @@ -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 (/claude-main), +and the user/machine for attribution. Bakes in the fleet conventions: + - broadcast to the whole fleet = to_session "ALL_SESSIONS" + - session id = "/claude-main" + - long message bodies via --body-file (avoids shell-quoting breakage) + +Usage: + coord.py status + coord.py msg send [body] [--body-file PATH] [--project KEY] + : ALL | | /claude-main + coord.py msg inbox [--all] # messages to me (unread by default) + coord.py msg read + coord.py todo add [--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 + coord.py lock claim [--ttl HOURS] + coord.py lock release + 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() diff --git a/session-logs/2026-06-04-session.md b/session-logs/2026-06-04-session.md index 8aad165..ad65677 100644 --- a/session-logs/2026-06-04-session.md +++ b/session-logs/2026-06-04-session.md @@ -108,3 +108,37 @@ Built the `/grok` capability-router skill: `.claude/skills/grok/SKILL.md` + `scr - Grok binary `~/.grok/bin/grok.exe` (0.2.20); auth `~/.grok/auth.json` (OIDC); config `~/.grok/config.toml` (`permission_mode=always-approve`, model grok-build). Models: grok-build (default), grok-composer-2.5-fast; agent self-reports Grok 4.3. - Native tools: image_gen, image_edit, image_to_video, reference_to_video, web_search, web_fetch, x_keyword_search, x_semantic_search, x_user_search, x_thread_fetch, run_terminal_command, file ops, scheduler_*, monitor, memory. - Skill: `.claude/skills/grok/`. Fleet Grok host: GURU-5070 (only install). + +## Update: 08:09 PDT — /coord skill + fleet grok-flag rollout + +### Summary +Built a `/coord` skill to remove the recurring "how do I call the coordination API?" friction (re-derived the message schema, broadcast convention, and payload-escaping each time). `.claude/skills/coord/scripts/coord.py` + `SKILL.md` wrap the coord API for messages, todos, locks, and status. It auto-derives the API base (identity.json `coord_api`), this session id (`/claude-main`), and user/machine attribution, and bakes in the conventions: broadcast = `to_session:"ALL_SESSIONS"`, machine-name -> session-id expansion, and `--body-file`/`--text-file` for long content. Tested: `status`, `msg inbox`, `todo add`, `todo list` all work. + +Used it (and direct API) to roll the new grok capability flag to the fleet two ways: a broadcast coord message (id `4407c349`, to ALL_SESSIONS) instructing each machine's next Claude session to run `migrate-identity.sh`, and a durable backstop todo (id `a3f3bde3`). identity.json is per-machine/gitignored, so each machine must populate its own `grok` block locally; the message pushes at next session start, the todo persists until done. + +### Key Decisions +- Broadcast target must be `to_session:"ALL_SESSIONS"` (matches existing fleet broadcasts); an omitted/`null` target does NOT reach sessions' unread queries. Baked into the skill. +- Long coord bodies via `--body-file` (same lesson as grok `--prompt-file`): build JSON with Python, never fight shell quoting. +- Pair message + todo for fleet rollouts: a message can be read-and-forgotten; a todo is durable/queryable. +- Wrote the coord helper in Python (urllib), consistent with the b2 skill's `scripts/*.py`, so JSON + HTTP + escaping are handled natively. + +### Problems Encountered +- First broadcast POST returned no id (failed) — long body with escaped quotes via curl `-d` was malformed; resolved by building the JSON payload with Python and posting `--data @file`. +- Initial broadcast used an omitted `to_session` (stored null) which would not have reached sessions; corrected to `ALL_SESSIONS` after inspecting existing fleet broadcasts. + +### Configuration Changes +- NEW `.claude/skills/coord/SKILL.md`, `.claude/skills/coord/scripts/coord.py` + +### Commands & Outputs +- `py .claude/skills/coord/scripts/coord.py status|msg inbox|msg send --body-file |todo add ...|todo list|lock claim ...` +- Sent broadcast id `4407c349` (ALL_SESSIONS); filed todo id `a3f3bde3` (grok-flag rollout). + +### Pending / Incomplete Tasks +- Fleet machines re-run `migrate-identity.sh` (driven by broadcast 4407c349 + todo a3f3bde3) to populate their `grok` flag — happens as each session next starts. +- Remote Grok routing still deferred. +- Carried over: human-flow promote beta->prod; MSP360/B2 follow-ups (todos 2e50f388 / dc3a6233 / 0fed5eb2); SBS portal removal (db03f8fe); rotate leaked MSP360 API key. + +### Reference Information +- Coord skill: `.claude/skills/coord/`. Coord API base from identity.json `coord_api` (default http://172.16.3.30:8001) + `/api/coord`. +- Broadcast msg `4407c349-eb37-4cf7-9b2c-75e4246d04ee`; rollout todo `a3f3bde3-b4bb-4ce9-b102-a07ea83e3ffa`. +- Protocol: `.claude/COORDINATION_PROTOCOL.md`.