hubkit-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. hubcore/__init__.py +1 -0
  2. hubcore/cli.py +273 -0
  3. hubcore/compat.py +187 -0
  4. hubcore/config.py +107 -0
  5. hubcore/data/hub/LICENSE +202 -0
  6. hubcore/data/hub/SKILL.md +125 -0
  7. hubcore/data/hub/config.example.toml +84 -0
  8. hubcore/data/hub/docs/architecture.md +133 -0
  9. hubcore/data/hub/docs/commands.md +66 -0
  10. hubcore/data/hub/docs/configuration.md +151 -0
  11. hubcore/data/hub/docs/troubleshooting.md +21 -0
  12. hubcore/data/hub/intents.example.toml +74 -0
  13. hubcore/data/hub/references/design-sources.md +14 -0
  14. hubcore/data/hub/references/research.md +49 -0
  15. hubcore/data/hub/scripts/hubcore/__init__.py +1 -0
  16. hubcore/data/hub/scripts/hubcore/cli.py +273 -0
  17. hubcore/data/hub/scripts/hubcore/compat.py +187 -0
  18. hubcore/data/hub/scripts/hubcore/config.py +107 -0
  19. hubcore/data/hub/scripts/hubcore/gate.py +145 -0
  20. hubcore/data/hub/scripts/hubcore/hooks.py +326 -0
  21. hubcore/data/hub/scripts/hubcore/install.py +66 -0
  22. hubcore/data/hub/scripts/hubcore/learnings.py +232 -0
  23. hubcore/data/hub/scripts/hubcore/lessons.py +168 -0
  24. hubcore/data/hub/scripts/hubcore/orders.py +508 -0
  25. hubcore/data/hub/scripts/hubcore/redact.py +89 -0
  26. hubcore/data/hub/scripts/hubcore/routed.py +1169 -0
  27. hubcore/data/hub/scripts/hubcore/slow.py +531 -0
  28. hubcore/data/hub/scripts/hubcore/status.py +535 -0
  29. hubcore/data/hub/scripts/hubcore/store.py +172 -0
  30. hubcore/data/hub/scripts/hubcore/watcher.py +167 -0
  31. hubcore/data/hub/scripts/hubkit +37 -0
  32. hubcore/data/hub/scripts/learn.py +84 -0
  33. hubcore/data/hub-mep/SKILL.md +35 -0
  34. hubcore/data/hubkit-setup/SKILL.md +50 -0
  35. hubcore/gate.py +145 -0
  36. hubcore/hooks.py +326 -0
  37. hubcore/install.py +66 -0
  38. hubcore/learnings.py +232 -0
  39. hubcore/lessons.py +168 -0
  40. hubcore/orders.py +508 -0
  41. hubcore/redact.py +89 -0
  42. hubcore/routed.py +1169 -0
  43. hubcore/slow.py +531 -0
  44. hubcore/status.py +535 -0
  45. hubcore/store.py +172 -0
  46. hubcore/watcher.py +167 -0
  47. hubkit_cli-0.1.0.dist-info/METADATA +195 -0
  48. hubkit_cli-0.1.0.dist-info/RECORD +51 -0
  49. hubkit_cli-0.1.0.dist-info/WHEEL +4 -0
  50. hubkit_cli-0.1.0.dist-info/entry_points.txt +2 -0
  51. hubkit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,172 @@
1
+ """Append-only JSONL, flock, atomic replace, human-prompt test, time helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import re
9
+ import time
10
+ import unicodedata
11
+ from collections.abc import Iterator
12
+ from datetime import datetime
13
+ from datetime import tzinfo as TzInfo
14
+ from pathlib import Path
15
+
16
+ from . import compat, config
17
+ from .redact import redact_obj
18
+
19
+ # Harness and peer wrappers. The test is a denylist on the whole normalised text, plus: any text
20
+ # that starts with a tag is not a typed prompt, and a payload origin other than "human" is rejected.
21
+ HARNESS_PREFIXES = ("caveat", "this session is being continued", "/compact", "[artifact", "[system", "[cross-session")
22
+ HARNESS_MARKERS = (
23
+ "cross-session-message",
24
+ "another-claude-session-sent-a-message",
25
+ "system-notification",
26
+ "task-notification",
27
+ "system-reminder",
28
+ "teammate-message",
29
+ "local-command-stdout",
30
+ "command-name",
31
+ "bash-input",
32
+ "bash-stdout",
33
+ "artifact-content-authored-by",
34
+ )
35
+
36
+
37
+ class StateError(RuntimeError):
38
+ pass
39
+
40
+
41
+ def _normalise(text: str) -> str:
42
+ """NFKC, all dash look-alikes to '-', zero-width and format characters removed, lower case."""
43
+ out = []
44
+ for ch in unicodedata.normalize("NFKC", text):
45
+ cat = unicodedata.category(ch)
46
+ if cat == "Cf":
47
+ continue
48
+ out.append("-" if cat == "Pd" else ch)
49
+ return "".join(out).lower()
50
+
51
+
52
+ def is_human(text: str, origin: str | None = None) -> bool:
53
+ """True only for a typed prompt. origin, when the payload has it, must be 'human'."""
54
+ if origin is not None and origin != "human":
55
+ return False
56
+ t = re.sub(r"</?pasted_content[^>]*>", " ", _normalise(text)).lstrip() # a typed paste is human
57
+ if not t or t.startswith(HARNESS_PREFIXES) or re.match(r"<[a-z/!?]", t):
58
+ return False
59
+ squashed = re.sub(r"[\s_]+", "-", t)
60
+ return not any(m in squashed for m in HARNESS_MARKERS)
61
+
62
+
63
+ def _inside_git(path: Path) -> bool:
64
+ p = path.resolve()
65
+ return any((q / ".git").exists() for q in [p, *p.parents])
66
+
67
+
68
+ def state(cfg: dict, create: bool = True) -> Path:
69
+ """The state directory (created on demand); refuses one inside a git repository or the skill dir."""
70
+ d = config.state_dir(cfg)
71
+ if d.resolve().is_relative_to(config.SKILL_DIR):
72
+ raise StateError(f"state dir {d} is inside the skill dir")
73
+ if _inside_git(d):
74
+ raise StateError(f"state dir {d} is inside a git repository")
75
+ if create:
76
+ for sub in ("", "seen", "archive", "eod", "payload-samples"):
77
+ (d / sub).mkdir(parents=True, exist_ok=True)
78
+ return d
79
+
80
+
81
+ def tzinfo(cfg: dict) -> TzInfo | None:
82
+ """The configured timezone, else the system one."""
83
+ name = cfg["user"].get("timezone", "")
84
+ if name:
85
+ from zoneinfo import ZoneInfo
86
+
87
+ return ZoneInfo(name)
88
+ return datetime.now().astimezone().tzinfo
89
+
90
+
91
+ def day_of(cfg: dict, epoch: float) -> str:
92
+ """YYYY-MM-DD of an epoch in the configured timezone."""
93
+ return datetime.fromtimestamp(epoch, tzinfo(cfg)).strftime("%Y-%m-%d")
94
+
95
+
96
+ def clock(cfg: dict, epoch: float) -> str:
97
+ """HH:MM of an epoch in the configured timezone."""
98
+ return datetime.fromtimestamp(epoch, tzinfo(cfg)).strftime("%H:%M")
99
+
100
+
101
+ def append_row(path: Path, row: dict) -> None:
102
+ """Redact, then one os.write on an O_APPEND fd. No lock needed for a pure append."""
103
+ row = redact_obj(row)
104
+ row.setdefault("ts", time.time())
105
+ data = (json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n").encode()
106
+ fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
107
+ try:
108
+ os.write(fd, data)
109
+ finally:
110
+ os.close(fd)
111
+
112
+
113
+ def read_rows(path: Path) -> tuple[list[dict], int]:
114
+ """Return (rows, bad_lines). A torn or corrupt line is skipped and counted."""
115
+ rows: list[dict] = []
116
+ bad = 0
117
+ if not path.exists():
118
+ return rows, bad
119
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
120
+ if not line.strip():
121
+ continue
122
+ try:
123
+ obj = json.loads(line)
124
+ except ValueError:
125
+ bad += 1
126
+ continue
127
+ if isinstance(obj, dict):
128
+ rows.append(obj)
129
+ else:
130
+ bad += 1
131
+ return rows, bad
132
+
133
+
134
+ @contextlib.contextmanager
135
+ def locked(path: Path, blocking: bool = True, retries: int = 3) -> Iterator[bool]:
136
+ """flock on a lock file. Yields True when held. Non-blocking mode retries then yields False."""
137
+ fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
138
+ got = False
139
+ try:
140
+ for _ in range(retries if not blocking else 1):
141
+ got = compat.lock_exclusive(fd, wait=blocking)
142
+ if got:
143
+ break
144
+ time.sleep(0.05)
145
+ yield got
146
+ finally:
147
+ os.close(fd)
148
+
149
+
150
+ def write_atomic(path: Path, text: str) -> None:
151
+ """Write text via a temp file and rename."""
152
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
153
+ tmp.write_text(text, encoding="utf-8")
154
+ os.replace(tmp, path)
155
+
156
+
157
+ def write_json_atomic(path: Path, obj: object, indent: int | None = 1) -> None:
158
+ """Redact every string, then write atomically (status.json, work.json)."""
159
+ write_atomic(path, json.dumps(redact_obj(obj, 2000), indent=indent, ensure_ascii=False) + "\n")
160
+
161
+
162
+ def safe(text: str, n: int = 300) -> str:
163
+ """Redact first, then truncate, so a cut secret cannot leak a prefix."""
164
+ from .redact import redact
165
+
166
+ return redact(text)[0][:n]
167
+
168
+
169
+ def touch_seen(sd: Path, name: str) -> None:
170
+ """Touch a heartbeat file under seen/."""
171
+ (sd / "seen").mkdir(exist_ok=True)
172
+ (sd / "seen" / name).touch()
@@ -0,0 +1,167 @@
1
+ """Checks that run in every tick (cron or watch), outside the hub session: the hub itself, peers stuck on a prompt,
2
+ the watch and cron checking each other, and the user notification."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import shlex
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import time
12
+
13
+ from . import hooks, routed, slow, store
14
+ from .orders import Orders
15
+
16
+ USER_KEYS = ("hub:", "peer:") # alerts that go to the user's desktop when no notify_cmd is configured
17
+ WAITING_STATES = ("waiting", "blocked", "needs_input", "permission")
18
+ HUB_SEEN = ("prompt", "stop", "routed", "answer", "session")
19
+
20
+
21
+ def live_jobs(cfg: dict, orders: Orders) -> int:
22
+ """Routed items plus open orders: the work that needs a hub."""
23
+ return len(routed.items(cfg)) + sum(o["state"] == "open" for o in orders.live())
24
+
25
+
26
+ def _last_hub_activity(sd: store.Path) -> float:
27
+ """Latest heartbeat the hub's own hooks touched."""
28
+ return max([(sd / "seen" / n).stat().st_mtime for n in HUB_SEEN if (sd / "seen" / n).exists()] or [0.0])
29
+
30
+
31
+ def hub_checks(cfg: dict, orders: Orders, now: float) -> list[dict]:
32
+ """The hub is gone, stuck on one status, or has not acted on an alert, while jobs are live."""
33
+ n = live_jobs(cfg, orders)
34
+ if not n:
35
+ return []
36
+ w = cfg["watcher"]
37
+ b = hooks.read_bind(cfg)
38
+ if not hooks.bind_alive(b):
39
+ return [{"key": "hub:dead", "msg": f"hub session is not running and {n} job{'s' if n > 1 else ''} are live. "
40
+ "NEXT: start the hub session and run hubkit bind"}]
41
+ out: list[dict] = []
42
+ sess = hooks.session_file(cfg, int(b["pid"])) or {}
43
+ since = float(sess.get("statusUpdatedAt", 0)) / 1000
44
+ if sess.get("status") == "busy" and since and now - since > float(w["stuck_min"]) * 60:
45
+ out.append({"key": "hub:stuck", "msg": f"hub session has been busy for {int((now - since) / 60)} min with {n} live jobs; "
46
+ "check it is not stuck on a prompt"})
47
+ act = _last_hub_activity(store.state(cfg))
48
+ old = [r for r in store.read_rows(store.state(cfg) / "alerts.jsonl")[0]
49
+ if r["ts"] > act and now - r["ts"] > float(w["unhandled_min"]) * 60 and not str(r.get("key", "")).startswith("hub:")]
50
+ if old:
51
+ first = min(old, key=lambda r: r["ts"])
52
+ out.append({"key": "hub:unhandled", "msg": f"alert unhandled for {int((now - first['ts']) / 60)} min and the hub has not acted since "
53
+ f"({str(first.get('msg', ''))[:70]})"})
54
+ return out
55
+
56
+
57
+ def peer_checks(cfg: dict, now: float) -> list[dict]:
58
+ """A peer holding a live item sits on a permission prompt or waits for input (session status / waitingFor)."""
59
+ out = []
60
+ for owner in dict.fromkeys(i["owner"] for i in routed.items(cfg)):
61
+ sess = slow.holder_session(cfg, owner)
62
+ if not sess or not (sess.get("waitingFor") or str(sess.get("status")) in WAITING_STATES):
63
+ continue
64
+ mins = int((now - float(sess.get("statusUpdatedAt", 0)) / 1000) / 60)
65
+ if mins >= int(cfg["watcher"]["waiting_min"]):
66
+ ids = ",".join(i["id"] for i in routed.items(cfg) if i["owner"] == owner)
67
+ what = store.safe(str(sess.get("waitingFor") or sess.get("status")), 40)
68
+ out.append({"key": f"peer:{owner}:waiting", "msg": f"{owner} has waited {mins} min on {what} (holds {ids}). "
69
+ f"NEXT: ask the user to answer it in that session, or re-brief"})
70
+ return out
71
+
72
+
73
+ def liveness_checks(cfg: dict, orders: Orders) -> list[dict]:
74
+ """Watch and cron check each other: work is live and no watch runs, or cron is configured but has gone stale."""
75
+ from . import cli, gate
76
+
77
+ out = []
78
+ started = [routed.started_at(cfg, i) for i in routed.items(cfg)] + [o["opened"] for o in orders.live() if o["state"] == "open"]
79
+ grace = float(cfg["watcher"]["watch_grace_min"]) * 60 # the stop gate makes the hub arm a watch; give it a moment
80
+ if started and time.time() - min(started) > grace and not gate.watch_live(cfg):
81
+ out.append({"key": "watch:dead", "msg": f"hubkit watch is not running and work is live. NEXT: start in the background: {gate.WATCH_CMD}"})
82
+ f = store.state(cfg) / "seen" / "cron"
83
+ limit = float(cfg["watchdog"]["cron_stale_min"])
84
+ quiet_min = (time.time() - f.stat().st_mtime) / 60 if f.exists() else (time.time() - min(started)) / 60 if started else 0.0
85
+ if quiet_min > limit and cli.cron_configured(): # never written counts from the oldest live job
86
+ out.append({"key": "cron:dead", "msg": f"cron has not run hubkit for over {int(limit)} min. NEXT: check the crontab line and cron's mail"})
87
+ return out
88
+
89
+
90
+ def checks(cfg: dict, orders: Orders, now: float) -> list[dict]:
91
+ """Every watcher alert that holds now."""
92
+ return [*hub_checks(cfg, orders, now), *peer_checks(cfg, now), *liveness_checks(cfg, orders), *stale_agent_checks(cfg, now)]
93
+
94
+
95
+ def stale_agent_checks(cfg: dict, now: float) -> list[dict]:
96
+ """A background agent reported finished long ago and its item is still listed (a review item waits on its PR)."""
97
+ limit = float(cfg["watcher"]["stale_agent_min"])
98
+ return [
99
+ {"key": f"agent:{i['owner']}:stale", "msg": f"agent {i['owner']} finished {int((now - i['reported']) / 60)} min ago and {i['id']} is still listed. "
100
+ "NEXT: close the item (hubkit work done) or act on the PR it waits for"}
101
+ for i in routed.items(cfg)
102
+ if i.get("tool_use_id") and i.get("reported") and (now - float(i["reported"])) / 60 > limit
103
+ ]
104
+
105
+
106
+ def desktop_argv(msg: str, platform: str = sys.platform) -> list[str] | None:
107
+ """The default desktop notification command for this platform, or None when no tool is installed."""
108
+ if platform.startswith("linux") and shutil.which("notify-send"):
109
+ return ["notify-send", "-u", "critical", "-a", "hubkit", "hubkit", msg]
110
+ if platform == "darwin" and shutil.which("osascript"):
111
+ safe = msg.replace("\\", " ").replace('"', "'")
112
+ return ["osascript", "-e", f'display notification "{safe}" with title "hubkit"']
113
+ return None
114
+
115
+
116
+ def notify(cfg: dict, new: list[dict]) -> None:
117
+ """Tell the user's desktop about new alerts. `tools.notify_cmd` (every alert) wins; with none set, the platform's
118
+ default is used for the alerts that are about the hub or a stuck peer. Never posts to chat."""
119
+ cmd = cfg["tools"]["notify_cmd"]
120
+ if cmd:
121
+ argv: list[str] | None = [*shlex.split(cmd), "hubkit: " + "; ".join(a["msg"] for a in new)]
122
+ else:
123
+ mine = [a["msg"] for a in new if str(a["key"]).startswith(USER_KEYS)]
124
+ argv = desktop_argv("; ".join(mine)) if mine else None
125
+ if not argv or (not cmd and not cfg["watcher"]["notify_user"]):
126
+ return
127
+ try:
128
+ subprocess.run(argv, timeout=5, check=False)
129
+ except OSError, subprocess.SubprocessError:
130
+ pass
131
+
132
+
133
+ def excused(cfg: dict, item: dict, now: float) -> str | None:
134
+ """Why a silent item is not a stall: it is blocked on another live job, or its holder is visibly working
135
+ (session busy and its transcript growing). An active holder is excused for 3x the limit only."""
136
+ live = {i["id"] for i in routed.items(cfg)}
137
+ if item.get("blocked_on") in live:
138
+ return f"blocked on {item['blocked_on']}"
139
+ if (now - routed.base(cfg, item)) / 60 > 3 * routed.limit(cfg, item):
140
+ return None
141
+ owner = item["owner"]
142
+ sess = slow.holder_session(cfg, owner)
143
+ path = slow.holder_transcript(cfg, owner, sess)
144
+ if sess and str(sess.get("status")) == "busy" and path:
145
+ try:
146
+ if now - os.path.getmtime(path) < float(cfg["watcher"]["active_s"]):
147
+ return "holder is working"
148
+ except OSError:
149
+ return None
150
+ return None
151
+
152
+
153
+ def set_blocked(cfg: dict, wid: str, on: str) -> str:
154
+ """Link a routed item to the job it waits on (empty `on` clears it)."""
155
+ sd = store.state(cfg)
156
+ with store.locked(sd / "work.lock"):
157
+ items = routed.work_load(sd)
158
+ hit = next((i for i in items if i["id"] == wid and i.get("kind") == "routed"), None)
159
+ if hit is None:
160
+ raise KeyError(f"no live routed item {wid}")
161
+ if on and on == wid:
162
+ raise ValueError("an item cannot block on itself")
163
+ if on and not any(i["id"] == on for i in items):
164
+ raise KeyError(f"no live item {on}")
165
+ hit["blocked_on"] = on
166
+ routed.work_save(sd, items)
167
+ return f"{wid} blocked on {on}" if on else f"{wid} no longer blocked"
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env python3
2
+ """hubkit entry point: python3 hubkit <command>.
3
+
4
+ For `hook` runs, an import failure must never exit non-zero in any session: it is logged to a
5
+ single import-errors.log in the state dir (a failure here cannot be classified as hub or not) and
6
+ the process exits 0.
7
+ """
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
12
+ try:
13
+ from hubcore.cli import main
14
+ except BaseException as exc: # noqa: BLE001 - the hook contract is exit 0
15
+ if len(sys.argv) > 1 and sys.argv[1] == "hook":
16
+ try:
17
+ import os
18
+ import time
19
+
20
+ base = os.environ.get("HUBKIT_STATE") or os.path.join(
21
+ os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state"), "hubkit"
22
+ )
23
+ os.makedirs(base, exist_ok=True)
24
+ with open(os.path.join(base, "import-errors.log"), "a", encoding="utf-8") as fh:
25
+ msg = str(exc)
26
+ try:
27
+ from hubcore.redact import redact
28
+
29
+ msg = redact(msg)[0]
30
+ except BaseException: # the redactor may be what failed to import
31
+ msg = "(message withheld: redaction unavailable)"
32
+ fh.write(f"{time.time():.0f} {type(exc).__name__}: {msg[:200]}\n")
33
+ except BaseException:
34
+ pass
35
+ sys.exit(0)
36
+ raise
37
+ sys.exit(main())
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ """Hub learnings log: add, list, promote-candidates, address, review. Stdlib only.
3
+
4
+ Entry format: see hubcore/learnings.py. The file is state (default: <state dir>/learnings.md),
5
+ edited under a lock with an atomic replace, and every field is redacted before it is written.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from datetime import date
13
+ from pathlib import Path
14
+
15
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
16
+ from hubcore import config, learnings # noqa: E402
17
+
18
+
19
+ def main() -> int:
20
+ """Run one learn.py verb. Returns the exit code."""
21
+ p = argparse.ArgumentParser(description="Hub learnings log")
22
+ p.add_argument("--file", help="learnings file (default: from config)")
23
+ sub = p.add_subparsers(dest="cmd", required=True)
24
+ a = sub.add_parser("add", help="append an entry or a repeat of a class")
25
+ a.add_argument("--class", dest="cls", required=True)
26
+ a.add_argument("--gate", default="?")
27
+ a.add_argument("--quote", required=True, help="the user's own words")
28
+ a.add_argument("--date", default=date.today().isoformat())
29
+ for f in ("situation", "wrong", "right", "evidence"):
30
+ a.add_argument(f"--{f}", default="")
31
+ ls = sub.add_parser("list")
32
+ ls.add_argument("--last", type=int, default=30)
33
+ pc = sub.add_parser("promote-candidates")
34
+ pc.add_argument("--min", type=int, default=2)
35
+ ad = sub.add_parser("address", help="mark a class as fixed (rule promoted, hook added)")
36
+ ad.add_argument("--class", dest="cls", required=True)
37
+ ad.add_argument("--how", required=True)
38
+ ad.add_argument("--date", default=date.today().isoformat())
39
+ pm = sub.add_parser("promote", help="write a short rule into the overlay under its gate")
40
+ pm.add_argument("cls")
41
+ pm.add_argument("--gate", required=True)
42
+ pm.add_argument("--rule", required=True)
43
+ rv = sub.add_parser("review", help="new, repeated and addressed status for a day")
44
+ rv.add_argument("--day", default=date.today().isoformat())
45
+ args = p.parse_args()
46
+
47
+ cfg = config.load()
48
+ path = Path(args.file) if args.file else learnings.default_file(cfg)
49
+ try:
50
+ if args.cmd == "add":
51
+ print(learnings.add(path, args.cls, args.quote, args.date, args.gate,
52
+ situation=args.situation, wrong=args.wrong,
53
+ right=args.right, evidence=args.evidence,
54
+ basis=learnings.rules_hash(cfg)))
55
+ elif args.cmd == "list":
56
+ for b in learnings.recent(path, args.last):
57
+ print("\n".join(b).rstrip() + "\n")
58
+ elif args.cmd == "promote-candidates":
59
+ _, blocks = learnings.read_blocks(path)
60
+ hits = sorted(((len(learnings.seen_lines(b)), b) for b in blocks
61
+ if len(learnings.seen_lines(b)) >= args.min), key=lambda r: -r[0])
62
+ if not hits:
63
+ print(f"none seen >= {args.min}x")
64
+ for n, b in hits:
65
+ m = learnings.HEADER_RE.match(b[0])
66
+ print(f"{n}x {m['cls']} (gate {m['gate']})" if m else f"{n}x ?")
67
+ elif args.cmd == "address":
68
+ ov = [f for f in (learnings.overlay_path(cfg), config.SKILL_DIR / "SKILL.md") if f.exists()]
69
+ print(learnings.mark_addressed(path, args.cls, args.date, args.how, learnings.rules_hash(cfg),
70
+ max(f.stat().st_mtime for f in ov),
71
+ "\n".join(f.read_text(encoding="utf-8", errors="replace") for f in ov)))
72
+ elif args.cmd == "promote":
73
+ print(learnings.promote(path, learnings.overlay_path(cfg), args.cls, args.gate, args.rule))
74
+ elif args.cmd == "review":
75
+ for k, v in learnings.review(path, args.day).items():
76
+ print(f"{k}: {', '.join(v) or '-'}")
77
+ except (ValueError, KeyError) as e:
78
+ print(f"error: {e.args[0]}", file=sys.stderr)
79
+ return 2
80
+ return 0
81
+
82
+
83
+ if __name__ == "__main__":
84
+ sys.exit(main())
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: hub-mep
3
+ description: Optional companion to the hub skill for users of the `mep` (miseenplace) task-list CLI. Use when the hub needs the user's open items, wants to put one batched decision to the user, watches the user's chat while away, or closes the day. Needs `mep` installed; without it, ignore this skill. Thin: mep's own skills own the verbs, this says only what the hub does with them.
4
+ ---
5
+
6
+ # hub-mep
7
+
8
+ `mep` is the user's list, inbox and journal (PyPI package `miseenplace-cli`). The hub reads it and fills its queues. It never edits mep's data files, never writes into the mep data root, and calls only the CLI, with a timeout, as an argument list.
9
+
10
+ | Hub need | mep verb | Who owns the loop |
11
+ |---|---|---|
12
+ | Watch the user's chat while away | `mep watch --cursor <name>`, run by a background agent; its exit wakes the hub | the inbox-watch skill; the hub picks the cursor name and decides what each line triggers |
13
+ | Classify an inbox line | `mep triage` | the inbox-triage skill |
14
+ | What is on the user, what waits on others | `mep status --json` (also in the hubkit block, labelled UNVERIFIED when mep is slow or absent) | the journal skill; the hub only reads |
15
+ | A decision the user must make | `mep ask --recommend --default --by` | mep owns the queue; the hub fills it with one batched ask |
16
+ | A message to a person | `mep draft`, then `mep send` only on a typed yes (hub gate G3) | the hub's messaging rules (overlay) |
17
+ | Replies to review asks | the review-reply skill named in the overlay | that skill |
18
+ | Daily close | the user's own end-of-day skill for the list; `hubkit eod` for the hub's orders and learnings | both, in that order |
19
+ | Lessons about hub behaviour | `learn.py` today; `mep lessons` is the planned single store (field map: situation->when, right->do, wrong->not, evidence->evidence, seen count->votes) | the hub, until the migration is approved |
20
+
21
+ Rules
22
+ 1. Before listing a mep item as pending on the user, re-read it now (hub section 5). Items older than the status build are `UNVERIFIED`.
23
+ 2. A mep call that fails or times out is reported as `unreachable`, never as "nothing pending".
24
+ 3. Orders (clocks, evidence) live in hubkit, not in mep: mep models the user's asks, hubkit models what the hub owes while an order is open.
25
+ 4. The adapter reads the `on_you` key of `mep status --json` and checks it; a missing key shows as UNVERIFIED. Re-check with `hubkit status` after a mep upgrade.
26
+ 5. Never paste mep output that contains tokens into state or reports; hubkit redacts what it stores, but report key names only.
27
+ 6. No `mep`? Set `[mep] enabled = false` in `~/.config/hubkit/config.toml`; the hub then ignores this skill.
28
+
29
+ ## Example
30
+
31
+ The user is away and says "tell me when anyone needs a review."
32
+
33
+ 1. The hub launches a background agent: `mep watch --cursor hub-away`; Done-when: exits on a line that asks for a review; Output: that line only.
34
+ 2. The agent exits with `review asked: PR 41`. The exit wakes the hub, which routes the review to the reviewer peer (hub gate G5 applies) and records an item with `hubkit work add --kind routed --owner reviewer --title "review PR 41" --pr https://github.com/acme/repo/pull/41`.
35
+ 3. At the next report, `hubkit status` shows `on the user (mep): N`; the hub re-reads anything it lists as pending (rule 1).
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: hubkit-setup
3
+ description: Set up hubkit for the user end to end: check the install, write the config from facts found on the machine, register the hooks in Claude Code settings, bind the hub session and prove it works. Use right after `hubkit skills --install`, or for "set up hubkit", "install the hub", "hubkit doctor shows problems". Ask the user only for what cannot be found out.
4
+ ---
5
+
6
+ # hubkit-setup
7
+
8
+ Goal: a working hub in one pass. Find out; do not ask. Ask once, batched, and only for what no command or file can tell you. Never print tokens or secrets. Never edit a file without a dated backup beside it.
9
+
10
+ `hubkit` below is the command on the PATH (from `uv tool install hubkit-cli`), or `python3 <skills dir>/hub/scripts/hubkit` when the tool is not on the PATH.
11
+
12
+ ## 1. Check the install
13
+
14
+ 1. `hubkit --help` works and `python3 --version` (or the interpreter hubkit runs under) is 3.14 or newer. If not, say so and stop: hubkit needs 3.14.
15
+ 2. The hub skill is in the skills directory: `<skills dir>/hub/SKILL.md` exists. If not, run `hubkit skills --install <skills dir>` (Claude Code user level is `~/.claude/skills`).
16
+ 3. `hubkit doctor` runs. Note what it says; it is the baseline.
17
+
18
+ ## 2. Find the facts (do not ask for these)
19
+
20
+ | Fact | How to find it |
21
+ |---|---|
22
+ | The user's name | `git config user.name`, else the OS account's full name |
23
+ | Time zone | `/etc/timezone`, `timedatectl show -p Timezone`, or `python3 -c "import tzlocal"` is not needed: `readlink /etc/localtime` (macOS and Linux); on Windows `tzutil /g` |
24
+ | GitHub login | `gh api user -q .login` when `gh` is logged in, else leave it empty |
25
+ | Notification command | Linux `notify-send`, macOS `osascript`: leave `notify_cmd` empty to use the platform default; on Windows set `tools.notify_cmd` to a command the user already has, else leave it empty |
26
+ | Sessions directory | `~/.claude/sessions` exists and holds `<pid>.json` files |
27
+ | `mep` | `command -v mep`; set `[mep] enabled = false` when absent |
28
+
29
+ ## 3. Write the config
30
+
31
+ Copy `<skills dir>/hub/config.example.toml` to `~/.config/hubkit/config.toml` (the directory `$XDG_CONFIG_HOME/hubkit` when set) unless one exists; if one exists, edit only missing keys and say what you changed. Fill the facts from section 2. Add the user's real names, handles and project words to `[private] terms` only if the user wants a clean check; `hubkit doctor --check-clean` lists files that contain them.
32
+
33
+ Copy `intents.example.toml` to `intents.toml` only when the user wants to change order words or clocks; the example is used by default.
34
+
35
+ ## 4. Register the hooks
36
+
37
+ 1. `hubkit hooks --skills-dir <skills dir>` prints the `hooks` block for `~/.claude/settings.json` (SessionStart, UserPromptSubmit, PostToolUse for questions and routed work, Stop). Add `--merge-guard` for the warn-only PreToolUse merge check.
38
+ 2. Back up `~/.claude/settings.json` to `settings.json.bak-<date>`, merge the block in (keep every existing hook; a hubkit entry already there is replaced, not duplicated), and write valid JSON.
39
+ 3. Tell the user that hooks load at the next session start.
40
+
41
+ ## 5. Bind and prove it
42
+
43
+ 1. In the session that will be the hub: `hubkit bind`. It binds only an interactive session.
44
+ 2. `hubkit doctor` must show `bind: live`, `hook-errors.jsonl: 0 rows`, and no `secret-shaped strings`.
45
+ 3. Open a throwaway order and close it to prove the loop: `hubkit order open --kind merge --quote "test order"`, then `hubkit order ls`, then `hubkit order cancel <id> --quote "setup test"`.
46
+ 4. Optional watchdog: on Linux or macOS add the crontab line from the README (name the Python 3.14 interpreter); on Windows create a Task Scheduler task that runs `hubkit tick --if-bound` every 5 minutes. `hubkit doctor` then reports `cron: ok` after the first run.
47
+
48
+ ## 6. Report
49
+
50
+ At most 6 lines: what you found, what you wrote (paths), what the user must do (restart the session; nothing else), and the one command to check later: `hubkit doctor`.