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
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"
|