hubkit-cli 0.2.1__tar.gz → 0.2.2__tar.gz

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 (59) hide show
  1. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/CHANGELOG.md +5 -0
  2. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/PKG-INFO +1 -1
  3. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/pyproject.toml +1 -1
  4. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/routed.py +314 -83
  5. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/run_reverts.py +1 -0
  6. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_review_chain.py +13 -1
  7. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/.gitignore +0 -0
  8. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/CLAUDE.md +0 -0
  9. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/CONTRIBUTING.md +0 -0
  10. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/LICENSE +0 -0
  11. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/README.md +0 -0
  12. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/SECURITY.md +0 -0
  13. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/SKILL.md +0 -0
  14. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/config.example.toml +0 -0
  15. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/docs/architecture.md +0 -0
  16. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/docs/commands.md +0 -0
  17. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/docs/configuration.md +0 -0
  18. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/docs/troubleshooting.md +0 -0
  19. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/intents.example.toml +0 -0
  20. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/references/design-sources.md +0 -0
  21. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/references/research.md +0 -0
  22. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/__init__.py +0 -0
  23. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/cli.py +0 -0
  24. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/compat.py +0 -0
  25. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/config.py +0 -0
  26. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/gate.py +0 -0
  27. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/hooks.py +0 -0
  28. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/inbound.py +0 -0
  29. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/install.py +0 -0
  30. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/learnings.py +0 -0
  31. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/ledger.py +0 -0
  32. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/lessons.py +0 -0
  33. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/orders.py +0 -0
  34. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/redact.py +0 -0
  35. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/slow.py +0 -0
  36. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/status.py +0 -0
  37. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/store.py +0 -0
  38. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/trust.py +0 -0
  39. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubcore/watcher.py +0 -0
  40. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/hubkit +0 -0
  41. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/scripts/learn.py +0 -0
  42. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/skills/hub-mep/SKILL.md +0 -0
  43. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/skills/hubkit-setup/SKILL.md +0 -0
  44. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/conftest.py +0 -0
  45. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_blockers.py +0 -0
  46. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_compat.py +0 -0
  47. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_guards2.py +0 -0
  48. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_hubkit.py +0 -0
  49. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_inbound.py +0 -0
  50. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_install.py +0 -0
  51. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_learning_rules.py +0 -0
  52. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_ledger.py +0 -0
  53. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_lessons.py +0 -0
  54. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_own_work.py +0 -0
  55. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_peer_reports.py +0 -0
  56. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_slow.py +0 -0
  57. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_state_rules.py +0 -0
  58. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_tool_guards.py +0 -0
  59. {hubkit_cli-0.2.1 → hubkit_cli-0.2.2}/tests/test_watcher.py +0 -0
@@ -4,6 +4,11 @@ All notable changes to this project are recorded here. The format follows [Keep
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.2] - 2026-11-06
8
+
9
+ ### Fixed
10
+ - A fix job whose change verb and PR number are in different sentences ("Fix the gaps. ... PR #7") is still recognised as a fix job, so its owner pushing to the PR is not counted as a stalled or moved head.
11
+
7
12
  ## [0.2.1] - 2026-11-06
8
13
 
9
14
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hubkit-cli
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Session supervisor for Claude Code: hooks-based multi-agent orchestration with order clocks, routed-work tracking, a watchdog and a stop gate, standard library only.
5
5
  Project-URL: Repository, https://github.com/ankitkpandey1/hubkit
6
6
  Project-URL: Documentation, https://github.com/ankitkpandey1/hubkit/tree/main/docs
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hubkit-cli"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "Session supervisor for Claude Code: hooks-based multi-agent orchestration with order clocks, routed-work tracking, a watchdog and a stop gate, standard library only."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -49,16 +49,23 @@ def opts(cfg: dict) -> dict:
49
49
  PUSH = re.compile(
50
50
  r"(?i)\b(fix(?:es|ed)?|address|rebase|implement|resolve|amend|patch|push|rework|refactor)\b"
51
51
  )
52
- EXAMPLE = re.compile(r"(?i)\b(examples?|e\.g\.|for instance|such as|sample|placeholder|hypothetical)\b")
52
+ EXAMPLE = re.compile(
53
+ r"(?i)\b(examples?|e\.g\.|for instance|such as|sample|placeholder|hypothetical)\b"
54
+ )
53
55
  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
+ NOUN_BEFORE = re.compile(
57
+ r"(?i)\b(the|his|her|their|its|a|an|my|our|your|that|this|in|from|with)\s+$"
58
+ )
59
+ NOUN_AFTER = re.compile(
60
+ r"(?i)^s?\s+(comments?|findings?|feedback|notes?|threads?|rounds?)\b"
61
+ )
56
62
 
57
63
 
58
64
  def strip_examples(text: str) -> str:
59
65
  """The text without what only illustrates: code fences, backtick and quoted spans, and the part of a sentence from
60
66
  "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."""
67
+ stays: "Review #7, e.g. the loader part" still asks for #7. A PR number inside an example is not an ask.
68
+ """
62
69
  text = QUOTED.sub(" ", text)
63
70
  kept: list[str] = []
64
71
  skip_next = False
@@ -86,11 +93,20 @@ def _kinds(cfg: dict, sentence: str) -> set[str]:
86
93
  PR, not to review it. "push" marks a change verb anywhere."""
87
94
  low = sentence.lower()
88
95
  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)
96
+ hits = [
97
+ m
98
+ for w in (x.lower() for x in opts(cfg)["review_words"])
99
+ for m in re.finditer(re.escape(w), low)
100
+ ]
101
+ existing = all(
102
+ NOUN_BEFORE.search(low[: m.start()]) or NOUN_AFTER.match(low[m.end() :])
103
+ for m in hits
104
+ )
91
105
  words = [x.lower() for x in opts(cfg)["review_words"]]
92
106
  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)
107
+ asks_later = any(
108
+ any(w in c for w in words) and not LEADS_CHANGE.match(c) for c in later
109
+ )
94
110
  if hits and not (LEADS_CHANGE.match(low) and existing and not asks_later):
95
111
  out.add("review")
96
112
  return out
@@ -98,7 +114,9 @@ def _kinds(cfg: dict, sentence: str) -> set[str]:
98
114
 
99
115
  def _asks(cfg: dict, text: str, summary: str = "") -> set[str]:
100
116
  """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)]
117
+ sentences = [
118
+ x for x in re.split(r"(?<=[.!?])\s+|\n", text) if x.strip() and PR_REF.search(x)
119
+ ]
102
120
  if summary.strip() and PR_REF.search(text + " " + summary):
103
121
  sentences.append(summary)
104
122
  return set().union(*(_kinds(cfg, x) for x in sentences)) if sentences else set()
@@ -113,24 +131,38 @@ def is_review(cfg: dict, text: str, summary: str = "") -> bool:
113
131
 
114
132
  def pushes_to_pr(cfg: dict, text: str, summary: str = "") -> bool:
115
133
  """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)
134
+ body = strip_examples(text)
135
+ if "push" in _asks(cfg, body, summary):
136
+ return True
137
+ # a fix job whose change verb sits in another sentence than the PR number ("Fix the gaps. ... PR #7"): still a fix job
138
+ return (
139
+ bool(PR_REF.search(text + " " + summary))
140
+ and bool(PUSH.search(body + " " + summary))
141
+ and not is_review(cfg, text, summary)
142
+ )
117
143
 
118
144
 
119
145
  def owner_is_author(cfg: dict, owner: str, author: str) -> bool:
120
146
  """Is the owner the PR's author? A peer or agent has no GitHub login, so this uses the data on hand: an
121
147
  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."""
148
+ it (a login of 4+ characters). An agent id never matches, so it is treated as a non-author.
149
+ """
123
150
  login = author.lower()
124
151
  if not login:
125
152
  return False
126
153
  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())
154
+ return (
155
+ (mapped or "").lower() == login
156
+ or owner.lower() == login
157
+ or (len(login) >= 4 and login in owner.lower())
158
+ )
128
159
 
129
160
 
130
161
  def is_chase(cfg: dict, text: str) -> bool:
131
162
  """A short status question about a PR: an event on the existing item, not a new ask. A message with a brief marker
132
163
  (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."""
164
+ says; a status question that merely mentions a review ("state of the review on #N") is not.
165
+ """
134
166
  sentences = [x.strip() for x in re.split(r"(?<=[.!?])\s+|\n", text)]
135
167
  if DELIVERABLE.search(text) or any(DIRECTIVE.match(x) for x in sentences):
136
168
  return False
@@ -152,14 +184,17 @@ DELIVERABLE = re.compile(r"(?i)done[- ]when|\boutput\b|\bdeliverable")
152
184
 
153
185
  def is_deliverable_brief(cfg: dict, description: str, prompt: str) -> bool:
154
186
  """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."""
187
+ not a standing watcher (`routed.skip_briefs`); a long-running agent with nothing to deliver cannot stall.
188
+ """
156
189
  low = f"{description} {prompt}".lower()
157
190
  if any(w.lower() in low for w in opts(cfg)["skip_briefs"]):
158
191
  return False
159
192
  return bool(DELIVERABLE.search(prompt) or PR_REF.search(prompt))
160
193
 
161
194
 
162
- def _is_reply(cfg: dict, owner: str, text: str, asked: list[str], now: float | None) -> bool:
195
+ def _is_reply(
196
+ cfg: dict, owner: str, text: str, asked: list[str], now: float | None
197
+ ) -> bool:
163
198
  """The hub answering a peer who wrote to it recently, with no PR to work on and no brief, is not an ask."""
164
199
  if asked or DELIVERABLE.search(text):
165
200
  return False
@@ -199,7 +234,9 @@ def is_work(cfg: dict, owner: str, text: str, asked: list[str] | None = None) ->
199
234
  o = opts(cfg)
200
235
  if any(s.lower() in owner.lower() for s in o["skip_owners"]):
201
236
  return False
202
- if ACK.match(text) and not (DELIVERABLE.search(text) or DIRECTIVE.search(text) or REQUEST.search(text)):
237
+ if ACK.match(text) and not (
238
+ DELIVERABLE.search(text) or DIRECTIVE.search(text) or REQUEST.search(text)
239
+ ):
203
240
  return False # "Ack #399 hold": a bare acknowledgement that names a PR is not an ask; any request cue makes it one
204
241
  if asked if asked is not None else PR_REF.search(text):
205
242
  return True
@@ -215,8 +252,14 @@ SHORT_ASK = re.compile(
215
252
 
216
253
  def is_short_ask(text: str) -> bool:
217
254
  """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))
255
+ Not an acknowledgement, not a status question (that is a chase), and long enough to carry a request.
256
+ """
257
+ return (
258
+ len(text.strip()) >= 15
259
+ and not ACK.match(text)
260
+ and not CHASE.search(text)
261
+ and bool(SHORT_ASK.search(text))
262
+ )
220
263
 
221
264
 
222
265
  REQUEST = re.compile(
@@ -228,7 +271,9 @@ DIRECTIVE = re.compile(
228
271
  )
229
272
 
230
273
 
231
- def asked_refs(cfg: dict, text: str, summary: str = "", cap: int | None = MAX_QUEUE) -> list[str]:
274
+ def asked_refs(
275
+ cfg: dict, text: str, summary: str = "", cap: int | None = MAX_QUEUE
276
+ ) -> list[str]:
232
277
  """The PRs the owner is asked to work on. A short message asks about every PR it names. In a long one only
233
278
  the summary and sentences that direct an action at a PR count: a PR named in passing (a bug
234
279
  list, a status) is not an ask."""
@@ -247,7 +292,11 @@ def pr_num(ref: str) -> str:
247
292
  def pr_key(ref: str) -> str:
248
293
  """Repo and number of a PR ref: `owner/repo#N` for a URL, `#N` for a bare number (repo not known)."""
249
294
  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)
295
+ return (
296
+ f"{parts[3]}/{parts[4]}#{parts[6]}".lower()
297
+ if ref.startswith("http") and len(parts) >= 7
298
+ else "#" + pr_num(ref)
299
+ )
251
300
 
252
301
 
253
302
  def same_pr(a: str, b: str) -> bool:
@@ -287,7 +336,11 @@ def _repo_refs(cfg: dict, text: str) -> tuple[list[str], str]:
287
336
 
288
337
  text = REPO_REF.sub(repl(), text)
289
338
  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)
339
+ text = re.sub(
340
+ rf"(?i)(?<![\w/.-]){re.escape(alias)}(?:\s*#|\s+pr\s*#?\s*|\s+)(\d{{2,6}})\b",
341
+ repl(repo),
342
+ text,
343
+ )
291
344
  return list(dict.fromkeys(out)), text
292
345
 
293
346
 
@@ -335,9 +388,17 @@ def pr_info(cfg: dict, url: str, timeout: float) -> dict | None:
335
388
  return None
336
389
  try:
337
390
  r = subprocess.run(
338
- [exe, "pr", "view", url, "--json", "headRefOid,reviews,author,commits,state,statusCheckRollup"],
391
+ [
392
+ exe,
393
+ "pr",
394
+ "view",
395
+ url,
396
+ "--json",
397
+ "headRefOid,reviews,author,commits,state,statusCheckRollup",
398
+ ],
339
399
  capture_output=True,
340
- encoding="utf-8", errors="replace",
400
+ encoding="utf-8",
401
+ errors="replace",
341
402
  timeout=timeout,
342
403
  check=False,
343
404
  )
@@ -366,7 +427,14 @@ def pr_info(cfg: dict, url: str, timeout: float) -> dict | None:
366
427
  return None
367
428
 
368
429
 
369
- FAILED_CHECK = {"FAILURE", "TIMED_OUT", "CANCELLED", "STARTUP_FAILURE", "ACTION_REQUIRED", "ERROR"}
430
+ FAILED_CHECK = {
431
+ "FAILURE",
432
+ "TIMED_OUT",
433
+ "CANCELLED",
434
+ "STARTUP_FAILURE",
435
+ "ACTION_REQUIRED",
436
+ "ERROR",
437
+ }
370
438
  OK_CHECK = {"SUCCESS", "NEUTRAL", "SKIPPED"}
371
439
 
372
440
 
@@ -374,7 +442,11 @@ def ci_state(rollup: object) -> str:
374
442
  """CI at the PR's head from gh's statusCheckRollup: 'failing', 'pending', 'green', or 'none' (no checks reported)."""
375
443
  if not isinstance(rollup, list) or not rollup:
376
444
  return "none"
377
- seen = {str(c.get("conclusion") or c.get("state") or "").upper() or "PENDING" for c in rollup if isinstance(c, dict)}
445
+ seen = {
446
+ str(c.get("conclusion") or c.get("state") or "").upper() or "PENDING"
447
+ for c in rollup
448
+ if isinstance(c, dict)
449
+ }
378
450
  if seen & FAILED_CHECK:
379
451
  return "failing"
380
452
  return "green" if seen <= OK_CHECK else "pending"
@@ -445,12 +517,15 @@ def _same_job(i: dict, owner: str, prs: list[str], summary: str) -> bool:
445
517
  return False
446
518
  if prs:
447
519
  return overlaps([*i["prs"], *i.get("queue", [])], prs)
448
- return not i["prs"] # no PR named: a follow-up to the same owner is the same outcome
520
+ return not i[
521
+ "prs"
522
+ ] # no PR named: a follow-up to the same owner is the same outcome
449
523
 
450
524
 
451
525
  def _chase(cfg: dict, item: dict, text: str, heads: dict[str, str], now: float) -> None:
452
526
  """A follow-up to the owner about work already on the item: counted, never a new clock. It answers a moved head
453
- or an old-head verdict (the hub acted), so those flags clear and the new head becomes the baseline."""
527
+ or an old-head verdict (the hub acted), so those flags clear and the new head becomes the baseline.
528
+ """
454
529
  _event(item, "chase", text, now)
455
530
  item["chases"] = int(item.get("chases", 0)) + 1
456
531
  item["addressed"] = [*item.get("addressed", []), now]
@@ -462,7 +537,9 @@ def _chase(cfg: dict, item: dict, text: str, heads: dict[str, str], now: float)
462
537
  item["heads"] = {**item["heads"], **heads}
463
538
 
464
539
 
465
- def chase_owner(cfg: dict, owner: str, text: str, now: float | None = None) -> dict | None:
540
+ def chase_owner(
541
+ cfg: dict, owner: str, text: str, now: float | None = None
542
+ ) -> dict | None:
466
543
  """A message to an owner who has open items, naming no PR and carrying no new brief, is a follow-up on all of
467
544
  them. Returns the first item, or None when the owner has none."""
468
545
  now = now or time.time()
@@ -508,7 +585,9 @@ def record(
508
585
  if hit is not None and (chase or follow_up):
509
586
  _chase(cfg, hit, summary, heads, now)
510
587
  if not hit.get("queue"):
511
- hit["prs"] = _merge_prs(hit["prs"], prs) # a full URL replaces a bare #N
588
+ hit["prs"] = _merge_prs(
589
+ hit["prs"], prs
590
+ ) # a full URL replaces a bare #N
512
591
  work_save(sd, all_)
513
592
  return hit
514
593
  is_new = hit is None
@@ -528,14 +607,37 @@ def record(
528
607
  all_.append(hit)
529
608
  moved: list[dict] = []
530
609
  if is_new:
531
- hit["behind"] = [i["id"] for i in all_ if i is not hit and i.get("kind") == "routed" and i["owner"] == owner]
532
- for other in all_: # a brief on the same PRs to another owner is a re-brief: it addresses the old item
533
- if other is not hit and other.get("kind") == "routed" and other["owner"] != owner and overlaps(named, [*other["prs"], *other.get("queue", [])]):
610
+ hit["behind"] = [
611
+ i["id"]
612
+ for i in all_
613
+ if i is not hit and i.get("kind") == "routed" and i["owner"] == owner
614
+ ]
615
+ for (
616
+ other
617
+ ) in (
618
+ all_
619
+ ): # a brief on the same PRs to another owner is a re-brief: it addresses the old item
620
+ if (
621
+ other is not hit
622
+ and other.get("kind") == "routed"
623
+ and other["owner"] != owner
624
+ and overlaps(named, [*other["prs"], *other.get("queue", [])])
625
+ ):
534
626
  other["addressed"] = [*other.get("addressed", []), now]
535
- if brief: # a real re-brief moves the PRs: the old owner no longer holds them
536
- other["prs"] = [u for u in other["prs"] if not overlaps([u], named)]
537
- other["queue"] = [u for u in other.get("queue", []) if not overlaps([u], named)]
538
- other["heads"] = {u: h for u, h in other["heads"].items() if u in other["prs"]}
627
+ if (
628
+ brief
629
+ ): # a real re-brief moves the PRs: the old owner no longer holds them
630
+ other["prs"] = [
631
+ u for u in other["prs"] if not overlaps([u], named)
632
+ ]
633
+ other["queue"] = [
634
+ u
635
+ for u in other.get("queue", [])
636
+ if not overlaps([u], named)
637
+ ]
638
+ other["heads"] = {
639
+ u: h for u, h in other["heads"].items() if u in other["prs"]
640
+ }
539
641
  if not other["prs"]:
540
642
  _archive(cfg, other, f"re-briefed to {owner}", now)
541
643
  moved.append(other)
@@ -557,7 +659,9 @@ def record(
557
659
  chased_level=None,
558
660
  )
559
661
  _set_estimate(cfg, hit, estimate, now)
560
- hit.setdefault("heads0", dict(heads)) # the head when first asked: a later head is the owner's artifact
662
+ hit.setdefault(
663
+ "heads0", dict(heads)
664
+ ) # the head when first asked: a later head is the owner's artifact
561
665
  hit["heads"] = {
562
666
  **hit["heads"],
563
667
  **heads,
@@ -578,8 +682,21 @@ def _set_estimate(cfg: dict, item: dict, given: int | None, now: float) -> None:
578
682
  from . import slow
579
683
 
580
684
  mins, basis = (float(given), "brief") if given else slow.item_default(cfg, item)
581
- item["estimates"] = [*item.get("estimates", []), {"t": now, "min": mins, "source": "brief" if given else "default", "basis": basis}]
582
- item.update(estimate_min=mins, estimate_defaulted=not given, estimate_basis=basis, addressed=[])
685
+ item["estimates"] = [
686
+ *item.get("estimates", []),
687
+ {
688
+ "t": now,
689
+ "min": mins,
690
+ "source": "brief" if given else "default",
691
+ "basis": basis,
692
+ },
693
+ ]
694
+ item.update(
695
+ estimate_min=mins,
696
+ estimate_defaulted=not given,
697
+ estimate_basis=basis,
698
+ addressed=[],
699
+ )
583
700
 
584
701
 
585
702
  def from_tool(
@@ -599,7 +716,9 @@ def from_tool(
599
716
  if _is_follow_up(cfg, owner, text):
600
717
  return chase_owner(cfg, owner, str(ti.get("summary") or text), now)
601
718
  asked = asked_refs(cfg, text, str(ti.get("summary") or ""))
602
- if _is_reply(cfg, owner, text, asked, now) or not is_work(cfg, owner, text, asked):
719
+ if _is_reply(cfg, owner, text, asked, now) or not is_work(
720
+ cfg, owner, text, asked
721
+ ):
603
722
  return None
604
723
  summary = str(ti.get("summary") or text.splitlines()[0])[:120]
605
724
  chase = is_chase(cfg, text)
@@ -620,7 +739,9 @@ def from_tool(
620
739
  else:
621
740
  return None
622
741
  every = asked_refs(cfg, text, summary)
623
- queue = every if len(every) > 1 else [] # one PR per clock: several PRs are a queue, the first is current
742
+ queue = (
743
+ every if len(every) > 1 else []
744
+ ) # one PR per clock: several PRs are a queue, the first is current
624
745
  prs = every[:1] if queue else every
625
746
  heads: dict[str, str] = {}
626
747
  authored = False
@@ -732,8 +853,10 @@ def next_action(cfg: dict, item: dict, now: float) -> str:
732
853
  lvl = eff_level(cfg, item, now)
733
854
  if item.get("reported") and not item.get("review"):
734
855
  prs = " ".join(_pr_no(u) for u in item["prs"])
735
- return (f"{o} reported it done {int((now - item['reported']) / 60)} min ago; that is a claim. Check the artifact"
736
- f"{' (' + prs + ')' if prs else ''}, then: hubkit work done {item['id']} --note \"<what you checked>\"")
856
+ return (
857
+ f"{o} reported it done {int((now - item['reported']) / 60)} min ago; that is a claim. Check the artifact"
858
+ f"{' (' + prs + ')' if prs else ''}, then: hubkit work done {item['id']} --note \"<what you checked>\""
859
+ )
737
860
  if lvl is not None:
738
861
  if item.get("chased_level") is not None and item["chased_level"] >= lvl:
739
862
  nxt = base(cfg, item) + limit(cfg, item) * 60 * (lvl + 2)
@@ -813,7 +936,9 @@ def alerts(cfg: dict, now: float) -> list[dict]:
813
936
  }
814
937
  )
815
938
  lvl = eff_level(cfg, i, now)
816
- if lvl is not None and needs_action(cfg, i, now): # a chased item waits: no alert, no wake-up
939
+ if lvl is not None and needs_action(
940
+ cfg, i, now
941
+ ): # a chased item waits: no alert, no wake-up
817
942
  out.append(
818
943
  {
819
944
  "key": f"{i['id']}:routed_stalled:L{lvl}",
@@ -836,8 +961,10 @@ def block_lines(cfg: dict, work: list[dict], now: float) -> list[str]:
836
961
  pr = ", ".join(
837
962
  f"{_pr_no(u)}@{i['heads'].get(u, '?')[:7]}" for u in i.get("prs", [])
838
963
  )
839
- flags = (f" +{len(i['queue']) - 1} queued" if i.get("queue") else "") + (" REVIEW" if i.get("review") else "") + (
840
- " PR unknown" if i.get("pr_unknown") else ""
964
+ flags = (
965
+ (f" +{len(i['queue']) - 1} queued" if i.get("queue") else "")
966
+ + (" REVIEW" if i.get("review") else "")
967
+ + (" PR unknown" if i.get("pr_unknown") else "")
841
968
  )
842
969
  out.append(
843
970
  f" {i['id']} -> {i['owner']}: {i['title'][:50]}{' [' + pr + ']' if pr else ''}{flags}; "
@@ -868,15 +995,28 @@ def heard(cfg: dict, events: list[tuple[float, str, str]]) -> int:
868
995
  i["owner"] in text
869
996
  or bool(i.get("tool_use_id") and i["tool_use_id"] in text)
870
997
  )
871
- if by_text and COMPLETED.search(text) and ts > (i.get("routed_at") or i["added"]):
998
+ if (
999
+ by_text
1000
+ and COMPLETED.search(text)
1001
+ and ts > (i.get("routed_at") or i["added"])
1002
+ ):
872
1003
  i["reported"] = ts # the agent finished after this ask and reported
873
- if not i.get("review"): # a review item stays: the review check on the PR decides
1004
+ if not i.get(
1005
+ "review"
1006
+ ): # a review item stays: the review check on the PR decides
874
1007
  i["answered"] = True
875
1008
  i["done_note"] = "agent completed (task notification)"
876
1009
  from_owner = bool(name) and name.lower() == i["owner"].lower()
877
- if from_owner and not i.get("review") and ts > (i.get("routed_at") or i["added"]) and is_report(text) \
878
- and _report_is_about(all_, i, text):
879
- i["reported"] = ts # a claim, not evidence: the item closes on the artifact (refresh_prs) or on `work done`
1010
+ if (
1011
+ from_owner
1012
+ and not i.get("review")
1013
+ and ts > (i.get("routed_at") or i["added"])
1014
+ and is_report(text)
1015
+ and _report_is_about(all_, i, text)
1016
+ ):
1017
+ i["reported"] = (
1018
+ ts # a claim, not evidence: the item closes on the artifact (refresh_prs) or on `work done`
1019
+ )
880
1020
  i["claimed"] = text[:160]
881
1021
  n += 1 # a change to save even when no last_heard moved
882
1022
  if (name.lower() == i["owner"].lower() or by_text) and ts > (
@@ -885,10 +1025,14 @@ def heard(cfg: dict, events: list[tuple[float, str, str]]) -> int:
885
1025
  i["last_heard"] = ts
886
1026
  n += 1
887
1027
  if i.get("state_ask") and ts > (i.get("routed_at") or 0):
888
- i["answered"] = True # a state question is answered by one reply
1028
+ i["answered"] = (
1029
+ True # a state question is answered by one reply
1030
+ )
889
1031
  closing = [x for x in all_ if x.get("answered")]
890
1032
  for i in closing:
891
- _archive(cfg, i, i.get("done_note") or "state question answered", time.time())
1033
+ _archive(
1034
+ cfg, i, i.get("done_note") or "state question answered", time.time()
1035
+ )
892
1036
  all_.remove(i)
893
1037
  if n or closing:
894
1038
  work_save(sd, all_)
@@ -896,22 +1040,39 @@ def heard(cfg: dict, events: list[tuple[float, str, str]]) -> int:
896
1040
 
897
1041
 
898
1042
  COMPLETED = re.compile(r"<status>\s*completed\s*</status>")
899
- PEER_BODY = re.compile(r"<cross-session-message[^>]*>(.*?)(?:</cross-session-message>|\Z)", re.S)
900
- PEER_RESULT = re.compile(r"(?i)\b(done|finished|complete[d]?|merged|pushed|passed|passing|green|fixed|shipped|result|ready for)\b")
901
- PEER_NOT_RESULT = re.compile(r"(?i)\b(blocked|failed|failing|cannot|can't|couldn't|unable|error|stuck|waiting)\b")
1043
+ PEER_BODY = re.compile(
1044
+ r"<cross-session-message[^>]*>(.*?)(?:</cross-session-message>|\Z)", re.S
1045
+ )
1046
+ PEER_RESULT = re.compile(
1047
+ r"(?i)\b(done|finished|complete[d]?|merged|pushed|passed|passing|green|fixed|shipped|result|ready for)\b"
1048
+ )
1049
+ PEER_NOT_RESULT = re.compile(
1050
+ r"(?i)\b(blocked|failed|failing|cannot|can't|couldn't|unable|error|stuck|waiting)\b"
1051
+ )
902
1052
 
903
1053
 
904
1054
  def _report_is_about(all_: list[dict], item: dict, text: str) -> bool:
905
1055
  """A report closes the owner's only item; when the owner holds several, it must name this item's PR or id."""
906
- mine = [x for x in all_ if x.get("kind") == "routed" and x["owner"] == item["owner"]]
907
- 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
1056
+ mine = [
1057
+ x for x in all_ if x.get("kind") == "routed" and x["owner"] == item["owner"]
1058
+ ]
1059
+ return (
1060
+ len(mine) == 1
1061
+ or any(pr_num(u) in re.findall(r"\d+", text) for u in item["prs"])
1062
+ or item["id"] in text
1063
+ )
908
1064
 
909
1065
 
910
1066
  def is_report(text: str) -> bool:
911
1067
  """A peer's message reports a result: a result word, no blocker word, and it does not end on a question."""
912
1068
  m = PEER_BODY.search(text)
913
1069
  body = (m[1] if m else text).strip()
914
- return bool(body) and not body.endswith("?") and bool(PEER_RESULT.search(body)) and not PEER_NOT_RESULT.search(body)
1070
+ return (
1071
+ bool(body)
1072
+ and not body.endswith("?")
1073
+ and bool(PEER_RESULT.search(body))
1074
+ and not PEER_NOT_RESULT.search(body)
1075
+ )
915
1076
 
916
1077
 
917
1078
  def peer_name(text: str) -> str:
@@ -1008,12 +1169,16 @@ def _landed(
1008
1169
  out: dict[str, tuple[str, dict | None]] = {}
1009
1170
  for u in item["prs"]:
1010
1171
  info = got.get(u)
1011
- revs = [
1012
- r
1013
- for r in (info["reviews"] if info else [])
1014
- if r["at"] >= (item.get("routed_at") or item["added"])
1015
- and counts_as_review(cfg, r, info)
1016
- ] if info else []
1172
+ revs = (
1173
+ [
1174
+ r
1175
+ for r in (info["reviews"] if info else [])
1176
+ if r["at"] >= (item.get("routed_at") or item["added"])
1177
+ and counts_as_review(cfg, r, info)
1178
+ ]
1179
+ if info
1180
+ else []
1181
+ )
1017
1182
  if not info or not revs:
1018
1183
  out[u] = ("", None)
1019
1184
  continue
@@ -1068,11 +1233,24 @@ def _advance(item: dict, det: dict, now: float) -> dict:
1068
1233
  item["queue"] = rest if len(rest) > 1 else []
1069
1234
  item["prs"] = rest[:1]
1070
1235
  item["heads"] = {}
1071
- item.update(routed_at=now, head_moved=None, verdict_old_head=None, verdict_acked=None, chased_level=None)
1072
- _event(item, "advance", f"{_pr_no(done)} reviewed; now {_pr_no(rest[0]) if rest else 'none'}", now)
1236
+ item.update(
1237
+ routed_at=now,
1238
+ head_moved=None,
1239
+ verdict_old_head=None,
1240
+ verdict_acked=None,
1241
+ chased_level=None,
1242
+ )
1243
+ _event(
1244
+ item,
1245
+ "advance",
1246
+ f"{_pr_no(done)} reviewed; now {_pr_no(rest[0]) if rest else 'none'}",
1247
+ now,
1248
+ )
1073
1249
  return {
1074
1250
  "key": f"{item['id']}:queue_advanced:{pr_num(done)}",
1075
- "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",
1251
+ "msg": (
1252
+ 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"
1253
+ ),
1076
1254
  }
1077
1255
 
1078
1256
 
@@ -1081,7 +1259,11 @@ def artifact_exists(item: dict, url: str, info: dict) -> bool:
1081
1259
  if info.get("state") in ("MERGED", "CLOSED"):
1082
1260
  return True
1083
1261
  first = str((item.get("heads0") or {}).get(url) or "")
1084
- return bool(first) and not same_head(first, info["head"]) and info.get("ci", "none") != "failing"
1262
+ return (
1263
+ bool(first)
1264
+ and not same_head(first, info["head"])
1265
+ and info.get("ci", "none") != "failing"
1266
+ )
1085
1267
 
1086
1268
 
1087
1269
  def next_step(cfg: dict, d: dict, owner: str) -> str:
@@ -1103,7 +1285,13 @@ def next_step(cfg: dict, d: dict, owner: str) -> str:
1103
1285
 
1104
1286
  def _reply_owed(cfg: dict, item: dict, done: dict[str, dict]) -> str:
1105
1287
  """Names the people who asked the hub for this review (inbound reply items on the same PRs)."""
1106
- who = sorted({x["owner"] for x in work_load(store.state(cfg)) if x.get("kind") == "reply" and overlaps(list(done), x.get("prs", []))})
1288
+ who = sorted(
1289
+ {
1290
+ x["owner"]
1291
+ for x in work_load(store.state(cfg))
1292
+ if x.get("kind") == "reply" and overlaps(list(done), x.get("prs", []))
1293
+ }
1294
+ )
1107
1295
  return f" Reply owed to: {', '.join(who)}." if who else ""
1108
1296
 
1109
1297
 
@@ -1117,13 +1305,19 @@ def _close_landed(cfg: dict, item: dict, done: dict[str, dict], now: float) -> d
1117
1305
  f"{_pr_no(u)} {d['state']} by {d['login']} at {d['commit'][:7]} (the head), CI {d.get('ci', 'none')}"
1118
1306
  for u, d in done.items()
1119
1307
  )
1120
- gap = f" Still open: {' '.join(_pr_no(u) for u in item['prs'])} (no review at its head yet)." if item["prs"] else ""
1308
+ gap = (
1309
+ f" Still open: {' '.join(_pr_no(u) for u in item['prs'])} (no review at its head yet)."
1310
+ if item["prs"]
1311
+ else ""
1312
+ )
1121
1313
  if not item["prs"]:
1122
1314
  _archive(cfg, item, "review " + note, now)
1123
1315
  first = next(iter(done.values()))
1124
1316
  return {
1125
1317
  "key": f"{item['id']}:review_landed:{pr_num(next(iter(done)))}",
1126
- "msg": f"{item['id']} to {item['owner']}: {note}.{gap}{_reply_owed(cfg, item, done)} NEXT: {next_step(cfg, first, item['owner'])}",
1318
+ "msg": (
1319
+ f"{item['id']} to {item['owner']}: {note}.{gap}{_reply_owed(cfg, item, done)} NEXT: {next_step(cfg, first, item['owner'])}"
1320
+ ),
1127
1321
  }
1128
1322
 
1129
1323
 
@@ -1134,12 +1328,19 @@ def resolve_bare_refs(cfg: dict) -> None:
1134
1328
  return
1135
1329
 
1136
1330
  def fix(u: str) -> str:
1137
- return u if u.startswith("http") else f"https://github.com/{repo}/pull/{pr_num(u)}"
1331
+ return (
1332
+ u if u.startswith("http") else f"https://github.com/{repo}/pull/{pr_num(u)}"
1333
+ )
1138
1334
 
1139
1335
  sd = store.state(cfg)
1140
1336
  with store.locked(sd / "work.lock"):
1141
1337
  all_ = work_load(sd)
1142
- if not any(not u.startswith("http") for i in all_ if i.get("kind") == "routed" for u in [*i["prs"], *i.get("queue", [])]):
1338
+ if not any(
1339
+ not u.startswith("http")
1340
+ for i in all_
1341
+ if i.get("kind") == "routed"
1342
+ for u in [*i["prs"], *i.get("queue", [])]
1343
+ ):
1143
1344
  return
1144
1345
  for i in all_:
1145
1346
  if i.get("kind") == "routed":
@@ -1175,15 +1376,23 @@ def refresh_prs(cfg: dict, now: float) -> list[dict]:
1175
1376
  if not info:
1176
1377
  continue
1177
1378
  old = i["heads"].get(u)
1178
- if not (old and HEX_HEAD.fullmatch(old)): # unset, or mangled by an older redaction: rebaseline
1379
+ if not (
1380
+ old and HEX_HEAD.fullmatch(old)
1381
+ ): # unset, or mangled by an older redaction: rebaseline
1179
1382
  i["heads"][u] = info["head"]
1180
1383
  elif owner_is_author(cfg, i["owner"], info["author"]):
1181
- i["review"] = False # the person asked wrote the PR: their pushes are the work, not a stale review
1384
+ i["review"] = (
1385
+ False # the person asked wrote the PR: their pushes are the work, not a stale review
1386
+ )
1182
1387
  i["heads"][u] = info["head"]
1183
1388
  elif i.get("pushes") and not i.get("review"):
1184
- i["heads"][u] = info["head"] # the owner was asked to push to this PR: its pushes are the work
1389
+ i["heads"][u] = info[
1390
+ "head"
1391
+ ] # the owner was asked to push to this PR: its pushes are the work
1185
1392
  elif owner_is_author(cfg, i["owner"], info.get("head_author", "")):
1186
- i["heads"][u] = info["head"] # the head moved by the owner's own commit: progress, not a stray push
1393
+ i["heads"][u] = info[
1394
+ "head"
1395
+ ] # the head moved by the owner's own commit: progress, not a stray push
1187
1396
  i["last_heard"] = max(i.get("last_heard") or 0, now)
1188
1397
  elif not same_head(old, info["head"]):
1189
1398
  i["head_moved"] = {
@@ -1194,8 +1403,14 @@ def refresh_prs(cfg: dict, now: float) -> list[dict]:
1194
1403
  }
1195
1404
  _event(i, "head_moved", f"{old[:7]} -> {info['head'][:7]}", now)
1196
1405
  i["heads"][u] = info["head"]
1197
- if i.get("waiting") and info["review_at"] and info["review_at"] > (i.get("routed_at") or 0):
1198
- i["waiting"] = False # a review shows on the PR: the owner has started it
1406
+ if (
1407
+ i.get("waiting")
1408
+ and info["review_at"]
1409
+ and info["review_at"] > (i.get("routed_at") or 0)
1410
+ ):
1411
+ i["waiting"] = (
1412
+ False # a review shows on the PR: the owner has started it
1413
+ )
1199
1414
  if info["review_at"] and info["review_at"] > base(cfg, i):
1200
1415
  i["last_heard"] = info["review_at"]
1201
1416
  res = _landed(cfg, i, got) if i.get("review") else {}
@@ -1205,16 +1420,32 @@ def refresh_prs(cfg: dict, now: float) -> list[dict]:
1205
1420
  old if old and acked != [old["commit"], old["head"]] else None
1206
1421
  )
1207
1422
  done = {u: d for u, (v, d) in res.items() if v == "closed" and d}
1208
- if i.get("reported") and not i.get("review"): # the owner said done: close only on its artifact
1423
+ if i.get("reported") and not i.get(
1424
+ "review"
1425
+ ): # the owner said done: close only on its artifact
1209
1426
  for u in i["prs"]:
1210
1427
  info = got.get(u)
1211
1428
  if info and artifact_exists(i, u, info):
1212
- done[u] = {"state": "REPORTED", "login": i["owner"], "commit": info["head"], "ci": info.get("ci", "none")}
1429
+ done[u] = {
1430
+ "state": "REPORTED",
1431
+ "login": i["owner"],
1432
+ "commit": info["head"],
1433
+ "ci": info.get("ci", "none"),
1434
+ }
1213
1435
  if i.get("review"): # a merged or closed PR needs no review any more
1214
1436
  for u in i["prs"]:
1215
1437
  info = got.get(u)
1216
- if u not in done and info and info.get("state") in ("MERGED", "CLOSED"):
1217
- done[u] = {"state": info["state"], "login": "GitHub", "commit": info["head"], "ci": info.get("ci", "none")}
1438
+ if (
1439
+ u not in done
1440
+ and info
1441
+ and info.get("state") in ("MERGED", "CLOSED")
1442
+ ):
1443
+ done[u] = {
1444
+ "state": info["state"],
1445
+ "login": "GitHub",
1446
+ "commit": info["head"],
1447
+ "ci": info.get("ci", "none"),
1448
+ }
1218
1449
  if done and i.get("queue"):
1219
1450
  cur = i["prs"][0]
1220
1451
  if cur in done:
@@ -350,6 +350,7 @@ REVERTS = [
350
350
  ('peer prompts redacted', 'hubcore/ledger.py', '"text": redact(" ".join(text.split()))[0][:300]', '"text": " ".join(text.split())[:300]', 'tests/test_ledger.py::test_peer_prompts_are_redacted_like_state'),
351
351
  ('eod lists PRs and peers', 'hubcore/status.py', 'glance(cfg, day) + [""] + ledger.day_lines(cfg, day)', 'glance(cfg, day)', 'tests/test_ledger.py::test_digest_and_eod_list_todays_prs_and_what_each_peer_was_asked'),
352
352
  ('digest lists PRs and peers', 'hubcore/status.py', 'return out + ledger.day_lines(cfg, store.day_of(cfg, since))', 'return out', 'tests/test_ledger.py::test_digest_and_eod_list_todays_prs_and_what_each_peer_was_asked'),
353
+ ('fix job push is not a stall', 'hubcore/routed.py', ' bool(PR_REF.search(text + " " + summary))\n and bool(PUSH', ' False\n and bool(PUSH', 'tests/test_review_chain.py::test_an_owners_pushes_to_a_fix_job_are_not_a_stalled_head_but_a_reviews_moved_head_still_is'),
353
354
  ]
354
355
 
355
356
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  import json
4
4
 
5
- from test_hubkit import PR1, fake_gh, review, routed_items, send
5
+ from test_hubkit import PR1, PR2, fake_gh, review, routed_items, send
6
6
 
7
7
 
8
8
  def gh_json(env, head, reviews=None, **extra):
@@ -42,3 +42,15 @@ def test_an_approval_on_red_ci_says_not_to_merge(env):
42
42
  gh_json(env, "aaa1111", [review("aaa1111")], statusCheckRollup=[{"conclusion": "FAILURE"}])
43
43
  out = env.run("tick", "--no-notify").stdout
44
44
  assert "CI is failing" in out and "do not merge on red" in out
45
+
46
+
47
+ def test_an_owners_pushes_to_a_fix_job_are_not_a_stalled_head_but_a_reviews_moved_head_still_is(env):
48
+ fake_gh(env, head="aaa1111")
49
+ env.bind()
50
+ send(env, "peer-fix", f"Fix the gaps from the audit and update the tests. Done-when: tests pass. Output: the PR {PR1}. Estimate: 30m", "audit work")
51
+ send(env, "peer-rev", f"Full gated review of {PR2}", "review 102")
52
+ gh_json(env, "bbb2222")
53
+ out = env.run("tick", "--no-notify").stdout
54
+ items = {i["owner"]: i for i in routed_items(env)}
55
+ assert items["peer-fix"]["head_moved"] is None and items["peer-fix"]["heads"][PR1] == "bbb2222"
56
+ assert items["peer-rev"]["head_moved"] and "ALERT" in out
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes