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,145 @@
|
|
|
1
|
+
"""Stop-hook gate: reasons the hub may not end its turn yet (arm the watch, stalled item, unfounded question, unverified verdict)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from . import lessons, routed, slow, store
|
|
9
|
+
from .orders import PR_REF, Orders
|
|
10
|
+
from .status import work_load
|
|
11
|
+
|
|
12
|
+
VERDICT = re.compile(r"(?i)\b(approved?|changes requested|request_changes|verdict|review(\s+(of|on|for)\s+\S+)?\s+(is\s+)?(done|complete|finished|landed))\b")
|
|
13
|
+
_V = r"(?:verdict|review|approval)"
|
|
14
|
+
AWAITING = re.compile(
|
|
15
|
+
rf"(?i)\b(?:wait(?:s|ing)?\s+(?:on|for)|awaiting|expect(?:s|ing|ed)?)\s+(?:\S+\s+){{0,3}}?{_V}\b"
|
|
16
|
+
rf"|\bpending\s+(?:an?\s+|the\s+)?{_V}\b|\bno\s+{_V}\b|\b(?:until|still\s+need\w*|yet\s+to\s+(?:get|have|receive|see))\s+(?:\S+\s+){{0,2}}?{_V}\b"
|
|
17
|
+
)
|
|
18
|
+
REPO_PR = re.compile(r"[\w.-]+(?:/[\w.-]+)?#\d{2,6}|\b[A-Za-z][\w.-]*\s+\d{2,6}\b")
|
|
19
|
+
WATCH_CMD = "python3 ~/.claude/skills/hub/scripts/hubkit watch --interval 120 --max-cycles 30"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def watch_live(cfg: dict) -> bool:
|
|
23
|
+
"""A `hubkit watch` is running: its pidfile names a process that is still the same process."""
|
|
24
|
+
from . import hooks
|
|
25
|
+
|
|
26
|
+
p = store.state(cfg) / "watch.pid"
|
|
27
|
+
try:
|
|
28
|
+
pid, start = p.read_text(encoding="utf-8").split()
|
|
29
|
+
except ValueError, OSError:
|
|
30
|
+
return False
|
|
31
|
+
return hooks.proc_start(int(pid)) == start
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def open_work(cfg: dict) -> bool:
|
|
35
|
+
"""True while routed work or a live open order exists."""
|
|
36
|
+
return bool(routed.items(cfg)) or any(o["state"] == "open" for o in Orders(cfg).live())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def blocked_on_user(cfg: dict) -> bool:
|
|
40
|
+
"""True when an `ask` work item says something really waits on the user."""
|
|
41
|
+
return any(i.get("kind") == "ask" for i in work_load(store.state(cfg)))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_question(cfg: dict, last: str) -> bool:
|
|
45
|
+
"""True when the final message ends in a question or holds a configured stop-guard phrase."""
|
|
46
|
+
tail = last.splitlines()[-1] if last else ""
|
|
47
|
+
return tail.endswith("?") or any(p in last.lower() for p in cfg["stop_guard"]["phrases"])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def unverified_verdict(cfg: dict, last: str) -> list[str]:
|
|
51
|
+
"""PRs of a tracked review item that a sentence of the message gives a verdict on, while GitHub shows no
|
|
52
|
+
review at the PR's current head that counts (routed.counts_as_review). A verdict word and a PR number count only in the same sentence."""
|
|
53
|
+
named: list[str] = []
|
|
54
|
+
for sentence in re.split(r"(?<=[.!?])\s+|\n", last):
|
|
55
|
+
if VERDICT.search(sentence) and (PR_REF.search(sentence) or REPO_PR.search(sentence)) and not AWAITING.search(sentence):
|
|
56
|
+
named += routed.refs(cfg, sentence)
|
|
57
|
+
urls = {
|
|
58
|
+
routed.pr_key(p): p
|
|
59
|
+
for i in routed.items(cfg)
|
|
60
|
+
if i.get("review")
|
|
61
|
+
for p in [*i["prs"], *i.get("queue", [])]
|
|
62
|
+
if any(routed.same_pr(p, r) for r in named)
|
|
63
|
+
}
|
|
64
|
+
timeout = float(routed.opts(cfg)["gh_timeout_s"])
|
|
65
|
+
return sorted(routed.pr_num(u) for u in urls.values() if not _reviewed_at_head(cfg, u, timeout))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _reviewed_at_head(cfg: dict, url: str, timeout: float) -> bool:
|
|
69
|
+
"""True when GitHub shows a non-author review at the head, or cannot be read (an unreadable PR proves nothing)."""
|
|
70
|
+
info = routed.pr_info(cfg, url, timeout)
|
|
71
|
+
if info is None:
|
|
72
|
+
return True
|
|
73
|
+
return any(routed.counts_as_review(cfg, r, info) and routed.same_head(r["commit"], info["head"]) for r in info["reviews"])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
BLOCKED_WORDS = ["blocked", "is down", "are down", "failed", "failing", "no host", "cannot reach", "can't reach", "unreachable",
|
|
77
|
+
"not available", "waiting on", "waiting for", "stuck", "needs a person", "needs his call", "needs her call"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
NEGATION = re.compile(r"(?i)\b(no|not|nothing|nobody|none|never|nor|without|isn't|aren't|wasn't|weren't|hasn't|haven't|doesn't|don't)\s+(?:\w+\s+){0,3}$")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# A wait that is the user's own stop or hold is not a blocker to solve: waiting on his word, or work he put on hold.
|
|
84
|
+
USER_WAIT = re.compile(
|
|
85
|
+
r"(?i)\b(?:wait(?:s|ing)?|awaiting)\s+(?:on|for)\s+(?:your|you|his|her|the user'?s?)\b"
|
|
86
|
+
r"|\b(?:on\s+hold|held|(?:you|he|she)\s+(?:asked|told|said|ordered)\s+(?:me\s+)?to\s+(?:hold|stop|wait|pause))\b"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _asserted(low: str, word: str) -> bool:
|
|
91
|
+
"""The word is in the sentence and not negated ("nothing is stuck", "not blocked", "no failed runs" assert nothing)."""
|
|
92
|
+
return any(not NEGATION.search(low[: m.start()]) for m in re.finditer(rf"(?<!\w){re.escape(word)}(?!\w)", low))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def blocked_claim(cfg: dict, last: str) -> str | None:
|
|
96
|
+
"""The sentence of the final message that says live work is blocked, down, failed or waiting on a host, tool or
|
|
97
|
+
person, or None. Review-verdict waits are the verdict check's business. Words come from intents.toml [stop_gate]."""
|
|
98
|
+
words = [w.lower() for w in Orders(cfg).intents.get("stop_gate", {}).get("blocked_words", BLOCKED_WORDS)]
|
|
99
|
+
for sentence in re.split(r"(?<=[.!?])\s+|\n", last):
|
|
100
|
+
low = sentence.lower()
|
|
101
|
+
if any(_asserted(low, w) for w in words) and not AWAITING.search(sentence) and not USER_WAIT.search(sentence):
|
|
102
|
+
return sentence.strip()[:100]
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def blocker_reasons(cfg: dict, last: str, now: float) -> list[str]:
|
|
107
|
+
"""Blocked work must be solved, not reported: an open blocker without a running fix, a fix past its limit, a
|
|
108
|
+
stop-loss with no result since, or a message that calls an open order blocked when no fix is on record."""
|
|
109
|
+
orders = Orders(cfg)
|
|
110
|
+
out = []
|
|
111
|
+
live = [o for o in orders.live() if o["state"] == "open"]
|
|
112
|
+
for o in live:
|
|
113
|
+
why = orders.blocker_problem(o, now)
|
|
114
|
+
if why:
|
|
115
|
+
out.append(f"hubkit BLOCKER: {why}. NEXT: start the fix now on a free owner, record it: hubkit order block {o['id']} --on \"..\" "
|
|
116
|
+
f"--fix \"<action running>\" --holder <who>; when it ran: hubkit order result {o['id']} --note \"<what came of it>\"")
|
|
117
|
+
if orders.stoploss_pending(o):
|
|
118
|
+
out.append(f"hubkit STOP-LOSS: {o['id']} was chased past the limit and no result is recorded since. A reply is not a result. "
|
|
119
|
+
f"NEXT: do the blocking step, then: hubkit order result {o['id']} --note \"<what you did, what came of it>\"")
|
|
120
|
+
claim = blocked_claim(cfg, last) if live else None
|
|
121
|
+
if claim and not any((o.get("blocker") or {}).get("fix") for o in live):
|
|
122
|
+
out.append(f"hubkit: your message says work is blocked ({claim!r}) but no order has a fix on record. Solve it, do not report it. "
|
|
123
|
+
f"NEXT: start the fix on a free owner, then: hubkit order block <id> --on \"..\" --fix \"<action running>\" --holder <who>")
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def stop_reasons(cfg: dict, last: str, now: float | None = None, transcript: str | None = None) -> list[str]:
|
|
128
|
+
"""Every reason the turn cannot end yet, each with its exact action. Empty = the turn may end."""
|
|
129
|
+
now = now or time.time()
|
|
130
|
+
reasons = routed.prompt_lines(cfg, now)
|
|
131
|
+
reasons += blocker_reasons(cfg, last, now)
|
|
132
|
+
reasons += [r for r in (lessons.unswept_asks(cfg, last, now), lessons.unchecked_claim(cfg, last, transcript)) if r]
|
|
133
|
+
try:
|
|
134
|
+
reasons += slow.gate_lines(cfg, now)
|
|
135
|
+
except Exception as e: # a failing estimate check must not wedge the hub's turn end
|
|
136
|
+
store.append_row(store.state(cfg) / "hook-errors.jsonl", {"where": "slow.gate", "error": repr(e)[:200]})
|
|
137
|
+
if open_work(cfg) and not watch_live(cfg):
|
|
138
|
+
reasons.append(f"hubkit: nothing will wake you when routed work stalls. NEXT: start in the background: {WATCH_CMD}")
|
|
139
|
+
for n in unverified_verdict(cfg, last):
|
|
140
|
+
reasons.append(f"hubkit: your message gives a verdict on #{n}, but GitHub shows no review at its current head "
|
|
141
|
+
"(agent words are not evidence). NEXT: correct the message; relay a verdict only after the item closes itself")
|
|
142
|
+
if is_question(cfg, last) and not blocked_on_user(cfg):
|
|
143
|
+
reasons.append("hubkit: you ended on a question to the user but nothing is blocked on him. NEXT: settle it from the record "
|
|
144
|
+
"or route it; only if it truly needs him run: hubkit work add --kind ask --title \"<the one question>\" --note \"<ordered work it blocks>\"")
|
|
145
|
+
return reasons
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Hook entry points. Every hook: no-op outside the live bound hub, fail open, exit 0."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import traceback
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TypeGuard
|
|
13
|
+
|
|
14
|
+
from . import compat, config, gate, lessons, routed, store
|
|
15
|
+
from .orders import Orders, hook_intake
|
|
16
|
+
from .redact import redact
|
|
17
|
+
from .status import read_status, render_block
|
|
18
|
+
|
|
19
|
+
# ---- bind: hub identity = session id + pid + procStart, liveness read through compat ----
|
|
20
|
+
|
|
21
|
+
def proc_start(pid: int) -> str | None:
|
|
22
|
+
"""Start-time token of a live process (see compat), or None when it is gone."""
|
|
23
|
+
return compat.proc_start(pid)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def session_file(cfg: dict, pid: int) -> dict | None:
|
|
27
|
+
"""The Claude Code session file for a pid, or None."""
|
|
28
|
+
try:
|
|
29
|
+
return json.loads((config.sessions_dir(cfg) / f"{pid}.json").read_text(encoding="utf-8"))
|
|
30
|
+
except (OSError, ValueError):
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def find_session_pid(cfg: dict) -> int | None:
|
|
35
|
+
"""Walk up the parent chain from this process to the nearest pid with a session file."""
|
|
36
|
+
pid = os.getppid()
|
|
37
|
+
for _ in range(12):
|
|
38
|
+
if pid <= 1:
|
|
39
|
+
return None
|
|
40
|
+
if session_file(cfg, pid):
|
|
41
|
+
return pid
|
|
42
|
+
parent = compat.parent_pid(pid)
|
|
43
|
+
if parent is None:
|
|
44
|
+
return None
|
|
45
|
+
pid = parent
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def bind_path(cfg: dict) -> Path:
|
|
50
|
+
"""File that records which session is the hub."""
|
|
51
|
+
return store.state(cfg, create=False) / "hub.bind"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read_bind(cfg: dict) -> dict | None:
|
|
55
|
+
"""The hub bind record, or None when absent or unreadable."""
|
|
56
|
+
try:
|
|
57
|
+
return json.loads(bind_path(cfg).read_text(encoding="utf-8"))
|
|
58
|
+
except (OSError, ValueError):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def bind_alive(b: dict | None) -> TypeGuard[dict]:
|
|
63
|
+
"""True when the bind record names a process that is still the same process (pid plus start time)."""
|
|
64
|
+
if not b:
|
|
65
|
+
return False
|
|
66
|
+
return proc_start(int(b["pid"])) == str(b["procStart"])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def bind(cfg: dict, pid: int | None) -> str:
|
|
70
|
+
"""Bind the session of `pid` (or of this process tree) as the hub; refuses a second live hub."""
|
|
71
|
+
pid = pid or find_session_pid(cfg)
|
|
72
|
+
sess = session_file(cfg, pid) if pid else None
|
|
73
|
+
if not sess or not pid:
|
|
74
|
+
raise RuntimeError("no Claude session found for this process (pass --pid)")
|
|
75
|
+
if sess.get("kind") != "interactive":
|
|
76
|
+
raise RuntimeError(f"session kind is {sess.get('kind')!r}, only interactive sessions can be the hub")
|
|
77
|
+
cur = read_bind(cfg)
|
|
78
|
+
if bind_alive(cur) and cur["session_id"] != sess["sessionId"]:
|
|
79
|
+
raise RuntimeError(f"live hub already bound: {cur.get('name')} (pid {cur['pid']})")
|
|
80
|
+
rec = {"session_id": sess["sessionId"], "pid": pid, "procStart": proc_start(pid),
|
|
81
|
+
"name": sess.get("name", ""), "cwd": sess.get("cwd", ""), "bound_at": time.time()}
|
|
82
|
+
store.state(cfg) # create the state dir
|
|
83
|
+
store.write_atomic(bind_path(cfg), json.dumps(rec))
|
|
84
|
+
return f"bound {rec['name'] or rec['session_id']} (pid {pid})"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def unbind(cfg: dict) -> str:
|
|
88
|
+
"""Remove the hub bind."""
|
|
89
|
+
bind_path(cfg).unlink(missing_ok=True)
|
|
90
|
+
return "unbound"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_hub(cfg: dict, payload: dict) -> bool:
|
|
94
|
+
"""True only for the live bound interactive hub session."""
|
|
95
|
+
if os.environ.get("HUBKIT_DISABLE"):
|
|
96
|
+
return False
|
|
97
|
+
b = read_bind(cfg)
|
|
98
|
+
if not bind_alive(b) or payload.get("session_id") != b["session_id"]:
|
|
99
|
+
return False
|
|
100
|
+
sess = session_file(cfg, int(b["pid"]))
|
|
101
|
+
return sess is not None and sess.get("kind") == "interactive" and sess.get("sessionId") == b["session_id"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---- the hooks ----
|
|
105
|
+
|
|
106
|
+
def _session_by_id(cfg: dict, session_id: str | None) -> tuple[int | None, dict | None]:
|
|
107
|
+
"""The running session file for this session id (the hook payload names it)."""
|
|
108
|
+
for f in config.sessions_dir(cfg).glob("*.json"):
|
|
109
|
+
sess = session_file(cfg, int(f.stem)) if f.stem.isdigit() else None
|
|
110
|
+
if sess and session_id and sess.get("sessionId") == session_id and proc_start(int(f.stem)) == str(sess.get("procStart")):
|
|
111
|
+
return int(f.stem), sess
|
|
112
|
+
return None, None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _rebind_candidate(cfg: dict, payload: dict) -> int | None:
|
|
116
|
+
"""Pid of this session when it may take over a dead bind, else None.
|
|
117
|
+
|
|
118
|
+
Only a session that resumes the bound session id, or that has the configured hub name AND the
|
|
119
|
+
bound cwd, qualifies. A live bind is never taken over; any other session is left alone.
|
|
120
|
+
"""
|
|
121
|
+
b = read_bind(cfg)
|
|
122
|
+
if not b or bind_alive(b):
|
|
123
|
+
return None
|
|
124
|
+
pid, sess = _session_by_id(cfg, payload.get("session_id"))
|
|
125
|
+
if not sess or sess.get("kind") != "interactive":
|
|
126
|
+
return None
|
|
127
|
+
resumed = sess["sessionId"] == b["session_id"]
|
|
128
|
+
named = bool(b.get("cwd")) and sess.get("cwd") == b["cwd"] and sess.get("name") == cfg["hub"]["name"]
|
|
129
|
+
return pid if resumed or named else None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def hook_session(cfg: dict, payload: dict) -> str:
|
|
133
|
+
"""SessionStart hook: print the status block, and re-bind a hub that resumed."""
|
|
134
|
+
rebound = ""
|
|
135
|
+
if not is_hub(cfg, payload):
|
|
136
|
+
pid = _rebind_candidate(cfg, payload)
|
|
137
|
+
if pid is None:
|
|
138
|
+
return "" # nothing printed, nothing written outside the bound hub
|
|
139
|
+
rebound = f"hubkit: {bind(cfg, pid)}; the old bind was dead (re-bound by the SessionStart hook)\n"
|
|
140
|
+
store.touch_seen(store.state(cfg), "session")
|
|
141
|
+
return rebound + render_block(cfg, Orders(cfg), read_status(cfg))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _words(text: str) -> set[str]:
|
|
145
|
+
return {w for w in re.findall(r"[a-z0-9']+", text.lower()) if len(w) > 2}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def correction_line(orders: Orders, sd: Path, text: str, now: float) -> str:
|
|
149
|
+
"""Detect a correction (configured phrases, or the same instruction repeated) and inject a record prompt."""
|
|
150
|
+
cfg = orders.intents.get("corrections", {})
|
|
151
|
+
why = ""
|
|
152
|
+
if any(re.search(p, text) for p in cfg.get("patterns", [])):
|
|
153
|
+
why = "correction phrase"
|
|
154
|
+
recent_path = sd / "recent-prompts.jsonl"
|
|
155
|
+
rows, _ = store.read_rows(recent_path)
|
|
156
|
+
mine = _words(text)
|
|
157
|
+
window = float(cfg.get("repeat_window_min", 30)) * 60
|
|
158
|
+
if not why and len(mine) >= 4:
|
|
159
|
+
for r in rows:
|
|
160
|
+
if now - r["ts"] <= window and len(_words(r["text"])) >= 4:
|
|
161
|
+
other = _words(r["text"])
|
|
162
|
+
if len(mine & other) / len(mine | other) >= float(cfg.get("repeat_similarity", 0.6)):
|
|
163
|
+
why = "repeated instruction"
|
|
164
|
+
break
|
|
165
|
+
store.append_row(recent_path, {"text": store.safe(text, 300), "ts": now})
|
|
166
|
+
if len(rows) > 2000: # `hubkit asks ls` reads this file: keep a day or more of the user's words
|
|
167
|
+
store.write_atomic(recent_path, "".join(json.dumps(r) + "\n" for r in rows[-1000:]))
|
|
168
|
+
if not why:
|
|
169
|
+
return ""
|
|
170
|
+
quote = redact(text)[0][:200]
|
|
171
|
+
return (f'hubkit CORRECTION ({why}): record it this turn. Quote: "{quote}". '
|
|
172
|
+
"Run learn.py add --class <existing-or-new-kebab> --gate <G1..G8> --quote <the words> "
|
|
173
|
+
"--situation .. --wrong .. --right .. --evidence .. (reuse a class from the block if it is the same mistake).")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def unordered_chase_lines(cfg: dict, orders: Orders, text: str, now: float) -> list[str]:
|
|
177
|
+
"""A short chase ("any progress?") with no order live: nothing counts it, so answer it from the job list."""
|
|
178
|
+
intake = orders.intents.get("intake", {})
|
|
179
|
+
chase = intake.get("chase")
|
|
180
|
+
if not chase or orders.live() or len(text) > int(intake.get("verb_max_chars", 120)) or not re.search(chase, text):
|
|
181
|
+
return []
|
|
182
|
+
from . import slow
|
|
183
|
+
|
|
184
|
+
jobs = slow.jobs(cfg, orders, now)
|
|
185
|
+
return ["hubkit: no order is live, so this chase is on the job list:", *(slow.block_lines(jobs, now) or ["no live job either: say so plainly"])]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def hook_prompt(cfg: dict, payload: dict) -> str:
|
|
189
|
+
"""UserPromptSubmit hook: open, chase, hold or cancel orders from the user's words; inject correction and overdue lines."""
|
|
190
|
+
if not is_hub(cfg, payload):
|
|
191
|
+
return ""
|
|
192
|
+
sd = store.state(cfg)
|
|
193
|
+
store.touch_seen(sd, "prompt")
|
|
194
|
+
text = payload.get("prompt") or ""
|
|
195
|
+
origin = (payload.get("origin") or {}).get("kind")
|
|
196
|
+
now = time.time()
|
|
197
|
+
routed.scan_transcript(cfg, payload.get("transcript_path")) # heard-from before the stall clock is read
|
|
198
|
+
if peer := routed.peer_name(text): # a peer message that did reach this hook: heard now, opens nothing
|
|
199
|
+
routed.heard(cfg, [(now, peer, text)])
|
|
200
|
+
if not store.is_human(text, origin):
|
|
201
|
+
return ""
|
|
202
|
+
orders = Orders(cfg)
|
|
203
|
+
corr = correction_line(orders, sd, text, now)
|
|
204
|
+
return "\n".join([*routed.prompt_lines(cfg, now), *hook_intake(orders, text), *unordered_chase_lines(cfg, orders, text, now), *([corr] if corr else [])])
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _answer_text(resp: object) -> str:
|
|
208
|
+
"""The user's answer text from an AskUserQuestion tool_response (dict with answers, or plain text)."""
|
|
209
|
+
if isinstance(resp, dict):
|
|
210
|
+
ans = resp.get("answers")
|
|
211
|
+
return "\n".join(str(v) for v in ans.values()) if isinstance(ans, dict) else ""
|
|
212
|
+
return resp if isinstance(resp, str) else ""
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def hook_answer(cfg: dict, payload: dict) -> str:
|
|
216
|
+
"""A card answer (AskUserQuestion) goes through the same intake as a typed prompt."""
|
|
217
|
+
if not is_hub(cfg, payload) or payload.get("tool_name") != "AskUserQuestion":
|
|
218
|
+
return ""
|
|
219
|
+
store.touch_seen(store.state(cfg), "answer")
|
|
220
|
+
text = _answer_text(payload.get("tool_response"))
|
|
221
|
+
if not store.is_human(text):
|
|
222
|
+
return ""
|
|
223
|
+
routed.close_asks(cfg, time.time()) # the user answered the card: nothing is blocked on him now
|
|
224
|
+
return "\n".join(hook_intake(Orders(cfg), text))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def hook_routed(cfg: dict, payload: dict) -> str:
|
|
228
|
+
"""SendMessage / Agent from the hub: open or refresh the routed item (see routed.from_tool)."""
|
|
229
|
+
tool = payload.get("tool_name")
|
|
230
|
+
if not is_hub(cfg, payload) or tool not in ("SendMessage", "Agent"):
|
|
231
|
+
return ""
|
|
232
|
+
store.touch_seen(store.state(cfg), "routed")
|
|
233
|
+
routed.scan_transcript(cfg, payload.get("transcript_path"))
|
|
234
|
+
ti = payload.get("tool_input") or {}
|
|
235
|
+
forbids = lessons.brief_forbids_fix(cfg, str(ti.get("message") or ti.get("prompt") or ""))
|
|
236
|
+
item = routed.from_tool(cfg, tool, payload.get("tool_input") or {}, payload.get("tool_response"),
|
|
237
|
+
str(payload.get("tool_use_id") or ""))
|
|
238
|
+
if not item:
|
|
239
|
+
return forbids or ""
|
|
240
|
+
out = f"hubkit: tracking {item['id']} routed to {item['owner']}; stall clock {routed.opts(cfg)['stall_min']} min"
|
|
241
|
+
if item.get("estimate_min") is not None:
|
|
242
|
+
out += f"; estimate {item['estimate_min']:g}m"
|
|
243
|
+
if item.get("estimate_defaulted"):
|
|
244
|
+
out += (f" DEFAULTED ({item.get('estimate_basis', '')}), the brief had no `Estimate: <n>m` line. "
|
|
245
|
+
f"Set it: hubkit work estimate {item['id']} --minutes N --why \"...\"")
|
|
246
|
+
out += queue_warning(cfg, item)
|
|
247
|
+
return out + (f"\n{forbids}" if forbids else "")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def hook_pretool(cfg: dict, payload: dict) -> str:
|
|
251
|
+
"""PreToolUse on Bash: warn (never deny) about a `gh pr merge` nobody ordered."""
|
|
252
|
+
if not is_hub(cfg, payload) or payload.get("tool_name") != "Bash":
|
|
253
|
+
return ""
|
|
254
|
+
warning = lessons.merge_warning(cfg, str((payload.get("tool_input") or {}).get("command") or ""))
|
|
255
|
+
if not warning:
|
|
256
|
+
return ""
|
|
257
|
+
return json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": warning}})
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def queue_warning(cfg: dict, item: dict) -> str:
|
|
261
|
+
"""A new job sent to an owner who already holds another live job, while an order is open: it queues behind that job."""
|
|
262
|
+
if not item.get("behind") or len(item.get("events", [])) != 1 or not any(o["state"] == "open" for o in Orders(cfg).live()):
|
|
263
|
+
return ""
|
|
264
|
+
return (f"\nhubkit WARNING: {item['owner']} already holds {', '.join(item['behind'])}; {item['id']} queues behind it while an order is open. "
|
|
265
|
+
"An urgent step never waits in a busy agent's queue. NEXT: send it to a free owner now")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def hook_stop(cfg: dict, payload: dict) -> str:
|
|
269
|
+
"""Block the turn end (JSON decision, which reaches the model; plain Stop stdout does not) while the hub owes
|
|
270
|
+
something: arm the watch, chase a stalled item, back a verdict, or justify a question. Once per stop:
|
|
271
|
+
`stop_hook_active` means the harness already sent the hub back, so it is let through."""
|
|
272
|
+
if not is_hub(cfg, payload) or payload.get("stop_hook_active"):
|
|
273
|
+
return ""
|
|
274
|
+
sd = store.state(cfg)
|
|
275
|
+
store.touch_seen(sd, "stop")
|
|
276
|
+
routed.scan_transcript(cfg, payload.get("transcript_path"))
|
|
277
|
+
last = (payload.get("last_assistant_message") or "").strip()
|
|
278
|
+
reasons = gate.stop_reasons(cfg, last, transcript=payload.get("transcript_path"))
|
|
279
|
+
if not reasons:
|
|
280
|
+
return ""
|
|
281
|
+
store.append_row(sd / "stop-guard.jsonl", {"blocked": True, "reasons": [store.safe(r, 300) for r in reasons]})
|
|
282
|
+
return json.dumps({"decision": "block", "reason": "\n".join(reasons)})
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def hook_record(cfg: dict, payload: dict) -> str:
|
|
286
|
+
"""Schema canary: keys and value types of the real payload, never values."""
|
|
287
|
+
if not is_hub(cfg, payload):
|
|
288
|
+
return ""
|
|
289
|
+
sd = store.state(cfg)
|
|
290
|
+
ev = payload.get("hook_event_name", "unknown")
|
|
291
|
+
src = payload.get("source", "")
|
|
292
|
+
sample = {k: type(v).__name__ for k, v in sorted(payload.items())}
|
|
293
|
+
store.write_atomic(sd / "payload-samples" / f"{ev}{'-' + src if src else ''}.json",
|
|
294
|
+
json.dumps({"keys": sample, "at": time.time()}))
|
|
295
|
+
return ""
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
HOOKS = {"session": hook_session, "prompt": hook_prompt, "stop": hook_stop, "record": hook_record, "answer": hook_answer, "routed": hook_routed, "pretool": hook_pretool}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def main(event: str) -> int:
|
|
302
|
+
"""Run one hook. Always exit 0. Errors are logged and shown only for the bound hub session."""
|
|
303
|
+
payload: dict = {}
|
|
304
|
+
cfg: dict = {}
|
|
305
|
+
try:
|
|
306
|
+
raw = sys.stdin.read()
|
|
307
|
+
payload = json.loads(raw) if raw.strip() else {}
|
|
308
|
+
payload = payload if isinstance(payload, dict) else {}
|
|
309
|
+
cfg = config.load()
|
|
310
|
+
out = HOOKS[event](cfg, payload)
|
|
311
|
+
if event != "record":
|
|
312
|
+
hook_record(cfg, payload) # real payload keys and types, hub session only
|
|
313
|
+
if out:
|
|
314
|
+
print(out)
|
|
315
|
+
except BaseException as e: # a hook must never break the session
|
|
316
|
+
try:
|
|
317
|
+
if cfg and is_hub(cfg, payload): # outside the hub: silent, no shared error file
|
|
318
|
+
store.append_row(
|
|
319
|
+
config.state_dir(cfg) / "hook-errors.jsonl",
|
|
320
|
+
{"event": event, "error": repr(e)[:200], "trace": traceback.format_exc()[-600:]},
|
|
321
|
+
)
|
|
322
|
+
if event in ("session", "prompt"):
|
|
323
|
+
print(f"hubkit: hook {event} failed ({type(e).__name__}); see hook-errors.jsonl")
|
|
324
|
+
except BaseException:
|
|
325
|
+
pass
|
|
326
|
+
return 0
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""`hubkit skills --install DIR` and `hubkit hooks`: put the skills where the agent tool reads them, and print the hooks block."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import config
|
|
10
|
+
|
|
11
|
+
HUB_FILES = ("SKILL.md", "LICENSE", "config.example.toml", "intents.example.toml", "references", "docs", "scripts")
|
|
12
|
+
EVENTS = (
|
|
13
|
+
("SessionStart", "", "session", 5),
|
|
14
|
+
("UserPromptSubmit", "", "prompt", 5),
|
|
15
|
+
("PostToolUse", "AskUserQuestion", "answer", 5),
|
|
16
|
+
("PostToolUse", "SendMessage|Agent", "routed", 6),
|
|
17
|
+
("Stop", "", "stop", 5),
|
|
18
|
+
)
|
|
19
|
+
IGNORE = shutil.ignore_patterns("__pycache__", "*.pyc", ".pytest_cache", ".ruff_cache", ".mypy_cache", "data")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def sources() -> dict[str, list[Path]]:
|
|
23
|
+
"""Skill name -> the paths copied into `<dir>/<name>/`, from a clone or from the wheel's package data."""
|
|
24
|
+
root = config.SKILL_DIR
|
|
25
|
+
extra = root if (root / "skills").is_dir() else root.parent
|
|
26
|
+
return {
|
|
27
|
+
"hub": [root / n for n in HUB_FILES if (root / n).exists()],
|
|
28
|
+
"hub-mep": [extra / "skills" / "hub-mep"] if (extra / "skills" / "hub-mep").is_dir() else [extra / "hub-mep"],
|
|
29
|
+
"hubkit-setup": [extra / "skills" / "hubkit-setup"] if (extra / "skills" / "hubkit-setup").is_dir() else [extra / "hubkit-setup"],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def install_skills(target: Path) -> list[str]:
|
|
34
|
+
"""Copy the skills into `target`, replacing earlier copies of the same files. Returns one line per skill."""
|
|
35
|
+
out = []
|
|
36
|
+
for name, paths in sources().items():
|
|
37
|
+
dest = target / name
|
|
38
|
+
if dest.resolve() == config.SKILL_DIR.resolve():
|
|
39
|
+
out.append(f"{name}: already the running copy, left alone")
|
|
40
|
+
continue
|
|
41
|
+
for p in paths:
|
|
42
|
+
if not p.exists():
|
|
43
|
+
raise FileNotFoundError(f"packaged skill file missing: {p}")
|
|
44
|
+
if p.is_dir() and p.name == name: # a whole skill directory
|
|
45
|
+
shutil.copytree(p, dest, dirs_exist_ok=True, ignore=IGNORE)
|
|
46
|
+
elif p.is_dir():
|
|
47
|
+
shutil.copytree(p, dest / p.name, dirs_exist_ok=True, ignore=IGNORE)
|
|
48
|
+
else:
|
|
49
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
shutil.copy2(p, dest / p.name)
|
|
51
|
+
out.append(f"{name}: installed in {dest}")
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def hooks_block(skills_dir: str, merge_guard: bool = False) -> str:
|
|
56
|
+
"""The `hooks` JSON for settings.json: each hook runs the hub skill's script, and says so if the script is missing."""
|
|
57
|
+
script = f"{skills_dir.rstrip('/')}/hub/scripts/hubkit"
|
|
58
|
+
hooks: dict[str, list[dict]] = {}
|
|
59
|
+
events = [*EVENTS, *((("PreToolUse", "Bash", "pretool", 5),) if merge_guard else ())]
|
|
60
|
+
for event, matcher, name, timeout in events:
|
|
61
|
+
cmd = f'[ -f "{script}" ] || echo "hubkit: script missing at {script}"; python3 "{script}" hook {name}'
|
|
62
|
+
group: dict = {"hooks": [{"type": "command", "command": cmd, "timeout": timeout}]}
|
|
63
|
+
if matcher:
|
|
64
|
+
group = {"matcher": matcher, **group}
|
|
65
|
+
hooks.setdefault(event, []).append(group)
|
|
66
|
+
return json.dumps({"hooks": hooks}, indent=2)
|