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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """hubkit core: orders, status, watchdog, hooks. Stdlib only, Python 3.11+."""
hubcore/cli.py ADDED
@@ -0,0 +1,273 @@
1
+ """hubkit command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import time
9
+ import tomllib
10
+ from pathlib import Path
11
+
12
+ from . import compat, config, hooks, install, lessons, routed, status, store, watcher
13
+ from .orders import Orders
14
+ from .redact import count_hits_text
15
+
16
+
17
+ def kv(pairs: list[str]) -> dict[str, str]:
18
+ """Parse repeated NAME=VALUE arguments into a dict."""
19
+ out = {}
20
+ for p in pairs or []:
21
+ k, _, v = p.partition("=")
22
+ out[k.strip()] = v.strip()
23
+ return out
24
+
25
+
26
+ def cmd_order(a, cfg: dict) -> int:
27
+ """Run an `order` verb; exit 2 with a message on an unknown id or refused action."""
28
+ o = Orders(cfg)
29
+ act = a.action
30
+ try:
31
+ if act == "ls":
32
+ for x in o.live():
33
+ print(o.status_line(x))
34
+ elif act == "status":
35
+ items = [o.get(a.id)] if a.id else o.live()
36
+ for x in items:
37
+ print(o.status_line(x))
38
+ elif act == "open":
39
+ x = o.open(a.kind, a.quote, state="unconfirmed" if a.unconfirmed else "open")
40
+ print(o.status_line(x))
41
+ elif act == "confirm":
42
+ o.confirm(a.id)
43
+ elif act == "move":
44
+ msg = o.move(a.id, a.note, a.estimate)
45
+ if msg:
46
+ print(msg)
47
+ elif act == "block":
48
+ print(o.block(a.id, a.on, a.fix, a.holder))
49
+ elif act == "result":
50
+ o.result(a.id, a.note)
51
+ elif act == "hold":
52
+ o.hold(a.id, a.quote)
53
+ elif act == "resume":
54
+ o.resume(a.id)
55
+ elif act == "cancel":
56
+ o.cancel(a.id, a.quote)
57
+ elif act == "close":
58
+ print(o.close(a.id, kv(a.evidence)))
59
+ elif act == "metrics":
60
+ print(json.dumps(o.metrics(a.day or store.day_of(cfg, time.time()))))
61
+ except (KeyError, ValueError) as e:
62
+ print(f"error: {e.args[0] if e.args else e}", file=sys.stderr)
63
+ return 2
64
+ return 0
65
+
66
+
67
+ def cmd_work(a, cfg: dict) -> int:
68
+ """Run a `work` verb (add, done, ls)."""
69
+ try:
70
+ if a.action == "add" and a.kind == "routed":
71
+ if not (a.owner and a.title):
72
+ raise ValueError("routed items need --owner and --title")
73
+ prs = routed.refs(cfg, " ".join(a.pr))
74
+ heads = {u: a.head for u in prs} if a.head else {}
75
+ print(routed.record(cfg, a.owner, a.title, prs, heads, review=routed.is_review(cfg, a.title + " " + " ".join(a.pr)), estimate=a.estimate)["id"])
76
+ elif a.action == "add":
77
+ print(status.work_add(cfg, a.kind, a.title, a.owner, a.note, a.estimate)["id"])
78
+ elif a.action == "block":
79
+ print(watcher.set_blocked(cfg, a.id, a.on))
80
+ elif a.action == "estimate":
81
+ from . import slow
82
+
83
+ print(slow.re_estimate(cfg, a.id, a.minutes or 0, a.why))
84
+ elif a.action == "done":
85
+ print(status.work_done(cfg, a.id, a.note))
86
+ else:
87
+ for i in status.work_load(store.state(cfg)):
88
+ print(f"{i['id']} [{i['kind']}] {i['title']}")
89
+ except (KeyError, ValueError) as e:
90
+ print(f"error: {e.args[0]}", file=sys.stderr)
91
+ return 2
92
+ return 0
93
+
94
+
95
+ def cron_configured() -> bool:
96
+ """True when the user's scheduler (crontab, or Task Scheduler on Windows) holds an active entry that runs hubkit."""
97
+ return any("hubkit" in x for x in compat.scheduler_lines())
98
+
99
+
100
+ def cron_line(cfg: dict, sd: Path) -> str:
101
+ """The `cron:` doctor line. When cron is configured, a `seen/cron` older than `watchdog.cron_stale_min` (or
102
+ never written) is flagged: every tick run touches it first, so a stale one means cron is not running hubkit."""
103
+ f = sd / "seen" / "cron"
104
+ if not cron_configured():
105
+ return f"cron: no hubkit line in the {compat.scheduler_name()}"
106
+ age = (time.time() - f.stat().st_mtime) / 60 if f.exists() else None
107
+ if age is None or age > float(cfg["watchdog"]["cron_stale_min"]):
108
+ shown = "never ran" if age is None else f"last ran {int(age)} min ago"
109
+ return f"cron: STALE: a hubkit {compat.scheduler_name()} entry exists but {shown} (limit {cfg['watchdog']['cron_stale_min']} min); check cron's mail and that the line runs"
110
+ return "cron: ok"
111
+
112
+
113
+ def intents_gaps(cfg: dict) -> list[str]:
114
+ """Sections and keys the shipped intents.example.toml has and the user's own intents.toml lacks (a new release adds checks the old file never turns on)."""
115
+ mine = config.intents_path(cfg)
116
+ example = config.SKILL_DIR / "intents.example.toml"
117
+ if mine == example or not mine.exists() or not example.exists():
118
+ return []
119
+ have, want = tomllib.loads(mine.read_text(encoding="utf-8")), tomllib.loads(example.read_text(encoding="utf-8"))
120
+ gaps = [f"[{sec}]" for sec in want if sec not in have]
121
+ gaps += [f"[{sec}] {key}" for sec in want if sec in have and sec != "kinds" for key in want[sec] if key not in have[sec]]
122
+ return gaps
123
+
124
+
125
+ def cmd_doctor(a, cfg: dict) -> int:
126
+ """Print config, bind, hook heartbeat and state health; exit 1 on secret-shaped strings or private terms."""
127
+ sd = store.state(cfg)
128
+ problems = 0
129
+ print(f"config: {cfg['_config_path']} ({'found' if Path(cfg['_config_path']).exists() else 'defaults'})")
130
+ print(f"state: {sd}")
131
+ b = hooks.read_bind(cfg)
132
+ print(f"bind: {'live ' + str(b.get('name')) if hooks.bind_alive(b) else 'none or dead'}")
133
+ for name in ("session", "prompt", "answer", "routed", "stop", "tick", "cron"):
134
+ f = sd / "seen" / name
135
+ print(f"seen/{name}: {int((time.time() - f.stat().st_mtime) / 60)} min ago" if f.exists() else f"seen/{name}: never")
136
+ print(cron_line(cfg, sd))
137
+ rows, bad = store.read_rows(sd / "orders.jsonl")
138
+ print(f"orders.jsonl: {len(rows)} rows, {bad} bad lines")
139
+ errs = store.read_rows(sd / "hook-errors.jsonl")[0]
140
+ print(f"hook-errors.jsonl: {len(errs)} rows")
141
+ gaps = intents_gaps(cfg)
142
+ if gaps:
143
+ print(f"WARNING intents.toml lacks what intents.example.toml has: {', '.join(gaps)}. Copy those lines from the example; the checks they configure use built-in defaults or stay off.")
144
+ print(f"transcript scan: {status.hook_errors(cfg)}")
145
+ hits = sum(count_hits_text(p.read_text(encoding="utf-8", errors="replace")) for p in sd.rglob("*.jsonl"))
146
+ print(f"secret-shaped strings in state (count only): {hits}")
147
+ problems += hits
148
+ if a.check_clean:
149
+ terms = [t.lower() for t in cfg["private"]["terms"]]
150
+ dirty = []
151
+ for p in config.SKILL_DIR.rglob("*"):
152
+ if p.is_file() and not {"__pycache__", ".pytest_cache", ".git", ".ruff_cache", ".mypy_cache"} & set(p.relative_to(config.SKILL_DIR).parts) \
153
+ and p.suffix in (".py", ".md", ".toml", ".txt", ".yml", ".yaml", ".gitignore", ""):
154
+ low = p.read_text(encoding="utf-8", errors="replace").lower()
155
+ dirty += [str(p.relative_to(config.SKILL_DIR)) for t in terms if t and t in low]
156
+ print(f"private terms in skill files (names only, no matches shown): {sorted(set(dirty)) or 'none'} [{len(terms)} terms]")
157
+ problems += len(dirty)
158
+ return 1 if problems else 0
159
+
160
+
161
+ def run() -> None:
162
+ """Console-script entry point (`uv tool install hubkit-cli`)."""
163
+ sys.exit(main())
164
+
165
+
166
+ def main(argv: list[str] | None = None) -> int:
167
+ """Parse argv and dispatch one verb. Returns the process exit code."""
168
+ compat.utf8_stdio()
169
+ p = argparse.ArgumentParser(prog="hubkit")
170
+ sub = p.add_subparsers(dest="cmd", required=True)
171
+
172
+ h = sub.add_parser("hook")
173
+ h.add_argument("event", choices=sorted(hooks.HOOKS))
174
+
175
+ b = sub.add_parser("bind")
176
+ b.add_argument("--pid", type=int)
177
+ sub.add_parser("unbind")
178
+
179
+ sk = sub.add_parser("skills")
180
+ sk.add_argument("--install", metavar="DIR", required=True, help="the skills directory of your agent tool, for example ~/.claude/skills")
181
+
182
+ hk = sub.add_parser("hooks")
183
+ hk.add_argument("--skills-dir", default="$HOME/.claude/skills")
184
+ hk.add_argument("--merge-guard", action="store_true", help="include the warn-only PreToolUse merge check")
185
+
186
+ k = sub.add_parser("asks")
187
+ k.add_argument("action", choices=["ls"])
188
+ k.add_argument("--since", default="12h", help="how far back, for example 30m, 12h, 2d")
189
+
190
+ o = sub.add_parser("order")
191
+ o.add_argument("action", choices=["ls", "status", "open", "confirm", "move", "block", "result", "hold", "resume", "cancel", "close", "metrics"])
192
+ o.add_argument("id", nargs="?")
193
+ o.add_argument("--kind")
194
+ o.add_argument("--quote", default="")
195
+ o.add_argument("--note", default="")
196
+ o.add_argument("--on", default="", help="block: what the order is blocked on")
197
+ o.add_argument("--fix", default="", help="block: the action running now to clear it")
198
+ o.add_argument("--holder", default="", help="block: who runs the fix")
199
+ o.add_argument("--evidence", action="append", default=[], help="name=value (value 'file:<path>' is checked)")
200
+ o.add_argument("--day")
201
+ o.add_argument("--unconfirmed", action="store_true")
202
+ o.add_argument("--estimate", type=int, help="move: minutes this order should take (default: the usual time)")
203
+
204
+ w = sub.add_parser("work")
205
+ w.add_argument("action", choices=["add", "done", "ls", "estimate", "block"])
206
+ w.add_argument("id", nargs="?")
207
+ w.add_argument("--kind", default="active")
208
+ w.add_argument("--title", default="")
209
+ w.add_argument("--owner", default="")
210
+ w.add_argument("--note", default="")
211
+ w.add_argument("--pr", action="append", default=[], help="routed: PR URL (repeatable)")
212
+ w.add_argument("--estimate", type=int, help="minutes this job should take")
213
+ w.add_argument("--minutes", type=int, help="estimate: the new total minutes")
214
+ w.add_argument("--on", default="", help="block: the job this one waits on (empty clears)")
215
+ w.add_argument("--why", default="", help="estimate: the reason for the new estimate")
216
+ w.add_argument("--head", default="", help="routed: head sha the work was routed at")
217
+
218
+ t = sub.add_parser("tick")
219
+ t.add_argument("--no-notify", action="store_true")
220
+ t.add_argument("--if-bound", action="store_true", help="no-op unless a hub is bound or orders are open (for cron)")
221
+ wt = sub.add_parser("watch")
222
+ wt.add_argument("--interval", type=float, default=300)
223
+ wt.add_argument("--max-cycles", type=int, default=12)
224
+
225
+ st = sub.add_parser("status")
226
+ st.add_argument("--json", action="store_true")
227
+ e = sub.add_parser("eod")
228
+ e.add_argument("--day")
229
+ e.add_argument("--note", default="")
230
+
231
+ d = sub.add_parser("doctor")
232
+ d.add_argument("--check-clean", action="store_true")
233
+
234
+ a = p.parse_args(argv)
235
+ if a.cmd == "hook":
236
+ return hooks.main(a.event)
237
+ cfg = config.load()
238
+ try:
239
+ if a.cmd == "bind":
240
+ print(hooks.bind(cfg, a.pid))
241
+ elif a.cmd == "unbind":
242
+ print(hooks.unbind(cfg))
243
+ elif a.cmd == "order":
244
+ return cmd_order(a, cfg)
245
+ elif a.cmd == "work":
246
+ return cmd_work(a, cfg)
247
+ elif a.cmd == "tick":
248
+ store.touch_seen(store.state(cfg), "cron")
249
+ if a.if_bound and not (hooks.bind_alive(hooks.read_bind(cfg)) or watcher.live_jobs(cfg, Orders(cfg))):
250
+ return 0
251
+ for x in status.tick(cfg, notify=not a.no_notify):
252
+ print("ALERT " + x["msg"])
253
+ elif a.cmd == "skills":
254
+ print("\n".join(install.install_skills(Path(a.install).expanduser())))
255
+ elif a.cmd == "hooks":
256
+ print(install.hooks_block(a.skills_dir, a.merge_guard))
257
+ elif a.cmd == "asks":
258
+ print("\n".join(lessons.asks_lines(cfg, a.since)))
259
+ elif a.cmd == "watch":
260
+ return status.watch(cfg, a.interval, a.max_cycles)
261
+ elif a.cmd == "status":
262
+ st_ = status.write_status(cfg)
263
+ print(json.dumps(st_, indent=1) if a.json else status.render_block(cfg, Orders(cfg), st_))
264
+ elif a.cmd == "eod":
265
+ day = a.day or store.day_of(cfg, time.time())
266
+ out = status.eod(cfg, day, a.note)
267
+ print(out.read_text(encoding="utf-8"), end="")
268
+ elif a.cmd == "doctor":
269
+ return cmd_doctor(a, cfg)
270
+ except (RuntimeError, ValueError, store.StateError) as err:
271
+ print(f"error: {err}", file=sys.stderr)
272
+ return 2
273
+ return 0
hubcore/compat.py ADDED
@@ -0,0 +1,187 @@
1
+ """The one place that knows which operating system it runs on (stdlib only).
2
+
3
+ Linux reads /proc; macOS and other POSIX systems ask `ps`; Windows asks kernel32 through ctypes. File locks are
4
+ flock on POSIX and msvcrt on Windows. The scheduler check is crontab on POSIX and Task Scheduler on Windows.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+
15
+ _PROC = Path("/proc/self/stat")
16
+
17
+
18
+ # ---- process identity: (pid, start time) names one process for as long as it lives ----
19
+
20
+
21
+ def _ps(*args: str) -> str | None:
22
+ """Output of `ps` (POSIX without /proc, e.g. macOS) or None when it fails or prints nothing."""
23
+ try:
24
+ r = subprocess.run(["ps", *args], capture_output=True, encoding="utf-8", errors="replace", timeout=5, check=False, env={**os.environ, "LC_ALL": "C"})
25
+ except OSError, subprocess.SubprocessError:
26
+ return None
27
+ out = r.stdout.strip()
28
+ return out if r.returncode == 0 and out else None
29
+
30
+
31
+ def _win_creation_time(pid: int) -> str | None:
32
+ if sys.platform != "win32":
33
+ raise OSError("Windows only")
34
+ import ctypes
35
+ from ctypes import wintypes
36
+
37
+ k32 = ctypes.WinDLL("kernel32", use_last_error=True)
38
+ k32.OpenProcess.restype = wintypes.HANDLE
39
+ k32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
40
+ k32.GetProcessTimes.argtypes = [wintypes.HANDLE, *([ctypes.POINTER(wintypes.FILETIME)] * 4)]
41
+ k32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
42
+ k32.CloseHandle.argtypes = [wintypes.HANDLE]
43
+ handle = k32.OpenProcess(0x1000 | 0x100000, False, pid) # QUERY_LIMITED_INFORMATION | SYNCHRONIZE
44
+ if not handle:
45
+ return None
46
+ try:
47
+ created, exited, kernel, user = (wintypes.FILETIME() for _ in range(4))
48
+ if not k32.GetProcessTimes(handle, *(ctypes.byref(t) for t in (created, exited, kernel, user))):
49
+ return None
50
+ if k32.WaitForSingleObject(handle, 0) == 0: # signalled: the process has ended
51
+ return None
52
+ return str((created.dwHighDateTime << 32) | created.dwLowDateTime)
53
+ finally:
54
+ k32.CloseHandle(handle)
55
+
56
+
57
+ def proc_start(pid: int) -> str | None:
58
+ """An opaque start-time token for a live process, or None when it is gone. Equal tokens mean the same process."""
59
+ if sys.platform == "win32":
60
+ return _win_creation_time(pid)
61
+ if _PROC.exists():
62
+ try:
63
+ return Path(f"/proc/{pid}/stat").read_text(encoding="utf-8").rsplit(")", 1)[1].split()[19]
64
+ except (OSError, IndexError):
65
+ return None
66
+ return _ps("-o", "lstart=", "-p", str(pid))
67
+
68
+
69
+ def _win_parent(pid: int) -> int | None:
70
+ if sys.platform != "win32":
71
+ raise OSError("Windows only")
72
+ import ctypes
73
+ from ctypes import wintypes
74
+
75
+ class Entry(ctypes.Structure):
76
+ _fields_ = [
77
+ ("dwSize", wintypes.DWORD),
78
+ ("cntUsage", wintypes.DWORD),
79
+ ("th32ProcessID", wintypes.DWORD),
80
+ ("th32DefaultHeapID", ctypes.c_void_p),
81
+ ("th32ModuleID", wintypes.DWORD),
82
+ ("cntThreads", wintypes.DWORD),
83
+ ("th32ParentProcessID", wintypes.DWORD),
84
+ ("pcPriClassBase", ctypes.c_long),
85
+ ("dwFlags", wintypes.DWORD),
86
+ ("szExeFile", ctypes.c_char * 260),
87
+ ]
88
+
89
+ k32 = ctypes.WinDLL("kernel32", use_last_error=True)
90
+ k32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
91
+ k32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
92
+ k32.Process32First.argtypes = k32.Process32Next.argtypes = [wintypes.HANDLE, ctypes.POINTER(Entry)]
93
+ k32.CloseHandle.argtypes = [wintypes.HANDLE]
94
+ snap = k32.CreateToolhelp32Snapshot(0x2, 0) # TH32CS_SNAPPROCESS
95
+ if snap in (None, wintypes.HANDLE(-1).value):
96
+ return None
97
+ try:
98
+ entry = Entry()
99
+ entry.dwSize = ctypes.sizeof(Entry)
100
+ ok = k32.Process32First(snap, ctypes.byref(entry))
101
+ while ok:
102
+ if entry.th32ProcessID == pid:
103
+ return int(entry.th32ParentProcessID)
104
+ ok = k32.Process32Next(snap, ctypes.byref(entry))
105
+ return None
106
+ finally:
107
+ k32.CloseHandle(snap)
108
+
109
+
110
+ def parent_pid(pid: int) -> int | None:
111
+ """Parent of a process, or None when unknown."""
112
+ if sys.platform == "win32":
113
+ return _win_parent(pid)
114
+ if _PROC.exists():
115
+ try:
116
+ return int(Path(f"/proc/{pid}/stat").read_text(encoding="utf-8").rsplit(")", 1)[1].split()[1])
117
+ except (OSError, IndexError, ValueError):
118
+ return None
119
+ out = _ps("-o", "ppid=", "-p", str(pid))
120
+ return int(out) if out and out.isdigit() else None
121
+
122
+
123
+ # ---- file lock: held until the descriptor is closed ----
124
+
125
+
126
+ def lock_exclusive(fd: int, wait: bool) -> bool:
127
+ """Take an exclusive lock on an open file. With wait, blocks until held; without, returns False when it is taken."""
128
+ if sys.platform == "win32":
129
+ import msvcrt
130
+
131
+ while True:
132
+ try:
133
+ os.lseek(fd, 0, os.SEEK_SET)
134
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
135
+ return True
136
+ except OSError:
137
+ if not wait:
138
+ return False
139
+ time.sleep(0.05)
140
+ import fcntl
141
+
142
+ try:
143
+ fcntl.flock(fd, fcntl.LOCK_EX | (0 if wait else fcntl.LOCK_NB))
144
+ return True
145
+ except BlockingIOError:
146
+ return False
147
+
148
+
149
+ def unlock(fd: int) -> None:
150
+ """Release a lock taken with lock_exclusive."""
151
+ if sys.platform == "win32":
152
+ import msvcrt
153
+
154
+ os.lseek(fd, 0, os.SEEK_SET)
155
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
156
+ return
157
+ import fcntl
158
+
159
+ fcntl.flock(fd, fcntl.LOCK_UN)
160
+
161
+
162
+ # ---- the user's scheduler ----
163
+
164
+
165
+ def scheduler_name() -> str:
166
+ return "Task Scheduler" if sys.platform == "win32" else "crontab"
167
+
168
+
169
+ def scheduler_lines() -> list[str]:
170
+ """Active scheduled entries as text lines (crontab lines, or Task Scheduler rows). Unreadable counts as none."""
171
+ argv = ["schtasks", "/query", "/fo", "csv", "/nh"] if sys.platform == "win32" else ["crontab", "-l"]
172
+ try:
173
+ out = subprocess.run(argv, capture_output=True, encoding="utf-8", errors="replace", timeout=10, check=False).stdout
174
+ except OSError, subprocess.SubprocessError:
175
+ return []
176
+ return [x for x in out.splitlines() if not x.lstrip().startswith("#")]
177
+
178
+
179
+ # ---- text I/O ----
180
+
181
+
182
+ def utf8_stdio() -> None:
183
+ """Hooks exchange UTF-8 JSON; on Windows the default console code page would mangle it."""
184
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
185
+ reconf = getattr(stream, "reconfigure", None)
186
+ if reconf:
187
+ reconf(encoding="utf-8", errors="replace")
hubcore/config.py ADDED
@@ -0,0 +1,107 @@
1
+ """Config loading. All user-specific values come from one TOML file.
2
+
3
+ Search order: $HUBKIT_CONFIG, $XDG_CONFIG_HOME/hubkit/config.toml, ~/.config/hubkit/config.toml.
4
+ Missing file means built-in defaults (generic, nothing personal).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import tomllib
11
+ from pathlib import Path
12
+
13
+ _HERE = Path(__file__).resolve().parent
14
+ # A clone or an installed skill directory sits two levels above hubcore; the wheel carries the same tree as package data.
15
+ SKILL_DIR = _HERE / "data" / "hub" if (_HERE / "data" / "hub").is_dir() else _HERE.parent.parent
16
+ ENV_PREFIX = "HUBKIT"
17
+
18
+ DEFAULTS: dict = {
19
+ "user": {"name": "the user", "timezone": "", "github_login": ""},
20
+ "hub": {"name": "hub"},
21
+ "paths": {
22
+ "state_dir": "", # default: $XDG_STATE_HOME/hubkit
23
+ "intents": "", # default: <config dir>/intents.toml, else skill example
24
+ "overlay": "", # default: <config dir>/overlay.md
25
+ "learnings": "", # default: <state dir>/learnings.md
26
+ "sessions_dir": "~/.claude/sessions",
27
+ "transcripts_glob": "~/.claude/projects/*/*.jsonl",
28
+ "extensions_dir": "", # default: <config dir>/ext
29
+ },
30
+ "tools": {"mep": "mep", "notify_cmd": "", "gh": "gh"},
31
+ "routed": {"stall_min": 15, "min_chars": 200, "skip_owners": ["inbox"], "gh_timeout_s": 10,
32
+ "repo": "", "reply_window_min": 60, "queued_wait_min": 60, "owner_logins": {}, "skip_briefs": ["mep watch", "inbox watch"],
33
+ "activity_globs": [
34
+ "~/.claude/projects/*/*/subagents/agent-{id}.jsonl",
35
+ "/tmp/claude-*/*/*/tasks/{id}.output",
36
+ ], "reviewer_logins": [], "review_words": ["review"], "review_stall_min": 5, "review_overdue_min": 10},
37
+ "slow": {"min_samples": 3, "order_default_min": 30, "floor_min": 2, "tool_min": 2, "scan_kb": 2048},
38
+ "watcher": {"stale_agent_min": 30, "stuck_min": 30, "unhandled_min": 10, "waiting_min": 3, "active_s": 120, "watch_grace_min": 5, "notify_user": True},
39
+ "mep": {"enabled": True, "timeout_s": 30},
40
+ "watchdog": {"transcript_scan_hours": 24, "cron_stale_min": 15},
41
+ "think": {"decisions": ""}, # path of the active slug's decisions.md; its last lines go in the status block
42
+ "stop_guard": {"phrases": ["waits for your go", "say go", "want me to", "shall i"]},
43
+ "private": {"terms": []},
44
+ }
45
+
46
+
47
+ def config_dir() -> Path:
48
+ """Directory holding the user's config files ($XDG_CONFIG_HOME/hubkit)."""
49
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
50
+ return Path(base) / "hubkit"
51
+
52
+
53
+ def config_path() -> Path:
54
+ """Path of config.toml: $HUBKIT_CONFIG, else <config dir>/config.toml."""
55
+ env = os.environ.get(f"{ENV_PREFIX}_CONFIG")
56
+ return Path(env).expanduser() if env else config_dir() / "config.toml"
57
+
58
+
59
+ def _merge(base: dict, over: dict) -> dict:
60
+ out = dict(base)
61
+ for key, val in over.items():
62
+ if isinstance(val, dict) and isinstance(out.get(key), dict):
63
+ out[key] = _merge(out[key], val)
64
+ else:
65
+ out[key] = val
66
+ return out
67
+
68
+
69
+ def load() -> dict:
70
+ """Defaults merged with the user's config.toml (a missing file means defaults)."""
71
+ path = config_path()
72
+ user: dict = {}
73
+ if path.exists():
74
+ user = tomllib.loads(path.read_text(encoding="utf-8"))
75
+ cfg = _merge(DEFAULTS, user)
76
+ cfg["_config_path"] = str(path)
77
+ cfg["_config_dir"] = str(path.parent)
78
+ return cfg
79
+
80
+
81
+ def state_dir(cfg: dict) -> Path:
82
+ """State directory: $HUBKIT_STATE, else paths.state_dir, else $XDG_STATE_HOME/hubkit."""
83
+ env = os.environ.get(f"{ENV_PREFIX}_STATE")
84
+ if env:
85
+ return Path(env).expanduser()
86
+ if cfg["paths"]["state_dir"]:
87
+ return Path(cfg["paths"]["state_dir"]).expanduser()
88
+ base = os.environ.get("XDG_STATE_HOME") or str(Path.home() / ".local" / "state")
89
+ return Path(base) / "hubkit"
90
+
91
+
92
+ def path_of(cfg: dict, key: str, default: Path) -> Path:
93
+ """A path from [paths] with ~ expanded, or `default` when unset."""
94
+ val = cfg["paths"].get(key, "")
95
+ return Path(val).expanduser() if val else default
96
+
97
+
98
+ def sessions_dir(cfg: dict) -> Path:
99
+ """Where Claude Code writes one JSON file per running session."""
100
+ env = os.environ.get(f"{ENV_PREFIX}_SESSIONS")
101
+ return Path(env).expanduser() if env else Path(cfg["paths"]["sessions_dir"]).expanduser()
102
+
103
+
104
+ def intents_path(cfg: dict) -> Path:
105
+ """The user's intents.toml, else the intents.example.toml shipped with the skill."""
106
+ mine = path_of(cfg, "intents", Path(cfg["_config_dir"]) / "intents.toml")
107
+ return mine if mine.exists() else SKILL_DIR / "intents.example.toml"