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
hubcore/store.py ADDED
@@ -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()
hubcore/watcher.py ADDED
@@ -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,195 @@
1
+ Metadata-Version: 2.5
2
+ Name: hubkit-cli
3
+ Version: 0.1.0
4
+ Summary: Session supervisor for Claude Code: hooks-based multi-agent orchestration with order clocks, routed-work tracking, a watchdog and a stop gate, standard library only.
5
+ Project-URL: Repository, https://github.com/ankitkpandey1/hubkit
6
+ Project-URL: Documentation, https://github.com/ankitkpandey1/hubkit/tree/main/docs
7
+ Project-URL: Issues, https://github.com/ankitkpandey1/hubkit/issues
8
+ Author: Ankit Kumar Pandey
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE
11
+ Keywords: adaptive,agent-coordination,agents,claude-code,hooks,multi-agent,orchestration,supervisor,watchdog
12
+ Requires-Python: >=3.14
13
+ Description-Content-Type: text/markdown
14
+
15
+ # hubkit
16
+
17
+ [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
18
+ ![Python 3.14+](https://img.shields.io/badge/python-3.14%2B-blue.svg)
19
+ ![Dependencies: none](https://img.shields.io/badge/dependencies-none-brightgreen.svg)
20
+
21
+ **hubkit is a session supervisor for Claude Code: a hooks-based kit for multi-agent orchestration that keeps the one session coordinating your other sessions and agents honest. It gives each order a clock, tracks work routed to peers, blocks a premature "done", and learns from your corrections.**
22
+
23
+ hubkit adapts to you. When you correct the hub ("I told you to wait for the review"), a hook notices, the hub records the correction as a class of mistake, and a class that repeats is promoted to a written rule in your own overlay file. A rule only counts as fixed after a check that the rule really exists, and it stays on probation until it has held. The words that open an urgent order, the clocks, what "done" means and every other setting live in your config files, not in the code, so the kit bends to how you work. This is a log-and-verify loop around the hub model, not machine learning: hubkit detects, stores and checks; the hub session writes the entries and rules.
24
+
25
+ ## Why use a Claude Code session supervisor
26
+
27
+ A coordinating Claude Code session routes work to peers and background agents, then forgets. After a context compaction it no longer knows which order was urgent, who was asked to review what, or whether "done" was ever proven. hubkit keeps that record outside the model's memory, gives every order a clock and an evidence check, watches routed work for silence, and blocks the hub from ending a turn while it owes something. Example session:
28
+
29
+ ```console
30
+ $ hubkit order open --kind hotfix --quote "hotfix the billing bug now"
31
+ O-261105-bd22 hotfix [open] open 0m, idle 0m, chases 0; done needs: pr_merged,prod_live
32
+ $ hubkit order move O-261105-bd22 --note "PR merged"
33
+ $ hubkit order close O-261105-bd22 --evidence pr_merged=https://github.com/acme/repo/pull/12
34
+ error: close refused: missing evidence 'prod_live'
35
+ $ hubkit order close O-261105-bd22 --evidence pr_merged=https://github.com/acme/repo/pull/12 \
36
+ --evidence prod_live=https://example.com/deploy-log
37
+ O-261105-bd22 closed
38
+ ```
39
+
40
+ In a live hub, the prompt hook opens that order itself from your sentence, and the hub only reports "done" after the close succeeds.
41
+
42
+ ## What hubkit does for multi-agent coordination
43
+
44
+ - **Learns from corrections.** A prompt hook flags correction phrases and repeated instructions. `scripts/learn.py` records, promotes and verifies them. See [docs/architecture.md](docs/architecture.md#the-learn-loop).
45
+ - **Order clock.** Orders open from your own prompts (never from peer or harness text), each with a first-action limit, a stall limit and a definition of done checked by evidence on close.
46
+ - **Routed-work tracking.** A hook on `SendMessage` and `Agent` records what the hub asked of whom, one item per (owner, PR). It follows last heard-from, moved heads and GitHub reviews at the PR head.
47
+ - **Watchdog and wake-up.** `hubkit tick` (cron) and `hubkit watch` (a background process) raise alerts with back-off; a watch exit wakes an idle hub.
48
+ - **Stop gate.** The Stop hook refuses to let the turn end while the hub owes something (no watch running, a stalled item, an unbacked verdict, a question to you that blocks nothing).
49
+ - **Status, end-of-day review.** A status block at session start and on each prompt, a daily archive, and `hubkit eod`.
50
+
51
+ `SKILL.md` is the behaviour contract the hub session reads. This file is the operator view.
52
+
53
+ ## Requirements
54
+
55
+ - Linux. hubkit has only been run on Linux; macOS and Windows code exists but has never been run (see [Platform support](#platform-support)).
56
+ - Python 3.14 or newer (the code uses `except A, B:` without parentheses, PEP 758). Standard library only.
57
+ - [Claude Code](https://code.claude.com), which provides the hooks and the skills directory.
58
+ - Optional: `gh` (GitHub CLI) on `PATH` for PR checks; without it items show "PR unknown". Optional: [`mep`](https://pypi.org/project/miseenplace-cli/) for a personal task list in the status block; without it the block shows `mep: UNVERIFIED (mep CLI not found ...)`, and `[mep] enabled = false` silences that.
59
+
60
+ ## Paste this into your agent
61
+
62
+ ```text
63
+ Install hubkit with `uv tool install hubkit-cli`, run `hubkit skills --install DIR` with the skills directory
64
+ for your tool, then run the hubkit-setup skill and follow it. Ask me only for things you cannot find out yourself.
65
+ ```
66
+
67
+ hubkit runs on Claude Code hooks, so the skills directory is Claude Code's: `~/.claude/skills` (user level) or `.claude/skills` (project level). The PyPI name `hubkit-cli` is free as of 2026-11-06 and is not published yet; until it is, install straight from GitHub with `uv tool install git+https://github.com/ankitkpandey1/hubkit` (or from a clone with `uv tool install ./hubkit`).
68
+
69
+ `hubkit skills --install DIR` copies three skills into `DIR`: `hub` (the rules and the scripts the hooks run), `hub-mep` (optional) and `hubkit-setup` (finds your facts, writes the config, registers the hooks, binds, proves it with `hubkit doctor`). Run it again after an upgrade to refresh the copies. `hubkit hooks --skills-dir DIR` prints the hooks block for `~/.claude/settings.json`.
70
+
71
+ ### Install by hand
72
+
73
+ ```bash
74
+ git clone https://github.com/ankitkpandey1/hubkit ~/.claude/skills/hub
75
+ python3 --version # 3.14 or newer
76
+ python3 ~/.claude/skills/hub/scripts/hubkit doctor
77
+ ```
78
+
79
+ The directory name must be `hub`: Claude Code finds the skill there, and the hooks and the stop gate use that path. `doctor` prints the config and state paths, the bind and the hook heartbeats; on a fresh install it shows `config: ... (defaults)` and `bind: none or dead`, which is correct.
80
+
81
+ ## Claude Code hook setup
82
+
83
+ Add the hooks to `~/.claude/settings.json` (merge with any existing `hooks` block). Each command is a shim that prints a visible line if the script is missing, then runs one hook event:
84
+
85
+ ```json
86
+ {
87
+ "hooks": {
88
+ "SessionStart": [{"hooks": [{"type": "command", "command": "[ -f $HOME/.claude/skills/hub/scripts/hubkit ] || echo 'hubkit: script missing'; python3 $HOME/.claude/skills/hub/scripts/hubkit hook session"}]}],
89
+ "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "python3 $HOME/.claude/skills/hub/scripts/hubkit hook prompt"}]}],
90
+ "PostToolUse": [
91
+ {"matcher": "AskUserQuestion", "hooks": [{"type": "command", "command": "python3 $HOME/.claude/skills/hub/scripts/hubkit hook answer"}]},
92
+ {"matcher": "SendMessage|Agent", "hooks": [{"type": "command", "timeout": 6, "command": "python3 $HOME/.claude/skills/hub/scripts/hubkit hook routed"}]}
93
+ ],
94
+ "Stop": [{"hooks": [{"type": "command", "command": "python3 $HOME/.claude/skills/hub/scripts/hubkit hook stop"}]}]
95
+ }
96
+ }
97
+ ```
98
+
99
+ Hooks do nothing, print nothing and exit 0 in every session that is not the bound hub. Then, in the session that is to be the hub (interactive sessions only):
100
+
101
+ ```bash
102
+ python3 ~/.claude/skills/hub/scripts/hubkit bind # --pid N to name the process
103
+ python3 ~/.claude/skills/hub/scripts/hubkit doctor # now shows "bind: live <name>"
104
+ ```
105
+
106
+ `hubkit unbind` frees it. For the `hubkit` command used below, add an alias such as `alias hubkit='python3 ~/.claude/skills/hub/scripts/hubkit'`.
107
+
108
+ ## Quick start
109
+
110
+ ```bash
111
+ hubkit order open --kind hotfix --quote "hotfix the billing bug now" # or let the prompt hook do it
112
+ hubkit order ls
113
+ hubkit work add --kind active --title "triage the flaky test"
114
+ hubkit status # the status block
115
+ python3 ~/.claude/skills/hub/scripts/learn.py add --class ask-before-merge --gate G5 \
116
+ --quote "I told you to wait for the review" --situation "..." --wrong "..." --right "..." --evidence "..."
117
+ ```
118
+
119
+ Optional cron line for the watchdog (a no-op unless a hub is bound or an order is open). hubkit needs Python 3.14; cron's `PATH` can put an older `python3` first (for example `/usr/local/bin`), which fails on the first import and, with output sent to `/dev/null`, leaves no trace except a stale `seen/cron`. Name the interpreter:
120
+
121
+ ```
122
+ */5 * * * * /usr/bin/python3 $HOME/.claude/skills/hub/scripts/hubkit tick --if-bound
123
+ ```
124
+
125
+ ## Configuration files
126
+
127
+ Everything personal lives outside the checkout, in `~/.config/hubkit/`:
128
+
129
+ | File | Purpose |
130
+ |---|---|
131
+ | `config.toml` | Settings. Template: [`config.example.toml`](config.example.toml). Override the path with `$HUBKIT_CONFIG`. No file means generic defaults. |
132
+ | `intents.toml` | Words that open an order, clocks, what "done" needs. Template: [`intents.example.toml`](intents.example.toml). |
133
+ | `overlay.md` | Your own rules (people, accounts, routing). The hub reads it with `SKILL.md`; promoted learnings are written here. |
134
+ | `ext/*.py` | Optional extensions: extra status lines and watchdog checks. |
135
+ | `~/.local/state/hubkit/` | State: orders, work items, alerts, archive, status. Override with `$HUBKIT_STATE`. Refused if inside a git repository. |
136
+
137
+ Every key is documented in [docs/configuration.md](docs/configuration.md).
138
+
139
+ ## What hubkit enforces and what it only warns about
140
+
141
+ | Behaviour | Strength |
142
+ |---|---|
143
+ | The Stop hook holds the turn while the hub owes something: no watch running, a stalled item, an unbacked verdict, an unsolved blocker, an item over its estimate, a "done" while your newest prompt is unread | Enforced (blocks the turn end) |
144
+ | An order closes only with the evidence its kind requires | Enforced (the close is refused) |
145
+ | A learned class counts as fixed only if a rule for it exists | Enforced (the address is refused) |
146
+ | A second job sent to a busy owner while an order is open; a brief that bans the action a blocker needs | Warning only |
147
+ | `gh pr merge` with no live order naming that PR (the merge guard, `hubkit hook pretool`) | Warning only, never denies |
148
+ | `hubkit doctor` warning when your `intents.toml` lacks keys the example has | Warning only |
149
+
150
+ Details and exact rules: [SKILL.md](SKILL.md) and [docs/architecture.md](docs/architecture.md#stop-gate-gatepy).
151
+
152
+ ## Documentation
153
+
154
+ - [docs/architecture.md](docs/architecture.md): hooks, orders, routed work, the stop gate and the learn loop, with a diagram.
155
+ - [docs/configuration.md](docs/configuration.md): every `config.toml` and `intents.toml` key.
156
+ - [docs/commands.md](docs/commands.md): every `hubkit` and `learn.py` verb.
157
+ - [docs/troubleshooting.md](docs/troubleshooting.md): common problems and what to check.
158
+ - [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), [CHANGELOG.md](CHANGELOG.md).
159
+
160
+ ## Tests
161
+
162
+ ```bash
163
+ python3 -m pytest tests -q # real subprocesses, scratch state dirs only
164
+ python3 tests/run_reverts.py # each guard reverted in a copy must fail its test (minutes)
165
+ ```
166
+
167
+ `run_reverts.py` exits 0 only when every reverted copy fails its test and every listed pattern and test exists. Install `pytest` first (`pip install pytest`).
168
+
169
+ ## Platform support
170
+
171
+ All OS-specific code is in one module, `scripts/hubcore/compat.py` (standard library only): process start time and parent (`/proc` on Linux, `ps` on macOS, kernel32 through `ctypes` on Windows), file locks (`flock`, or `msvcrt` on Windows), the scheduler check (`crontab -l`, or `schtasks /query` on Windows) and UTF-8 stdio.
172
+
173
+ | OS | Status |
174
+ |---|---|
175
+ | Linux | Full test suite run and passing here (ruff, mypy, pytest). |
176
+ | macOS | The `ps` code path is exercised on Linux against the real `ps` (`tests/test_compat.py`), and mypy checks it with `--platform darwin`. Never run on a Mac. Not claimed to work until someone runs it there. |
177
+ | Windows | The `ctypes` and `msvcrt` code is type-checked with `--platform win32` only. Never run on Windows. Tests that need a POSIX `sh` shim for `gh`, `crontab` or a notifier are skipped there. Not claimed to work until someone runs it there. There is no default desktop notification on Windows: set `tools.notify_cmd`. |
178
+
179
+ There is no CI. Update this table only from a real run on that OS. On Windows the schedule is a Task Scheduler task whose name or command holds `hubkit` (for example `schtasks /create /sc minute /mo 5 /tn hubkit-tick /tr "python %USERPROFILE%\.claude\skills\hub\scripts\hubkit tick --if-bound"`), and hooks in `settings.json` call `python ...\hubkit hook <event>`.
180
+
181
+ ## Limits and known gaps
182
+
183
+ - Python 3.14 only. macOS and Windows are written but unproven; see Platform support.
184
+ - It targets one hub per machine. A second live hub is refused at bind.
185
+ - Order creation takes a blocking `flock` (`scripts/hubcore/orders.py`); a process that holds the lock forever stalls order opens.
186
+ - A hook payload with no `origin` field is treated as human input.
187
+ - Redaction matches secret shapes and `key=value` forms. Natural-language secrets ("the password is hunter2") are not caught. See [SECURITY.md](SECURITY.md).
188
+ - Whether a message to a peer is an ask, a reply or a follow-up is decided by rules in `scripts/hubcore/routed.py`; an unusual message can be misjudged.
189
+ - The learn loop needs the hub model to act on the `CORRECTION` line; hubkit cannot make it record or write a rule, only refuse to mark a class fixed without one.
190
+ - `skills/hub-mep` describes an optional integration with the `mep` task-list CLI and is of no use without it.
191
+ - Hooks are written against the Claude Code hook payloads of late 2026; `payload-samples/` in the state dir records the real payload keys so a schema change is visible.
192
+
193
+ ## License
194
+
195
+ Apache License 2.0. See [LICENSE](LICENSE). Copyright 2026 the hubkit contributors.
@@ -0,0 +1,51 @@
1
+ hubcore/__init__.py,sha256=IyQZIMUEIvq505jdiUulXi0cbQHQ93shqO0_2L0yPMo,79
2
+ hubcore/cli.py,sha256=ms5bvHnI95ml2XO9PdWAHfKQVK25pFsUhVS8FG99cwE,12336
3
+ hubcore/compat.py,sha256=yCCV2IA-T2vxTYe1Fp47qkCXREJb4OmSzv6OyiNuBjU,6700
4
+ hubcore/config.py,sha256=qN_ewJJrt99FCcWzCx1Tf_nDacSJfiT4nFknRx0641U,4611
5
+ hubcore/gate.py,sha256=mENEHwD8dMWSjef28rz4Ug0T2QepsSWfHoalDp__JVA,8124
6
+ hubcore/hooks.py,sha256=okuNkqj0uvKaAfPeagL91-p1j6qngK1zPQB6Bgu9Eww,14757
7
+ hubcore/install.py,sha256=VBgLXi28spfEn5By2GzSUmeYbikDmexlhlBsqzm0hK0,3073
8
+ hubcore/learnings.py,sha256=1Sm5XaokUcKBCrb7vw9FLsf9GZFRr7j68cYgbt4MMeo,9257
9
+ hubcore/lessons.py,sha256=Nvetkk10bcDGygSyPYFE1GddGQ2pMRzdNA2ViQT-OqA,7867
10
+ hubcore/orders.py,sha256=oHU45Gem5bQlXHGWJKMkh4Wf3juSxJLJ1jVQTLn9xMk,24779
11
+ hubcore/redact.py,sha256=sT4tSvUbDd7FqYHJ0-deLEub52opMpgYc8KTxFKTimU,3322
12
+ hubcore/routed.py,sha256=eA3Wv8hOcmcIlbzLZntHwUxCu97apXcMs12W95qT-h8,50013
13
+ hubcore/slow.py,sha256=lKHqyUOXKKLilQwtnbR1IUZ6OgPO4L9zIfZBomglcOw,20597
14
+ hubcore/status.py,sha256=jzG-EtE3uCu2Ezwvj9DZiZwCl91sok1kYc1koXES0R4,23452
15
+ hubcore/store.py,sha256=EzDzNZcCw-xUorNxXKZNyfBoLlYG5M5hrffbMfOg4zI,5647
16
+ hubcore/watcher.py,sha256=pjalPYGF_6W1QzLjMw_iC1SIaP1oW8r9aGkrxQeQudE,8454
17
+ hubcore/data/hub-mep/SKILL.md,sha256=Z1BV-MezlAoW02KDPsb4Va3eAL9nruFi70GdCh5KhsE,3302
18
+ hubcore/data/hubkit-setup/SKILL.md,sha256=0O_ji8sJzgZGOE1LX3m_BQRKCU-UxFdbS3WEOqhia3w,3961
19
+ hubcore/data/hub/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
20
+ hubcore/data/hub/SKILL.md,sha256=pG9wkwZBAAsBiwx55A4Jx2ktzZiQpiK0tS5nCoRM9PQ,17053
21
+ hubcore/data/hub/config.example.toml,sha256=Sot5NKXtN-Uxco_WAWxHTpqpQbbxP-Lv0NbIjjZ3Sqs,4884
22
+ hubcore/data/hub/docs/architecture.md,sha256=KZZHvzAxHd9jhV3RnzK7vWKi3hlvC2isiL7g2XXtefU,16764
23
+ hubcore/data/hub/docs/commands.md,sha256=Yelxi785rv9f3DoKljIItqY8n9iCGFzfo4Ky_WbKJhQ,5632
24
+ hubcore/data/hub/docs/configuration.md,sha256=blhBZpzmsi0MnZWK2aZVb4t7Vf-iWgAfmGOTZpiZ0-s,9150
25
+ hubcore/data/hub/docs/troubleshooting.md,sha256=SPonaqwdj3Uu9UXfYxQO33HL1c-SxGc67MqHjx0FGpw,3469
26
+ hubcore/data/hub/intents.example.toml,sha256=zkCGO-zp8YL4E1fhdulBK313xeMyNz-2gbdy6Xd9XCI,4323
27
+ hubcore/data/hub/references/design-sources.md,sha256=i53AaQsJFnZLFXAJoWZxaCEy918BFf0neqvJDyNWJ_k,1607
28
+ hubcore/data/hub/references/research.md,sha256=SDKPp3XDnbI20M1fNEZBpPw26zaV8D6Yewf-Aj7VAM0,3523
29
+ hubcore/data/hub/scripts/hubcore/__init__.py,sha256=IyQZIMUEIvq505jdiUulXi0cbQHQ93shqO0_2L0yPMo,79
30
+ hubcore/data/hub/scripts/hubcore/cli.py,sha256=ms5bvHnI95ml2XO9PdWAHfKQVK25pFsUhVS8FG99cwE,12336
31
+ hubcore/data/hub/scripts/hubcore/compat.py,sha256=yCCV2IA-T2vxTYe1Fp47qkCXREJb4OmSzv6OyiNuBjU,6700
32
+ hubcore/data/hub/scripts/hubcore/config.py,sha256=qN_ewJJrt99FCcWzCx1Tf_nDacSJfiT4nFknRx0641U,4611
33
+ hubcore/data/hub/scripts/hubcore/gate.py,sha256=mENEHwD8dMWSjef28rz4Ug0T2QepsSWfHoalDp__JVA,8124
34
+ hubcore/data/hub/scripts/hubcore/hooks.py,sha256=okuNkqj0uvKaAfPeagL91-p1j6qngK1zPQB6Bgu9Eww,14757
35
+ hubcore/data/hub/scripts/hubcore/install.py,sha256=VBgLXi28spfEn5By2GzSUmeYbikDmexlhlBsqzm0hK0,3073
36
+ hubcore/data/hub/scripts/hubcore/learnings.py,sha256=1Sm5XaokUcKBCrb7vw9FLsf9GZFRr7j68cYgbt4MMeo,9257
37
+ hubcore/data/hub/scripts/hubcore/lessons.py,sha256=Nvetkk10bcDGygSyPYFE1GddGQ2pMRzdNA2ViQT-OqA,7867
38
+ hubcore/data/hub/scripts/hubcore/orders.py,sha256=oHU45Gem5bQlXHGWJKMkh4Wf3juSxJLJ1jVQTLn9xMk,24779
39
+ hubcore/data/hub/scripts/hubcore/redact.py,sha256=sT4tSvUbDd7FqYHJ0-deLEub52opMpgYc8KTxFKTimU,3322
40
+ hubcore/data/hub/scripts/hubcore/routed.py,sha256=eA3Wv8hOcmcIlbzLZntHwUxCu97apXcMs12W95qT-h8,50013
41
+ hubcore/data/hub/scripts/hubcore/slow.py,sha256=lKHqyUOXKKLilQwtnbR1IUZ6OgPO4L9zIfZBomglcOw,20597
42
+ hubcore/data/hub/scripts/hubcore/status.py,sha256=jzG-EtE3uCu2Ezwvj9DZiZwCl91sok1kYc1koXES0R4,23452
43
+ hubcore/data/hub/scripts/hubcore/store.py,sha256=EzDzNZcCw-xUorNxXKZNyfBoLlYG5M5hrffbMfOg4zI,5647
44
+ hubcore/data/hub/scripts/hubcore/watcher.py,sha256=pjalPYGF_6W1QzLjMw_iC1SIaP1oW8r9aGkrxQeQudE,8454
45
+ hubcore/data/hub/scripts/hubkit,sha256=ipNoQHb3kntnBFQ_qct9ytR5AM7c1qR45Vp6DBrr6_4,1405
46
+ hubcore/data/hub/scripts/learn.py,sha256=x_XMSy59ar-bbhP0viD0-TKBLFY_KxQLnfhiWmzDLh8,4036
47
+ hubkit_cli-0.1.0.dist-info/METADATA,sha256=8JhDlWxxDvcHvj7Rf2hrIY10NXQTLS0iJ46CQYGswh4,14277
48
+ hubkit_cli-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
49
+ hubkit_cli-0.1.0.dist-info/entry_points.txt,sha256=gxPYc8e-YUz4olwwJQwyblb4oXmGki5dYpNKtAGPW3w,43
50
+ hubkit_cli-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
51
+ hubkit_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hubkit = hubcore.cli:run