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
@@ -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)