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,1169 @@
1
+ """Routed work: an outcome ledger for everything the hub hands to a peer session or background agent.
2
+
3
+ An item is an OUTCOME (owner + PR number), not a message. The PostToolUse hook on SendMessage and
4
+ Agent opens it; a second ask about the same PR to the same owner refreshes it; a chase ("status on
5
+ #7") is only an event on it and never restarts its clock. Time is read from files: nothing from the
6
+ owner for the limit is a stall, the stall climbs a ladder, and every item has one NEXT action.
7
+ `tick` reads each PR with `gh` once per tick; a review that lands at the PR's current head closes a
8
+ review item by itself, a head that moved on is an alert.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import glob
14
+ import json
15
+ import os
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import time
20
+ from datetime import datetime
21
+ from pathlib import Path
22
+
23
+ from . import store
24
+ from .orders import PR_REF, PR_URL, load_intents
25
+ from .status import next_work_id, work_load, work_save
26
+
27
+ ACK = re.compile(
28
+ r"(?i)^\W*(ack(nowledged)?|ok(ay)?|thanks?|thank you|got it|noted|will do|roger|done)\b"
29
+ )
30
+ CHASE = re.compile(
31
+ r"(?i)\b(status|eta|updates?|any progress|where are|how long|still (not|waiting|running)|done\?|report back|one[- ]line (state|status)|current state|what state|state of)\b"
32
+ )
33
+ BARE = re.compile(r"(?<![\w/])(?:#|PR\s*#?\s*)(\d{2,6})\b", re.IGNORECASE)
34
+ FROM_NAME = re.compile(r'<cross-session-message[^>]*\bfrom-name="([^"]+)"')
35
+ MAX_PRS = 3
36
+ MAX_QUEUE = 50
37
+ MAX_EVENTS = 20
38
+ SCAN_TAIL = 2_000_000 # first scan of a transcript reads only its last 2 MB
39
+
40
+
41
+ def opts(cfg: dict) -> dict:
42
+ """cfg [routed] overridden by intents.toml [routed]."""
43
+ return {**cfg["routed"], **load_intents(cfg).get("routed", {})}
44
+
45
+
46
+ # ---- what counts as routed work ----
47
+
48
+
49
+ PUSH = re.compile(
50
+ r"(?i)\b(fix(?:es|ed)?|address|rebase|implement|resolve|amend|patch|push|rework|refactor)\b"
51
+ )
52
+ EXAMPLE = re.compile(r"(?i)\b(examples?|e\.g\.|for instance|such as|sample|placeholder|hypothetical)\b")
53
+ QUOTED = re.compile(r"```.*?```|`[^`\n]*`|\"[^\"\n]*\"|\u201c[^\u201d\n]*\u201d", re.S)
54
+ NOUN_BEFORE = re.compile(r"(?i)\b(the|his|her|their|its|a|an|my|our|your|that|this|in|from|with)\s+$")
55
+ NOUN_AFTER = re.compile(r"(?i)^s?\s+(comments?|findings?|feedback|notes?|threads?|rounds?)\b")
56
+
57
+
58
+ def strip_examples(text: str) -> str:
59
+ """The text without what only illustrates: code fences, backtick and quoted spans, and the part of a sentence from
60
+ "example", "e.g." or "such as" on (the line after a header that ends in a colon goes too). What comes before the marker
61
+ stays: "Review #7, e.g. the loader part" still asks for #7. A PR number inside an example is not an ask."""
62
+ text = QUOTED.sub(" ", text)
63
+ kept: list[str] = []
64
+ skip_next = False
65
+ for s in re.split(r"(?<=[.!?])\s+|\n", text.replace("e.g.", "example")):
66
+ m = EXAMPLE.search(s)
67
+ if skip_next or m:
68
+ skip_next = bool(m) and s.rstrip().endswith(":")
69
+ if m and not skip_next and not m.start() == 0:
70
+ kept.append(s[: m.start()])
71
+ continue
72
+ kept.append(s)
73
+ return "\n".join(kept)
74
+
75
+
76
+ LEADS_CHANGE = re.compile(
77
+ r"(?i)^\W*(?:(?:please|then|and|also)\s+)*(?:fix(?:es|ed)?|address|resolve|apply|rebase|implement|amend|patch|push|rework|refactor)\b"
78
+ )
79
+
80
+
81
+ def _kinds(cfg: dict, sentence: str) -> set[str]:
82
+ """What the owner may be asked to do in one sentence. A review word beside a PR is a review ask by default
83
+ ("review #7", "needs a review", "can I get a review on #7"). The one exception: the sentence opens with a change
84
+ verb (fix, address, resolve, apply, ...), every review word in it names an existing review (his review, the
85
+ review comments) and no later clause ("then give #7 a review") asks for one, so the owner is asked to change the
86
+ PR, not to review it. "push" marks a change verb anywhere."""
87
+ low = sentence.lower()
88
+ out = {"push"} if PUSH.search(low) else set()
89
+ hits = [m for w in (x.lower() for x in opts(cfg)["review_words"]) for m in re.finditer(re.escape(w), low)]
90
+ existing = all(NOUN_BEFORE.search(low[: m.start()]) or NOUN_AFTER.match(low[m.end() :]) for m in hits)
91
+ words = [x.lower() for x in opts(cfg)["review_words"]]
92
+ later = [c.strip() for c in re.split(r",|\bthen\b|\band\b", low)[1:]]
93
+ asks_later = any(any(w in c for w in words) and not LEADS_CHANGE.match(c) for c in later)
94
+ if hits and not (LEADS_CHANGE.match(low) and existing and not asks_later):
95
+ out.add("review")
96
+ return out
97
+
98
+
99
+ def _asks(cfg: dict, text: str, summary: str = "") -> set[str]:
100
+ """The kinds of ask (see _kinds) in the sentences that name a PR, plus the summary when the text names one."""
101
+ sentences = [x for x in re.split(r"(?<=[.!?])\s+|\n", text) if x.strip() and PR_REF.search(x)]
102
+ if summary.strip() and PR_REF.search(text + " " + summary):
103
+ sentences.append(summary)
104
+ return set().union(*(_kinds(cfg, x) for x in sentences)) if sentences else set()
105
+
106
+
107
+ def is_review(cfg: dict, text: str, summary: str = "") -> bool:
108
+ """A review ask: the owner is asked to review a PR, i.e. a sentence naming a PR leads with a review word (or the
109
+ summary does). A review word elsewhere (a handover, a status) or as a noun after another action ("fix the
110
+ findings in his review of #7") does not make it one."""
111
+ return "review" in _asks(cfg, strip_examples(text), summary)
112
+
113
+
114
+ def pushes_to_pr(cfg: dict, text: str, summary: str = "") -> bool:
115
+ """The owner is asked to change the PR (fix, rebase, address findings): its own pushes move the head."""
116
+ return "push" in _asks(cfg, strip_examples(text), summary)
117
+
118
+
119
+ def owner_is_author(cfg: dict, owner: str, author: str) -> bool:
120
+ """Is the owner the PR's author? A peer or agent has no GitHub login, so this uses the data on hand: an
121
+ explicit `routed.owner_logins` map (owner name -> login), else the owner's name equals the login or contains
122
+ it (a login of 4+ characters). An agent id never matches, so it is treated as a non-author."""
123
+ login = author.lower()
124
+ if not login:
125
+ return False
126
+ mapped = opts(cfg)["owner_logins"].get(owner)
127
+ return (mapped or "").lower() == login or owner.lower() == login or (len(login) >= 4 and login in owner.lower())
128
+
129
+
130
+ def is_chase(cfg: dict, text: str) -> bool:
131
+ """A short status question about a PR: an event on the existing item, not a new ask. A message with a brief marker
132
+ (Done-when, Output) or a sentence that opens with an action on a PR (Review #N ...) is an ask whatever else it
133
+ says; a status question that merely mentions a review ("state of the review on #N") is not."""
134
+ sentences = [x.strip() for x in re.split(r"(?<=[.!?])\s+|\n", text)]
135
+ if DELIVERABLE.search(text) or any(DIRECTIVE.match(x) for x in sentences):
136
+ return False
137
+ return len(text) < int(opts(cfg)["min_chars"]) and bool(CHASE.search(text))
138
+
139
+
140
+ QUEUED = re.compile(
141
+ r"(?i)\b(?:after|behind|once)\b[^.\n]{0,40}?(?:'s\b|\bqueue\b|\bbatch\b|\bfinish(?:es|ed)?\b|\bdone\b|\bcomplete[sd]?\b|\bahead\b)"
142
+ )
143
+
144
+
145
+ def queued_behind(text: str, summary: str = "") -> bool:
146
+ """The ask says it waits behind other work ("after X's queue", "once Y finishes"): the owner has not started it."""
147
+ return bool(QUEUED.search(f"{summary}\n{text}"))
148
+
149
+
150
+ DELIVERABLE = re.compile(r"(?i)done[- ]when|\boutput\b|\bdeliverable")
151
+
152
+
153
+ def is_deliverable_brief(cfg: dict, description: str, prompt: str) -> bool:
154
+ """A background agent is routed work only when its brief asks for a result (a PR, an Output, a Done-when) and is
155
+ not a standing watcher (`routed.skip_briefs`); a long-running agent with nothing to deliver cannot stall."""
156
+ low = f"{description} {prompt}".lower()
157
+ if any(w.lower() in low for w in opts(cfg)["skip_briefs"]):
158
+ return False
159
+ return bool(DELIVERABLE.search(prompt) or PR_REF.search(prompt))
160
+
161
+
162
+ def _is_reply(cfg: dict, owner: str, text: str, asked: list[str], now: float | None) -> bool:
163
+ """The hub answering a peer who wrote to it recently, with no PR to work on and no brief, is not an ask."""
164
+ if asked or DELIVERABLE.search(text):
165
+ return False
166
+ wrote = peers(cfg).get(owner.lower(), 0.0)
167
+ return (now or time.time()) - wrote < float(opts(cfg)["reply_window_min"]) * 60
168
+
169
+
170
+ def peers(cfg: dict) -> dict[str, float]:
171
+ """Last time each peer wrote to the hub (from the transcript scans)."""
172
+ try:
173
+ return json.loads((store.state(cfg) / "peers.json").read_text(encoding="utf-8"))
174
+ except ValueError, OSError:
175
+ return {}
176
+
177
+
178
+ def _note_peers(cfg: dict, events: list[tuple[float, str, str]]) -> None:
179
+ seen = peers(cfg)
180
+ for ts, name, _ in events:
181
+ if name and ts > seen.get(name.lower(), 0.0):
182
+ seen[name.lower()] = ts
183
+ store.write_atomic(store.state(cfg) / "peers.json", json.dumps(seen))
184
+
185
+
186
+ def _is_follow_up(cfg: dict, owner: str, text: str) -> bool:
187
+ """To an owner with open items: not an acknowledgement, names no PR, carries no new brief."""
188
+ return (
189
+ not ACK.match(text)
190
+ and not PR_REF.search(text)
191
+ and not DELIVERABLE.search(text)
192
+ and not any(s.lower() in owner.lower() for s in opts(cfg)["skip_owners"])
193
+ and any(i["owner"] == owner for i in items(cfg))
194
+ )
195
+
196
+
197
+ def is_work(cfg: dict, owner: str, text: str, asked: list[str] | None = None) -> bool:
198
+ """A message is routed work when it names a PR or is long; acknowledgements and the inbox watcher are not."""
199
+ o = opts(cfg)
200
+ if any(s.lower() in owner.lower() for s in o["skip_owners"]):
201
+ return False
202
+ if ACK.match(text) and not (DELIVERABLE.search(text) or DIRECTIVE.search(text) or REQUEST.search(text)):
203
+ return False # "Ack #399 hold": a bare acknowledgement that names a PR is not an ask; any request cue makes it one
204
+ if asked if asked is not None else PR_REF.search(text):
205
+ return True
206
+ if len(text) >= int(o["min_chars"]):
207
+ return not ACK.match(text)
208
+ return is_short_ask(text)
209
+
210
+
211
+ SHORT_ASK = re.compile(
212
+ r"(?im)(?:^\W*|[.!?]\s+)(?:please|pls|can you|could you|would you|will you|check|look|fix|send|run|merge|deploy|rebase|take|handle|tell|report|reply|confirm|verify|investigate|find|start|stop|update|write|add|open|close|make)\b|\?\s*$"
213
+ )
214
+
215
+
216
+ def is_short_ask(text: str) -> bool:
217
+ """A short message that asks the peer to do or answer something: a request verb opening a sentence, or a question.
218
+ Not an acknowledgement, not a status question (that is a chase), and long enough to carry a request."""
219
+ return len(text.strip()) >= 15 and not ACK.match(text) and not CHASE.search(text) and bool(SHORT_ASK.search(text))
220
+
221
+
222
+ REQUEST = re.compile(
223
+ r"(?i)\b(please|pls|needs?|needed|next|your|you|eyes|look|review|check|fix|merge|deploy|rebase|send|take|handle|findings?|can|could|would|should|must|do)\b|\?"
224
+ )
225
+ DIRECTIVE = re.compile(
226
+ r"(?i)\b(re-?review|review|re-?check|check|merge|approve|test|rebase|deploy|fix|land|verify|finish|start|continue)\b"
227
+ r"(?:[^.\n]|\.(?=\S)){0,60}?(?:https://github\.com/[\w.-]+/[\w.-]+/pull/\d+|#\d{2,}|\bpr\s*#?\d+)(?:[^.\n]|\.(?=\S)){0,300}"
228
+ )
229
+
230
+
231
+ def asked_refs(cfg: dict, text: str, summary: str = "", cap: int | None = MAX_QUEUE) -> list[str]:
232
+ """The PRs the owner is asked to work on. A short message asks about every PR it names. In a long one only
233
+ the summary and sentences that direct an action at a PR count: a PR named in passing (a bug
234
+ list, a status) is not an ask."""
235
+ text = strip_examples(text)
236
+ if len(text) < int(opts(cfg)["min_chars"]):
237
+ return refs(cfg, f"{summary}\n{text}", cap)
238
+ spans = [summary, *(m.group(0) for m in DIRECTIVE.finditer(text))]
239
+ return refs(cfg, "\n".join(spans), cap)
240
+
241
+
242
+ def pr_num(ref: str) -> str:
243
+ """The PR number at the end of a URL or `#N` reference."""
244
+ return ref.rstrip("/").rsplit("/", 1)[-1].lstrip("#")
245
+
246
+
247
+ def pr_key(ref: str) -> str:
248
+ """Repo and number of a PR ref: `owner/repo#N` for a URL, `#N` for a bare number (repo not known)."""
249
+ parts = ref.rstrip("/").split("/")
250
+ return f"{parts[3]}/{parts[4]}#{parts[6]}".lower() if ref.startswith("http") and len(parts) >= 7 else "#" + pr_num(ref)
251
+
252
+
253
+ def same_pr(a: str, b: str) -> bool:
254
+ """The same PR: equal repo and number, or one side is a bare number (repo unknown) with the same number."""
255
+ ka, kb = pr_key(a), pr_key(b)
256
+ return ka == kb or (("/" not in ka or "/" not in kb) and pr_num(a) == pr_num(b))
257
+
258
+
259
+ def overlaps(xs: list[str], ys: list[str]) -> bool:
260
+ """Any PR in common."""
261
+ return any(same_pr(x, y) for x in xs for y in ys)
262
+
263
+
264
+ def covers(xs: list[str], ys: list[str]) -> bool:
265
+ """Every PR of `xs` is in `ys`."""
266
+ return all(any(same_pr(x, y) for y in ys) for x in xs)
267
+
268
+
269
+ REPO_REF = re.compile(r"(?<![\w/.-])([\w.-]+/[\w.-]+)#(\d{2,6})\b")
270
+
271
+
272
+ def _repo_refs(cfg: dict, text: str) -> tuple[list[str], str]:
273
+ """PRs named with their repo: `owner/repo#N`, or `<alias> N` / `<alias>#N` / `<alias> PR N` for a configured
274
+ `routed.repo_aliases` entry (alias -> owner/repo). Returns the URLs and the text with those spans removed, so
275
+ the number is not read again as a bare one in the default repo."""
276
+ out: list[str] = []
277
+
278
+ def take(repo: str, n: str) -> str:
279
+ out.append(f"https://github.com/{repo}/pull/{n}")
280
+ return " "
281
+
282
+ def repl(repo: str | None = None): # noqa: ANN202 - a re.sub callback
283
+ def inner(m: re.Match[str]) -> str:
284
+ return take(repo or m[1], m[2] if repo is None else m[1])
285
+
286
+ return inner
287
+
288
+ text = REPO_REF.sub(repl(), text)
289
+ for alias, repo in dict(opts(cfg).get("repo_aliases", {})).items():
290
+ text = re.sub(rf"(?i)(?<![\w/.-]){re.escape(alias)}(?:\s*#|\s+pr\s*#?\s*|\s+)(\d{{2,6}})\b", repl(repo), text)
291
+ return list(dict.fromkeys(out)), text
292
+
293
+
294
+ def refs(cfg: dict, text: str, cap: int | None = MAX_PRS) -> list[str]:
295
+ """PR refs in a text: full URLs, repo-qualified refs, and bare #N / PR N resolved against `routed.repo`
296
+ (else left as #N)."""
297
+ qualified, text = _repo_refs(cfg, text)
298
+ urls = list(dict.fromkeys([*PR_URL.findall(text), *qualified]))
299
+ seen = {pr_key(u) for u in urls}
300
+ repo = str(opts(cfg).get("repo", ""))
301
+ out = list(urls)
302
+ for n in dict.fromkeys(BARE.findall(text)):
303
+ ref = f"https://github.com/{repo}/pull/{n}" if repo else f"#{n}"
304
+ if pr_key(ref) not in seen:
305
+ seen.add(pr_key(ref))
306
+ out.append(ref)
307
+ return out[:cap]
308
+
309
+
310
+ # ---- gh ----
311
+
312
+
313
+ def _gh(cfg: dict) -> str | None:
314
+ want = os.path.expanduser(cfg["tools"]["gh"])
315
+ if os.sep in want:
316
+ return want if os.access(want, os.X_OK) else None
317
+ found = shutil.which(want)
318
+ if found:
319
+ return found
320
+ for d in ("~/.local/bin", "/usr/local/bin", "/usr/bin"):
321
+ cand = os.path.join(os.path.expanduser(d), want)
322
+ if os.access(cand, os.X_OK):
323
+ return cand
324
+ return None
325
+
326
+
327
+ def _epoch(iso: str) -> float:
328
+ return datetime.fromisoformat(iso).timestamp()
329
+
330
+
331
+ def pr_info(cfg: dict, url: str, timeout: float) -> dict | None:
332
+ """Head sha, PR author and every review, from gh; None (unknown) on any failure."""
333
+ exe = _gh(cfg)
334
+ if exe is None or timeout <= 0 or not url.startswith("http"):
335
+ return None
336
+ try:
337
+ r = subprocess.run(
338
+ [exe, "pr", "view", url, "--json", "headRefOid,reviews,author,commits"],
339
+ capture_output=True,
340
+ encoding="utf-8", errors="replace",
341
+ timeout=timeout,
342
+ check=False,
343
+ )
344
+ data = json.loads(r.stdout)
345
+ revs: list[dict] = [
346
+ {
347
+ "at": _epoch(x["submittedAt"]),
348
+ "state": str(x.get("state", "")),
349
+ "commit": str((x.get("commit") or {}).get("oid", "")),
350
+ "login": str((x.get("author") or {}).get("login", "")),
351
+ "body": str(x.get("body") or ""),
352
+ }
353
+ for x in data["reviews"]
354
+ if x.get("submittedAt")
355
+ ]
356
+ return {
357
+ "head": str(data["headRefOid"]),
358
+ "reviews": revs,
359
+ "author": str((data.get("author") or {}).get("login", "")),
360
+ "review_at": max((x["at"] for x in revs), default=None),
361
+ "head_author": _last_author(data.get("commits")),
362
+ }
363
+ except OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError:
364
+ return None
365
+
366
+
367
+ def _last_author(commits: object) -> str:
368
+ """Login of the author of the PR's newest commit, else an empty string (unknown)."""
369
+ try:
370
+ return str(commits[-1]["authors"][0]["login"]) if commits else "" # type: ignore[index]
371
+ except KeyError, IndexError, TypeError:
372
+ return ""
373
+
374
+
375
+ def counts_as_review(cfg: dict, rev: dict, info: dict) -> bool:
376
+ """Does this review count as the review that was asked for? A non-author review does. On a PR written by the
377
+ hub's user (`user.github_login`) the reviewer peer posts from that same account, and GitHub allows only a
378
+ COMMENT there, so a COMMENT review with a body counts on such a PR. A COMMENT with an empty body is what a reply in
379
+ an inline thread leaves behind (gh gives each review's `body`), so it does not. `reviewer_logins`, when set,
380
+ limits who counts."""
381
+ need = opts(cfg)["reviewer_logins"]
382
+ if need and rev["login"] not in need:
383
+ return False
384
+ if rev["login"] != info["author"]:
385
+ return True
386
+ me = str(cfg["user"].get("github_login", "")).lower()
387
+ return (
388
+ bool(me)
389
+ and rev["login"].lower() == me
390
+ and rev["state"].upper().startswith("COMMENT")
391
+ and bool(rev["body"].strip())
392
+ )
393
+
394
+
395
+ def same_head(a: str, b: str) -> bool:
396
+ """True when two shas agree on their common prefix."""
397
+ return bool(a) and bool(b) and (a.startswith(b) or b.startswith(a))
398
+
399
+
400
+ # ---- the items ----
401
+
402
+
403
+ def items(cfg: dict) -> list[dict]:
404
+ """All routed work items."""
405
+ return [i for i in work_load(store.state(cfg)) if i.get("kind") == "routed"]
406
+
407
+
408
+ def _event(item: dict, kind: str, text: str, now: float) -> None:
409
+ item["events"] = [
410
+ *item.get("events", []),
411
+ {"t": now, "kind": kind, "text": text[:160]},
412
+ ][-MAX_EVENTS:]
413
+
414
+
415
+ def _merge_prs(old: list[str], new: list[str]) -> list[str]:
416
+ """One ref per PR number; a full URL replaces a bare #N."""
417
+ merged: list[str] = []
418
+ for ref in [*old, *new]:
419
+ hit = next((k for k, m in enumerate(merged) if same_pr(m, ref)), None)
420
+ if hit is None:
421
+ merged.append(ref)
422
+ elif ref.startswith("http"):
423
+ merged[hit] = ref
424
+ return merged[:MAX_PRS]
425
+
426
+
427
+ def _same_job(i: dict, owner: str, prs: list[str], summary: str) -> bool:
428
+ if i.get("kind") != "routed" or i["owner"] != owner:
429
+ return False
430
+ if prs:
431
+ return overlaps([*i["prs"], *i.get("queue", [])], prs)
432
+ return not i["prs"] # no PR named: a follow-up to the same owner is the same outcome
433
+
434
+
435
+ def _chase(cfg: dict, item: dict, text: str, heads: dict[str, str], now: float) -> None:
436
+ """A follow-up to the owner about work already on the item: counted, never a new clock. It answers a moved head
437
+ or an old-head verdict (the hub acted), so those flags clear and the new head becomes the baseline."""
438
+ _event(item, "chase", text, now)
439
+ item["chases"] = int(item.get("chases", 0)) + 1
440
+ item["addressed"] = [*item.get("addressed", []), now]
441
+ item["chased_level"] = eff_level(cfg, item, now)
442
+ v = item.get("verdict_old_head")
443
+ if v:
444
+ item["verdict_acked"] = [v["commit"], v["head"]]
445
+ item["head_moved"] = item["verdict_old_head"] = None
446
+ item["heads"] = {**item["heads"], **heads}
447
+
448
+
449
+ def chase_owner(cfg: dict, owner: str, text: str, now: float | None = None) -> dict | None:
450
+ """A message to an owner who has open items, naming no PR and carrying no new brief, is a follow-up on all of
451
+ them. Returns the first item, or None when the owner has none."""
452
+ now = now or time.time()
453
+ sd = store.state(cfg)
454
+ with store.locked(sd / "work.lock"):
455
+ all_ = work_load(sd)
456
+ mine = [i for i in all_ if i.get("kind") == "routed" and i["owner"] == owner]
457
+ for i in mine:
458
+ _chase(cfg, i, text[:120], {}, now)
459
+ if mine:
460
+ work_save(sd, all_)
461
+ return mine[0] if mine else None
462
+
463
+
464
+ def record(
465
+ cfg: dict,
466
+ owner: str,
467
+ summary: str,
468
+ prs: list[str],
469
+ heads: dict[str, str],
470
+ tool_use_id: str = "",
471
+ now: float | None = None,
472
+ review: bool = False,
473
+ chase: bool = False,
474
+ brief: bool = False,
475
+ queue: list[str] | None = None,
476
+ waiting: bool = False,
477
+ pushes: bool = False,
478
+ estimate: int | None = None,
479
+ ) -> dict:
480
+ """Open the outcome, or refresh it (same owner and PR number). A chase only adds an event."""
481
+ now = now or time.time()
482
+ sd = store.state(cfg)
483
+ with store.locked(sd / "work.lock"):
484
+ all_ = work_load(sd)
485
+ named = [*prs, *(queue or [])]
486
+ hit = next((i for i in all_ if _same_job(i, owner, named, summary)), None)
487
+ follow_up = (
488
+ hit is not None
489
+ and not brief
490
+ and covers(named, [*hit["prs"], *hit.get("queue", [])])
491
+ )
492
+ if hit is not None and (chase or follow_up):
493
+ _chase(cfg, hit, summary, heads, now)
494
+ if not hit.get("queue"):
495
+ hit["prs"] = _merge_prs(hit["prs"], prs) # a full URL replaces a bare #N
496
+ work_save(sd, all_)
497
+ return hit
498
+ is_new = hit is None
499
+ if hit is None:
500
+ hit = {
501
+ "id": next_work_id(sd, all_),
502
+ "kind": "routed",
503
+ "owner": owner,
504
+ "note": "",
505
+ "added": now,
506
+ "last_heard": None,
507
+ "heads": {},
508
+ "prs": [],
509
+ "events": [],
510
+ "chases": 0,
511
+ }
512
+ all_.append(hit)
513
+ if is_new:
514
+ hit["behind"] = [i["id"] for i in all_ if i is not hit and i.get("kind") == "routed" and i["owner"] == owner]
515
+ for other in all_: # a brief on the same PRs to another owner is a re-brief: it addresses the old item
516
+ if other is not hit and other.get("kind") == "routed" and other["owner"] != owner and overlaps(named, [*other["prs"], *other.get("queue", [])]):
517
+ other["addressed"] = [*other.get("addressed", []), now]
518
+ _event(hit, "ask", summary, now)
519
+ hit.update(
520
+ title=summary,
521
+ routed_at=now,
522
+ prs=prs if queue else _merge_prs(hit["prs"], prs),
523
+ queue=queue or [],
524
+ waiting=waiting,
525
+ verdict_acked=None,
526
+ review=review,
527
+ pushes=pushes,
528
+ state_ask=chase and not brief,
529
+ tool_use_id=tool_use_id or hit.get("tool_use_id", ""),
530
+ head_moved=None,
531
+ verdict_old_head=None,
532
+ chased_level=None,
533
+ )
534
+ _set_estimate(cfg, hit, estimate, now)
535
+ hit["heads"] = {
536
+ **hit["heads"],
537
+ **heads,
538
+ } # re-asking resets the baseline: the hub has acted on the new head
539
+ work_save(sd, all_)
540
+ return hit
541
+
542
+
543
+ def from_estimate(text: str) -> int | None:
544
+ """Minutes of the brief's `Estimate: <n>m` line, else None."""
545
+ from . import slow
546
+
547
+ return slow.parse_estimate(text)
548
+
549
+
550
+ def _set_estimate(cfg: dict, item: dict, given: int | None, now: float) -> None:
551
+ """A new ask carries an estimate: the brief's `Estimate:` line, else the usual time for its step (defaulted)."""
552
+ from . import slow
553
+
554
+ mins, basis = (float(given), "brief") if given else slow.item_default(cfg, item)
555
+ item["estimates"] = [*item.get("estimates", []), {"t": now, "min": mins, "source": "brief" if given else "default", "basis": basis}]
556
+ item.update(estimate_min=mins, estimate_defaulted=not given, estimate_basis=basis, addressed=[])
557
+
558
+
559
+ def from_tool(
560
+ cfg: dict,
561
+ tool: str,
562
+ ti: dict,
563
+ resp: object,
564
+ tool_use_id: str,
565
+ now: float | None = None,
566
+ ) -> dict | None:
567
+ """The item for one SendMessage / Agent call, or None when the call is not routed work."""
568
+ chase = False
569
+ if tool == "SendMessage":
570
+ owner, text = str(ti.get("to") or ti.get("recipient") or ""), ti.get("message")
571
+ if not owner or not isinstance(text, str):
572
+ return None
573
+ if _is_follow_up(cfg, owner, text):
574
+ return chase_owner(cfg, owner, str(ti.get("summary") or text), now)
575
+ asked = asked_refs(cfg, text, str(ti.get("summary") or ""))
576
+ if _is_reply(cfg, owner, text, asked, now) or not is_work(cfg, owner, text, asked):
577
+ return None
578
+ summary = str(ti.get("summary") or text.splitlines()[0])[:120]
579
+ chase = is_chase(cfg, text)
580
+ elif tool == "Agent" and ti.get(
581
+ "run_in_background"
582
+ ): # a foreground agent hands its result back in-turn
583
+ text = str(ti.get("prompt") or "")
584
+ if not is_deliverable_brief(cfg, str(ti.get("description") or ""), text):
585
+ return None
586
+ found = re.search(r"agentId: (\w+)", resp) if isinstance(resp, str) else None
587
+ ident = (
588
+ resp.get("agentId")
589
+ if isinstance(resp, dict)
590
+ else (found.group(1) if found else None)
591
+ )
592
+ owner = str(ident or "agent:" + str(ti.get("description", "")))
593
+ summary = str(ti.get("description") or text.splitlines()[0])[:120]
594
+ else:
595
+ return None
596
+ every = asked_refs(cfg, text, summary)
597
+ queue = every if len(every) > 1 else [] # one PR per clock: several PRs are a queue, the first is current
598
+ prs = every[:1] if queue else every
599
+ heads: dict[str, str] = {}
600
+ authored = False
601
+ deadline = time.time() + 3.0 # the hook is synchronous: 3 s in total for baselines
602
+ for url in prs:
603
+ info = pr_info(cfg, url, deadline - time.time())
604
+ if info:
605
+ heads[url] = info["head"]
606
+ authored = authored or owner_is_author(cfg, owner, info["author"])
607
+ return record(
608
+ cfg,
609
+ owner,
610
+ summary,
611
+ prs,
612
+ heads,
613
+ tool_use_id,
614
+ now,
615
+ bool(prs) and not authored and not chase and is_review(cfg, text, summary),
616
+ chase,
617
+ bool(DELIVERABLE.search(text)),
618
+ queue,
619
+ queued_behind(text, summary),
620
+ pushes_to_pr(cfg, text, summary) and not chase,
621
+ from_estimate(text),
622
+ )
623
+
624
+
625
+ # ---- clocks and the ladder ----
626
+
627
+
628
+ def started_at(cfg: dict, item: dict) -> float:
629
+ """When the owner is expected to start: the ask, or for work queued behind other work, `queued_wait_min` later
630
+ (until a review shows on the PR)."""
631
+ t = item.get("routed_at") or item["added"]
632
+ return t + float(opts(cfg)["queued_wait_min"]) * 60 if item.get("waiting") else t
633
+
634
+
635
+ def base(cfg: dict, item: dict) -> float:
636
+ """Last word from the owner: the later of the start and last heard."""
637
+ return max(started_at(cfg, item), item.get("last_heard") or 0.0)
638
+
639
+
640
+ def limit(cfg: dict, item: dict) -> float:
641
+ """Stall limit in minutes: reviews are held to the shorter review clock."""
642
+ o = opts(cfg)
643
+ return float(o["review_stall_min"] if item.get("review") else o["stall_min"])
644
+
645
+
646
+ def overdue(cfg: dict, item: dict, now: float) -> int | None:
647
+ """Minutes since the ask when a review passes its hard limit (hearing from the owner does not stop it)."""
648
+ age = int((now - started_at(cfg, item)) / 60)
649
+ return (
650
+ age
651
+ if item.get("review") and age > float(opts(cfg)["review_overdue_min"])
652
+ else None
653
+ )
654
+
655
+
656
+ def level(cfg: dict, item: dict, now: float) -> int | None:
657
+ """Ladder step by silence: 0 past 1x the limit, 1 past 2x, 2 past 3x; None while within the limit."""
658
+ lim = limit(cfg, item)
659
+ idle = (now - base(cfg, item)) / 60
660
+ return None if idle <= lim else min(int(idle // lim) - 1, 2)
661
+
662
+
663
+ def eff_level(cfg: dict, item: dict, now: float) -> int | None:
664
+ """level(), but a review past its hard limit counts as at least step 1."""
665
+ lvl = level(cfg, item, now)
666
+ return (
667
+ max(lvl if lvl is not None else 0, 1)
668
+ if overdue(cfg, item, now) is not None
669
+ else lvl
670
+ )
671
+
672
+
673
+ def needs_action(cfg: dict, item: dict, now: float) -> bool:
674
+ """Something the hub must do now: a moved head, a verdict on an old head, or a silence not yet chased at this step."""
675
+ if item.get("head_moved") or item.get("verdict_old_head"):
676
+ return True
677
+ from . import watcher
678
+
679
+ lvl = eff_level(cfg, item, now)
680
+ return (
681
+ lvl is not None
682
+ and (item.get("chased_level") is None or item["chased_level"] < lvl)
683
+ and watcher.excused(cfg, item, now) is None
684
+ )
685
+
686
+
687
+ def _pr_no(ref: str) -> str:
688
+ return "#" + pr_num(ref)
689
+
690
+
691
+ def _label(item: dict) -> str:
692
+ prs = " ".join(_pr_no(u) for u in item["prs"])
693
+ if item.get("queue"):
694
+ prs += f" (+{len(item['queue']) - 1} queued)"
695
+ return f"{item['id']} ({item['title'][:50]}{' ' + prs if prs else ''})"
696
+
697
+
698
+ def next_action(cfg: dict, item: dict, now: float) -> str:
699
+ """The single next action for one item."""
700
+ o = item["owner"]
701
+ m, v = item.get("head_moved"), item.get("verdict_old_head")
702
+ if m:
703
+ return f"SendMessage {o}: {_pr_no(m['url'])} head moved {m['old'][:7]} -> {m['new'][:7]}; ask for a re-check at the new head"
704
+ if v:
705
+ return f"SendMessage {o}: the {v['state']} review landed at {v['commit'][:7]} but the head is {v['head'][:7]}; ask for a re-review at the head"
706
+ lvl = eff_level(cfg, item, now)
707
+ if lvl is not None:
708
+ if item.get("chased_level") is not None and item["chased_level"] >= lvl:
709
+ nxt = base(cfg, item) + limit(cfg, item) * 60 * (lvl + 2)
710
+ return f"chased {item.get('chases', 0)}x, waiting; escalate to step {lvl + 1} at {store.clock(cfg, nxt)}"
711
+ heard = item.get("last_heard")
712
+ if lvl >= 1 and heard and now - heard < limit(cfg, item) * 60:
713
+ return f"SendMessage {o} for an ETA on {' '.join(_pr_no(u) for u in item['prs']) or 'the work'}; it reported {int((now - heard) / 60)} min ago, so do not re-brief it"
714
+ steps = (
715
+ f"SendMessage {o} for a one-line state",
716
+ "re-brief this work to another idle peer or a fresh agent (ListAgents first); the new brief opens its own item",
717
+ "tell the user in the next report in one line (no card), then decide the executor",
718
+ )
719
+ return steps[lvl]
720
+ if item.get("pr_unknown"):
721
+ if any(not u.startswith("http") for u in item["pr_unknown"]):
722
+ return "a PR is a bare #N and routed.repo is not set: set [routed] repo in config.toml (or work add --pr with the full URL)"
723
+ return (
724
+ "gh could not read the PR: have an agent read it; nothing is known about it"
725
+ )
726
+ if item.get("waiting") and now < started_at(cfg, item):
727
+ return f"queued behind other work: no clock until {store.clock(cfg, started_at(cfg, item))} or until a review shows on the PR; then chase {o}"
728
+ return f"wait for {o}; next check {store.clock(cfg, base(cfg, item) + limit(cfg, item) * 60)} (limit {int(limit(cfg, item))} min)"
729
+
730
+
731
+ def _why(cfg: dict, item: dict, now: float) -> str:
732
+ if item.get("head_moved"):
733
+ m = item["head_moved"]
734
+ return f"{_pr_no(m['url'])} head moved {m['old'][:7]} -> {m['new'][:7]} after the ask"
735
+ if item.get("verdict_old_head"):
736
+ return "the review landed on an old head"
737
+ late = overdue(cfg, item, now)
738
+ idle = int((now - base(cfg, item)) / 60)
739
+ if late is not None:
740
+ return f"review asked {late} min ago, past the hard limit of {opts(cfg)['review_overdue_min']} min"
741
+ return f"nothing for {idle} min (limit {int(limit(cfg, item))})"
742
+
743
+
744
+ def prompt_lines(cfg: dict, now: float) -> list[str]:
745
+ """One line per owner that needs the hub, most urgent first. The action is always one NEXT."""
746
+ by_owner: dict[str, list[dict]] = {}
747
+ for i in items(cfg):
748
+ if needs_action(cfg, i, now):
749
+ by_owner.setdefault(i["owner"], []).append(i)
750
+ out = []
751
+ for owner, its in by_owner.items():
752
+ tag = (
753
+ "OVERDUE"
754
+ if any(overdue(cfg, i, now) is not None for i in its)
755
+ else "STALLED"
756
+ )
757
+ facts = "; ".join(f"{_label(i)}: {_why(cfg, i, now)}" for i in its)
758
+ out.append(
759
+ f"hubkit {tag}: {owner}: {facts}. NEXT: {next_action(cfg, its[0], now)}"
760
+ )
761
+ return out
762
+
763
+
764
+ def slow_why(cfg: dict, item: dict, now: float) -> str:
765
+ """What the holder is doing, from real signals (see slow.why)."""
766
+ from . import slow
767
+
768
+ return slow.why(cfg, item["owner"], item, now, False)[0]
769
+
770
+
771
+ def alerts(cfg: dict, now: float) -> list[dict]:
772
+ """Tick alerts, one per item and ladder step; back-off is applied by the tick."""
773
+ out = []
774
+ for i in items(cfg):
775
+ m = i.get("head_moved")
776
+ if m:
777
+ out.append(
778
+ {
779
+ "key": f"{i['id']}:head_moved:{m['new'][:8]}",
780
+ "msg": (
781
+ f"{_label(i)} to {i['owner']}: {_why(cfg, i, now)}. NEXT: {next_action(cfg, i, now)}"
782
+ ),
783
+ }
784
+ )
785
+ lvl = eff_level(cfg, i, now)
786
+ if lvl is not None and needs_action(cfg, i, now): # a chased item waits: no alert, no wake-up
787
+ out.append(
788
+ {
789
+ "key": f"{i['id']}:routed_stalled:L{lvl}",
790
+ "msg": (
791
+ f"{_label(i)} to {i['owner']}: {_why(cfg, i, now)}; holder: {slow_why(cfg, i, now)}. NEXT: {next_action(cfg, i, now)}"
792
+ ),
793
+ }
794
+ )
795
+ return out
796
+
797
+
798
+ def block_lines(cfg: dict, work: list[dict], now: float) -> list[str]:
799
+ """Status-block lines for routed work: owner, age, last heard and the NEXT action."""
800
+ mine = [i for i in work if i.get("kind") == "routed"]
801
+ if not mine:
802
+ return []
803
+ out = ["routed work (owner, age, last heard, NEXT):"]
804
+ for i in mine:
805
+ heard = int((now - i["last_heard"]) / 60) if i.get("last_heard") else None
806
+ pr = ", ".join(
807
+ f"{_pr_no(u)}@{i['heads'].get(u, '?')[:7]}" for u in i.get("prs", [])
808
+ )
809
+ flags = (f" +{len(i['queue']) - 1} queued" if i.get("queue") else "") + (" REVIEW" if i.get("review") else "") + (
810
+ " PR unknown" if i.get("pr_unknown") else ""
811
+ )
812
+ out.append(
813
+ f" {i['id']} -> {i['owner']}: {i['title'][:50]}{' [' + pr + ']' if pr else ''}{flags}; "
814
+ f"age {int((now - i.get('routed_at', i['added'])) / 60)}m, last heard "
815
+ f"{'never' if heard is None else str(heard) + 'm ago'}, chased {i.get('chases', 0)}x"
816
+ )
817
+ out.append(f" NEXT: {next_action(cfg, i, now)}")
818
+ return out
819
+
820
+
821
+ # ---- heard-from ----
822
+
823
+
824
+ def heard(cfg: dict, events: list[tuple[float, str, str]]) -> int:
825
+ """events: (epoch, from-name or "", free text). Marks items whose owner or tool use id is named."""
826
+ if not events:
827
+ return 0
828
+ _note_peers(cfg, events)
829
+ sd = store.state(cfg)
830
+ n = 0
831
+ with store.locked(sd / "work.lock"):
832
+ all_ = work_load(sd)
833
+ for i in all_:
834
+ if i.get("kind") != "routed":
835
+ continue
836
+ for ts, name, text in events:
837
+ by_text = not name and (
838
+ i["owner"] in text
839
+ or bool(i.get("tool_use_id") and i["tool_use_id"] in text)
840
+ )
841
+ if by_text and COMPLETED.search(text) and ts > (i.get("routed_at") or i["added"]):
842
+ i["reported"] = ts # the agent finished after this ask and reported
843
+ if not i.get("review"): # a review item stays: the review check on the PR decides
844
+ i["answered"] = True
845
+ i["done_note"] = "agent completed (task notification)"
846
+ from_owner = bool(name) and name.lower() == i["owner"].lower()
847
+ if from_owner and not i.get("review") and ts > (i.get("routed_at") or i["added"]) and is_report(text) \
848
+ and _report_is_about(all_, i, text):
849
+ i["answered"] = True # the owner reported its result to the hub; a review item waits for the PR's own review
850
+ i["done_note"] = "owner reported its result (message to the hub)"
851
+ if (name.lower() == i["owner"].lower() or by_text) and ts > (
852
+ i.get("last_heard") or 0
853
+ ):
854
+ i["last_heard"] = ts
855
+ n += 1
856
+ if i.get("state_ask") and ts > (i.get("routed_at") or 0):
857
+ i["answered"] = True # a state question is answered by one reply
858
+ closing = [x for x in all_ if x.get("answered")]
859
+ for i in closing:
860
+ _archive(cfg, i, i.get("done_note") or "state question answered", time.time())
861
+ all_.remove(i)
862
+ if n or closing:
863
+ work_save(sd, all_)
864
+ return n
865
+
866
+
867
+ COMPLETED = re.compile(r"<status>\s*completed\s*</status>")
868
+ PEER_BODY = re.compile(r"<cross-session-message[^>]*>(.*?)(?:</cross-session-message>|\Z)", re.S)
869
+ PEER_RESULT = re.compile(r"(?i)\b(done|finished|complete[d]?|merged|pushed|passed|passing|green|fixed|shipped|result|ready for)\b")
870
+ PEER_NOT_RESULT = re.compile(r"(?i)\b(blocked|failed|failing|cannot|can't|couldn't|unable|error|stuck|waiting)\b")
871
+
872
+
873
+ def _report_is_about(all_: list[dict], item: dict, text: str) -> bool:
874
+ """A report closes the owner's only item; when the owner holds several, it must name this item's PR or id."""
875
+ mine = [x for x in all_ if x.get("kind") == "routed" and x["owner"] == item["owner"]]
876
+ return len(mine) == 1 or any(pr_num(u) in re.findall(r"\d+", text) for u in item["prs"]) or item["id"] in text
877
+
878
+
879
+ def is_report(text: str) -> bool:
880
+ """A peer's message reports a result: a result word, no blocker word, and it does not end on a question."""
881
+ m = PEER_BODY.search(text)
882
+ body = (m[1] if m else text).strip()
883
+ return bool(body) and not body.endswith("?") and bool(PEER_RESULT.search(body)) and not PEER_NOT_RESULT.search(body)
884
+
885
+
886
+ def peer_name(text: str) -> str:
887
+ """Name of the peer a message came from, or an empty string."""
888
+ m = FROM_NAME.search(text)
889
+ return m.group(1) if m else ""
890
+
891
+
892
+ def _entry_event(o: dict) -> tuple[str, str] | None:
893
+ """(from-name, text) of a peer message or task notification entry in a transcript, else None.
894
+
895
+ Two shapes carry them. A queue-operation entry holds the raw text the moment the message is queued
896
+ (while the hub is busy, this is the only entry for a long time). A user entry with origin.kind
897
+ peer / task-notification is written when the hub takes the message up.
898
+ """
899
+ if o.get("type") == "queue-operation":
900
+ text = o.get("content") if o.get("operation") == "enqueue" else None
901
+ text = text.lstrip() if isinstance(text, str) else ""
902
+ if text.startswith("<cross-session-message"):
903
+ return peer_name(text), text
904
+ return ("", text) if text.startswith("<task-notification") else None
905
+ kind = (o.get("origin") or {}).get("kind")
906
+ if o.get("type") != "user" or kind not in ("peer", "task-notification"):
907
+ return None
908
+ c = o.get("message", {}).get("content")
909
+ text = c if isinstance(c, str) else json.dumps(c)
910
+ return (peer_name(text), text) if kind == "peer" else ("", text)
911
+
912
+
913
+ def scan_transcript(cfg: dict, path: str | Path | None) -> int:
914
+ """Incremental scan of the hub transcript: peer messages and task notifications are 'heard from'."""
915
+ if not path:
916
+ return 0
917
+ p = Path(path)
918
+ sp = store.state(cfg) / "routed-scan.json"
919
+ try:
920
+ size = p.stat().st_size
921
+ except OSError:
922
+ return 0
923
+ try:
924
+ seen = json.loads(sp.read_text(encoding="utf-8"))
925
+ except ValueError, OSError:
926
+ seen = {}
927
+ prev = seen.get(str(p))
928
+ first = not isinstance(prev, int) or prev > size
929
+ off: int = max(0, size - SCAN_TAIL) if first or not isinstance(prev, int) else prev
930
+ with open(p, "rb") as fh:
931
+ fh.seek(off)
932
+ data = fh.read()
933
+ cut = data.rfind(b"\n") + 1 # only whole lines
934
+ lines = data[:cut].decode("utf-8", "replace").splitlines()
935
+ if first and off > 0 and lines:
936
+ lines = lines[1:] # the first line may start mid-record
937
+ events = []
938
+ for line in lines:
939
+ if "cross-session-message" not in line and "task-notification" not in line:
940
+ continue
941
+ try:
942
+ o = json.loads(line)
943
+ hit = _entry_event(o)
944
+ if hit:
945
+ events.append((_epoch(o["timestamp"]), *hit))
946
+ except ValueError, KeyError:
947
+ continue
948
+ seen[str(p)] = off + cut
949
+ store.write_atomic(sp, json.dumps(seen))
950
+ return heard(cfg, events)
951
+
952
+
953
+ def transcript_of(cfg: dict, session_id: str) -> str | None:
954
+ """Path of the transcript file for a session id, or None."""
955
+ for f in glob.glob(os.path.expanduser(cfg["paths"]["transcripts_glob"])):
956
+ if os.path.basename(f) == f"{session_id}.jsonl":
957
+ return f
958
+ return None
959
+
960
+
961
+ # ---- PR check, once per PR per tick ----
962
+
963
+
964
+ def _archive(cfg: dict, item: dict, note: str, now: float) -> None:
965
+ day = store.day_of(cfg, now)
966
+ store.append_row(
967
+ store.state(cfg) / "archive" / f"archive_{day}.jsonl",
968
+ {"type": "work", **item, "outcome": "done", "done_note": note, "ts": now},
969
+ )
970
+
971
+
972
+ def _landed(
973
+ cfg: dict, item: dict, got: dict[str, dict | None]
974
+ ) -> dict[str, tuple[str, dict | None]]:
975
+ """Per PR of a review item: ('closed'|'old'|'', detail). The newest review since the ask, from someone but the
976
+ author, judged against that PR's current head."""
977
+ out: dict[str, tuple[str, dict | None]] = {}
978
+ for u in item["prs"]:
979
+ info = got.get(u)
980
+ revs = [
981
+ r
982
+ for r in (info["reviews"] if info else [])
983
+ if r["at"] >= (item.get("routed_at") or item["added"])
984
+ and counts_as_review(cfg, r, info)
985
+ ] if info else []
986
+ if not info or not revs:
987
+ out[u] = ("", None)
988
+ continue
989
+ last = max(revs, key=lambda r: r["at"])
990
+ if same_head(last["commit"], info["head"]):
991
+ out[u] = ("closed", {**last, "url": u})
992
+ else:
993
+ out[u] = ("old", {**last, "head": info["head"], "url": u})
994
+ return out
995
+
996
+
997
+ HEX_HEAD = re.compile(r"[0-9a-f]{7,40}")
998
+ AGENT_ID = re.compile(r"^[0-9a-f]{12,}$")
999
+
1000
+
1001
+ def agent_activity(cfg: dict, now: float) -> int:
1002
+ """A background agent whose transcript or output file is still growing is not silent: count the file's
1003
+ modification time as heard-from. Returns the number of items refreshed."""
1004
+ sd = store.state(cfg)
1005
+ pats = opts(cfg)["activity_globs"]
1006
+ n = 0
1007
+ with store.locked(sd / "work.lock"):
1008
+ all_ = work_load(sd)
1009
+ for i in all_:
1010
+ if i.get("kind") != "routed" or not AGENT_ID.match(i["owner"]):
1011
+ continue
1012
+ stamps = [
1013
+ m
1014
+ for pat in pats
1015
+ for f in glob.glob(os.path.expanduser(pat.format(id=i["owner"])))
1016
+ if (m := _mtime(f)) is not None
1017
+ ]
1018
+ seen = min(max(stamps, default=0.0), now)
1019
+ if seen > base(cfg, i):
1020
+ i["last_heard"] = seen
1021
+ n += 1
1022
+ if n:
1023
+ work_save(sd, all_)
1024
+ return n
1025
+
1026
+
1027
+ def _mtime(path: str) -> float | None:
1028
+ try:
1029
+ return os.path.getmtime(path)
1030
+ except OSError:
1031
+ return None
1032
+
1033
+
1034
+ def _advance(item: dict, det: dict, now: float) -> dict:
1035
+ """A queue item's current PR has its review: move to the next PR with a fresh clock."""
1036
+ done, rest = item["queue"][0], item["queue"][1:]
1037
+ item["queue"] = rest if len(rest) > 1 else []
1038
+ item["prs"] = rest[:1]
1039
+ item["heads"] = {}
1040
+ item.update(routed_at=now, head_moved=None, verdict_old_head=None, verdict_acked=None, chased_level=None)
1041
+ _event(item, "advance", f"{_pr_no(done)} reviewed; now {_pr_no(rest[0]) if rest else 'none'}", now)
1042
+ return {
1043
+ "key": f"{item['id']}:queue_advanced:{pr_num(done)}",
1044
+ "msg": f"{item['id']} to {item['owner']}: {_pr_no(done)} {det['state']} at {det['commit'][:7]}; the clock restarts on the next PR. NEXT: read the review on GitHub and act on it",
1045
+ }
1046
+
1047
+
1048
+ def _close_landed(cfg: dict, item: dict, done: dict[str, dict], now: float) -> dict:
1049
+ """Reviews landed at the head for some PRs of a (non-queue) item: drop them from the item and report the gaps.
1050
+ The item is archived when no PR is left."""
1051
+ item["prs"] = [u for u in item["prs"] if u not in done]
1052
+ for u in done:
1053
+ item["heads"].pop(u, None)
1054
+ note = "; ".join(
1055
+ f"{_pr_no(u)} {d['state']} by {d['login']} at {d['commit'][:7]} (the head)"
1056
+ for u, d in done.items()
1057
+ )
1058
+ gap = f" Still open: {' '.join(_pr_no(u) for u in item['prs'])} (no review at its head yet)." if item["prs"] else ""
1059
+ if not item["prs"]:
1060
+ _archive(cfg, item, "review " + note, now)
1061
+ first = next(iter(done.values()))
1062
+ return {
1063
+ "key": f"{item['id']}:review_landed:{pr_num(next(iter(done)))}",
1064
+ "msg": f"{item['id']} to {item['owner']}: {note}.{gap} NEXT: read the review on GitHub and act on the {first['state']} verdict",
1065
+ }
1066
+
1067
+
1068
+ def resolve_bare_refs(cfg: dict) -> None:
1069
+ """Items recorded before `routed.repo` was set (or by hand) hold bare #N refs gh cannot read: resolve them."""
1070
+ repo = str(opts(cfg).get("repo", ""))
1071
+ if not repo:
1072
+ return
1073
+
1074
+ def fix(u: str) -> str:
1075
+ return u if u.startswith("http") else f"https://github.com/{repo}/pull/{pr_num(u)}"
1076
+
1077
+ sd = store.state(cfg)
1078
+ with store.locked(sd / "work.lock"):
1079
+ all_ = work_load(sd)
1080
+ if not any(not u.startswith("http") for i in all_ if i.get("kind") == "routed" for u in [*i["prs"], *i.get("queue", [])]):
1081
+ return
1082
+ for i in all_:
1083
+ if i.get("kind") == "routed":
1084
+ i["prs"] = [fix(u) for u in i["prs"]]
1085
+ i["queue"] = [fix(u) for u in i.get("queue", [])]
1086
+ i["heads"] = {fix(u): h for u, h in i["heads"].items()}
1087
+ work_save(sd, all_)
1088
+
1089
+
1090
+ def refresh_prs(cfg: dict, now: float) -> list[dict]:
1091
+ """Read head and reviews with gh once per PR. A failure marks the PRs unknown. A moved head is recorded for
1092
+ the alert (it does not reset the stall clock: the pusher is not known). A review after the ask counts as
1093
+ movement. A review item closes when a review lands at the PR's current head; a review on an older head never
1094
+ closes it. Returns the alerts for items closed here."""
1095
+ sd = store.state(cfg)
1096
+ resolve_bare_refs(cfg)
1097
+ todo = {u for i in items(cfg) for u in i["prs"]}
1098
+ if not todo:
1099
+ return []
1100
+ timeout = float(opts(cfg)["gh_timeout_s"])
1101
+ got = {u: pr_info(cfg, u, timeout) for u in sorted(todo)}
1102
+ msgs: list[dict] = []
1103
+ with store.locked(sd / "work.lock"):
1104
+ all_ = work_load(sd)
1105
+ keep = []
1106
+ for i in all_:
1107
+ if i.get("kind") != "routed":
1108
+ keep.append(i)
1109
+ continue
1110
+ i["pr_unknown"] = [u for u in i["prs"] if got.get(u) is None]
1111
+ for u in i["prs"]:
1112
+ info = got.get(u)
1113
+ if not info:
1114
+ continue
1115
+ old = i["heads"].get(u)
1116
+ if not (old and HEX_HEAD.fullmatch(old)): # unset, or mangled by an older redaction: rebaseline
1117
+ i["heads"][u] = info["head"]
1118
+ elif owner_is_author(cfg, i["owner"], info["author"]):
1119
+ i["review"] = False # the person asked wrote the PR: their pushes are the work, not a stale review
1120
+ i["heads"][u] = info["head"]
1121
+ elif i.get("pushes") and not i.get("review"):
1122
+ i["heads"][u] = info["head"] # the owner was asked to push to this PR: its pushes are the work
1123
+ elif owner_is_author(cfg, i["owner"], info.get("head_author", "")):
1124
+ i["heads"][u] = info["head"] # the head moved by the owner's own commit: progress, not a stray push
1125
+ i["last_heard"] = max(i.get("last_heard") or 0, now)
1126
+ elif not same_head(old, info["head"]):
1127
+ i["head_moved"] = {
1128
+ "url": u,
1129
+ "old": old,
1130
+ "new": info["head"],
1131
+ "at": now,
1132
+ }
1133
+ _event(i, "head_moved", f"{old[:7]} -> {info['head'][:7]}", now)
1134
+ i["heads"][u] = info["head"]
1135
+ if i.get("waiting") and info["review_at"] and info["review_at"] > (i.get("routed_at") or 0):
1136
+ i["waiting"] = False # a review shows on the PR: the owner has started it
1137
+ if info["review_at"] and info["review_at"] > base(cfg, i):
1138
+ i["last_heard"] = info["review_at"]
1139
+ res = _landed(cfg, i, got) if i.get("review") else {}
1140
+ old = next((d for v, d in res.values() if v == "old" and d), None)
1141
+ acked = i.get("verdict_acked")
1142
+ i["verdict_old_head"] = (
1143
+ old if old and acked != [old["commit"], old["head"]] else None
1144
+ )
1145
+ done = {u: d for u, (v, d) in res.items() if v == "closed" and d}
1146
+ if done and i.get("queue"):
1147
+ cur = i["prs"][0]
1148
+ if cur in done:
1149
+ msgs.append(_advance(i, done[cur], now))
1150
+ elif done:
1151
+ msgs.append(_close_landed(cfg, i, done, now))
1152
+ if not i["prs"]:
1153
+ continue
1154
+ keep.append(i)
1155
+ work_save(sd, keep)
1156
+ return msgs
1157
+
1158
+
1159
+ def close_asks(cfg: dict, now: float) -> int:
1160
+ """The user answered: every open ask-the-user item is done."""
1161
+ sd = store.state(cfg)
1162
+ with store.locked(sd / "work.lock"):
1163
+ all_ = work_load(sd)
1164
+ done = [i for i in all_ if i.get("kind") == "ask"]
1165
+ for i in done:
1166
+ _archive(cfg, i, "answered", now)
1167
+ if done:
1168
+ work_save(sd, [i for i in all_ if i.get("kind") != "ask"])
1169
+ return len(done)