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.
- hubcore/__init__.py +1 -0
- hubcore/cli.py +273 -0
- hubcore/compat.py +187 -0
- hubcore/config.py +107 -0
- hubcore/data/hub/LICENSE +202 -0
- hubcore/data/hub/SKILL.md +125 -0
- hubcore/data/hub/config.example.toml +84 -0
- hubcore/data/hub/docs/architecture.md +133 -0
- hubcore/data/hub/docs/commands.md +66 -0
- hubcore/data/hub/docs/configuration.md +151 -0
- hubcore/data/hub/docs/troubleshooting.md +21 -0
- hubcore/data/hub/intents.example.toml +74 -0
- hubcore/data/hub/references/design-sources.md +14 -0
- hubcore/data/hub/references/research.md +49 -0
- hubcore/data/hub/scripts/hubcore/__init__.py +1 -0
- hubcore/data/hub/scripts/hubcore/cli.py +273 -0
- hubcore/data/hub/scripts/hubcore/compat.py +187 -0
- hubcore/data/hub/scripts/hubcore/config.py +107 -0
- hubcore/data/hub/scripts/hubcore/gate.py +145 -0
- hubcore/data/hub/scripts/hubcore/hooks.py +326 -0
- hubcore/data/hub/scripts/hubcore/install.py +66 -0
- hubcore/data/hub/scripts/hubcore/learnings.py +232 -0
- hubcore/data/hub/scripts/hubcore/lessons.py +168 -0
- hubcore/data/hub/scripts/hubcore/orders.py +508 -0
- hubcore/data/hub/scripts/hubcore/redact.py +89 -0
- hubcore/data/hub/scripts/hubcore/routed.py +1169 -0
- hubcore/data/hub/scripts/hubcore/slow.py +531 -0
- hubcore/data/hub/scripts/hubcore/status.py +535 -0
- hubcore/data/hub/scripts/hubcore/store.py +172 -0
- hubcore/data/hub/scripts/hubcore/watcher.py +167 -0
- hubcore/data/hub/scripts/hubkit +37 -0
- hubcore/data/hub/scripts/learn.py +84 -0
- hubcore/data/hub-mep/SKILL.md +35 -0
- hubcore/data/hubkit-setup/SKILL.md +50 -0
- hubcore/gate.py +145 -0
- hubcore/hooks.py +326 -0
- hubcore/install.py +66 -0
- hubcore/learnings.py +232 -0
- hubcore/lessons.py +168 -0
- hubcore/orders.py +508 -0
- hubcore/redact.py +89 -0
- hubcore/routed.py +1169 -0
- hubcore/slow.py +531 -0
- hubcore/status.py +535 -0
- hubcore/store.py +172 -0
- hubcore/watcher.py +167 -0
- hubkit_cli-0.1.0.dist-info/METADATA +195 -0
- hubkit_cli-0.1.0.dist-info/RECORD +51 -0
- hubkit_cli-0.1.0.dist-info/WHEEL +4 -0
- hubkit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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`.
|