Add capture mode: /capture + per-room capture threads → cross-project inbox
A capture-thread message (or /capture <text> in any room) logs to standards/ INBOX.md via capture-note.sh — deterministic, no claude call — and confirms in-thread; /triage stays the gate into each repo (D13). Thread roots seeded by seed-capture-threads.py.
This commit is contained in:
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/bin/zsh -l
|
||||
# capture-note.sh — matrix-bridge capture wrapper.
|
||||
#
|
||||
# Invoked over SSH by the bot: capture-note.sh <project> <text...>
|
||||
# Appends ONE line to the cross-project inbox (standards/INBOX.md) in the exact format the
|
||||
# /capture skill uses, commits it, and best-effort pushes — so /triage (run later inside the
|
||||
# target repo) drains it like any other captured item.
|
||||
#
|
||||
# Deterministic on purpose: no LLM, no token, nothing leaves the Mac except the git push to
|
||||
# Gitea. The "smarts" (real type/priority, repo-routing, phrasing) stay at /triage, where the
|
||||
# human is — and routing message text through a frontier model would break the sovereignty
|
||||
# boundary (D13/D8). Defaults every capture to a raw [idea][P2]; /triage reclassifies.
|
||||
#
|
||||
# Why a login shell (-l): git config / PATH live in ~/.zprofile, which a non-login SSH shell
|
||||
# skips — the same seam as launch-claude.sh / ask-claude.sh.
|
||||
|
||||
set -e
|
||||
|
||||
standards="$HOME/Projects/standards"
|
||||
inbox="$standards/INBOX.md"
|
||||
|
||||
project="$1"
|
||||
shift || true
|
||||
note="$*"
|
||||
|
||||
if [[ -z "$project" || -z "$note" ]]; then
|
||||
print -u2 "usage: capture-note.sh <project> <text>"
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -f "$inbox" ]]; then
|
||||
print -u2 "capture-note: inbox not found: $inbox"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
line="- [ ] ($project) [idea][P2] $note — via matrix, $(date +%F)"
|
||||
|
||||
print -r -- "$line" >> "$inbox"
|
||||
|
||||
git -C "$standards" add INBOX.md
|
||||
git -C "$standards" commit -q -m "Capture: ${note[1,60]} (via matrix)" -- INBOX.md
|
||||
# Push is best-effort — a captured line committed locally is not lost if Gitea is unreachable.
|
||||
git -C "$standards" push -q 2>/dev/null || print -u2 "capture-note: push skipped/failed (committed locally)"
|
||||
|
||||
# Echo the exact line so the bot can post it back into the thread as confirmation.
|
||||
print -r -- "$line"
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""seed-capture-threads.py — create each room's capture-thread root, print the event IDs.
|
||||
|
||||
Posts one "capture thread" root message into every room in config.toml and prints the resulting
|
||||
event_id per room. Paste each into its [[room]] block as `capture_thread = "$..."` — the bot then
|
||||
treats any reply in that thread as a capture (no /capture prefix needed in-thread).
|
||||
|
||||
Idempotent-ish: by default it skips rooms that already have `capture_thread` set in config, so a
|
||||
re-run after adding a project only seeds the new room. Use --force to reseed everything, pass room
|
||||
labels to seed only those, or --dry-run to preview without posting.
|
||||
|
||||
Reads ../.env (MATRIX_ACCESS_TOKEN) and ../config.toml (homeserver URL + rooms). Talks to the
|
||||
homeserver over plain HTTPS, so run it anywhere the homeserver is reachable (Mac or Spark).
|
||||
|
||||
python3 scripts/seed-capture-threads.py [--force] [--dry-run] [label ...]
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tomllib
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import uuid
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE)
|
||||
|
||||
|
||||
def load_env(path):
|
||||
env = {}
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
env[k] = v.strip().strip('"')
|
||||
return env
|
||||
|
||||
|
||||
def root_body(project):
|
||||
who = "any project" if project == "?" else f"({project})"
|
||||
tag = "?" if project == "?" else project
|
||||
return (
|
||||
f"📥 Capture thread — reply in this thread to log an idea, feature, or bug for {who}. "
|
||||
f"It goes straight to the cross-project inbox tagged ({tag}) (no /capture prefix needed "
|
||||
f"in-thread); run /triage in the repo to land it."
|
||||
)
|
||||
|
||||
|
||||
def send_root(url, token, room_id, project):
|
||||
txn = uuid.uuid4().hex
|
||||
endpoint = (
|
||||
f"{url.rstrip('/')}/_matrix/client/v3/rooms/"
|
||||
f"{urllib.parse.quote(room_id, safe='')}/send/m.room.message/{txn}"
|
||||
)
|
||||
body = json.dumps({"msgtype": "m.text", "body": root_body(project)}).encode()
|
||||
req = urllib.request.Request(
|
||||
endpoint, data=body, method="PUT",
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.load(resp)["event_id"]
|
||||
|
||||
|
||||
def main():
|
||||
args = [a for a in sys.argv[1:]]
|
||||
force = "--force" in args
|
||||
dry_run = "--dry-run" in args
|
||||
only = [a for a in args if not a.startswith("--")]
|
||||
|
||||
env = load_env(os.path.join(ROOT, ".env"))
|
||||
with open(os.path.join(ROOT, "config.toml"), "rb") as f:
|
||||
cfg = tomllib.load(f)
|
||||
url = cfg["homeserver"]["url"]
|
||||
token = env.get("MATRIX_ACCESS_TOKEN") or env.get("MATRIX_ACCESS_TOKEN".lower())
|
||||
if not token:
|
||||
sys.exit("no MATRIX_ACCESS_TOKEN in .env")
|
||||
|
||||
# Build the seed list: every project room, plus the all-projects fan-out room as a `?` catch-all.
|
||||
targets = []
|
||||
for r in cfg.get("room", []):
|
||||
targets.append((r["label"], r["room_id"], r.get("capture_project", r["label"]),
|
||||
r.get("capture_thread")))
|
||||
ap = cfg.get("all_projects", {})
|
||||
if ap.get("room_id"):
|
||||
targets.append(("all-projects", ap["room_id"], ap.get("capture_project", "?"),
|
||||
ap.get("capture_thread")))
|
||||
|
||||
if only:
|
||||
targets = [t for t in targets if t[0] in only]
|
||||
|
||||
seeded = []
|
||||
for label, room_id, project, existing in targets:
|
||||
if existing and not force:
|
||||
print(f"SKIP {label:<20} already has capture_thread={existing}")
|
||||
continue
|
||||
if dry_run:
|
||||
print(f"DRYRUN {label:<20} {room_id} (project {project})")
|
||||
continue
|
||||
try:
|
||||
event_id = send_root(url, token, room_id, project)
|
||||
seeded.append((label, room_id, project, event_id))
|
||||
print(f"SEEDED {label:<20} {room_id} -> {event_id}")
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"ERROR {label:<20} {room_id} HTTP {e.code}: {e.read().decode()[:200]}")
|
||||
except Exception as e: # noqa: BLE001 — surface any transport failure per room, keep going
|
||||
print(f"ERROR {label:<20} {room_id} {e}")
|
||||
|
||||
if seeded:
|
||||
print("\n# Paste each line into the matching [[room]] block (or [all_projects]):")
|
||||
for label, _room_id, _project, event_id in seeded:
|
||||
print(f'{label}: capture_thread = "{event_id}"')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user