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,531 @@
1
+ """Job records, estimates and the slowness check. Internal to the hub: nothing here is a user-facing report.
2
+
3
+ One record per live job (an order or a routed item): the job, its current status, when it started and when that
4
+ status began, the estimate, the holder, and that it is not done yet. Every tick rebuilds the records and checks them;
5
+ a job past its estimate (or, with no estimate, past its usual time) is SLOW, and a SLOW job with an estimate on
6
+ record blocks the stop gate until the hub addresses it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import glob
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import statistics
17
+ import subprocess
18
+ import time
19
+ from collections import defaultdict
20
+ from datetime import datetime
21
+ from pathlib import Path
22
+
23
+ from . import config, hooks, routed, store
24
+ from .orders import Orders
25
+ from .status import work_load, work_save
26
+
27
+ ESTIMATE = re.compile(r"(?im)^\s*estimate:\s*(\d{1,4})\s*m(?:in)?\b")
28
+ AGENT_ID = re.compile(r"^[0-9a-f]{12,}$")
29
+ BAD = {
30
+ "FAILURE",
31
+ "ERROR",
32
+ "TIMED_OUT",
33
+ "CANCELLED",
34
+ "ACTION_REQUIRED",
35
+ "STARTUP_FAILURE",
36
+ }
37
+ RUNNING = {"IN_PROGRESS", "QUEUED", "PENDING", "WAITING", "EXPECTED"}
38
+
39
+
40
+ def parse_estimate(text: str) -> int | None:
41
+ """Minutes from an `Estimate: <n>m` line of a brief, else None."""
42
+ m = ESTIMATE.search(text)
43
+ return int(m[1]) if m else None
44
+
45
+
46
+ def step_of(item: dict) -> str:
47
+ """The step a routed item is in, by what it was asked to do."""
48
+ return (
49
+ "review" if item.get("review") else "fix push" if item.get("pushes") else "work"
50
+ )
51
+
52
+
53
+ # ---- baseline: the usual duration per step, by median ----
54
+
55
+
56
+ def history(cfg: dict) -> dict[str, list[float]]:
57
+ """Minutes taken per step key, from every archived routed item and closed order."""
58
+ out: dict[str, list[float]] = defaultdict(list)
59
+ for f in (store.state(cfg) / "archive").glob("archive_*.jsonl"):
60
+ for r in store.read_rows(f)[0]:
61
+ end = r.get("ts") or 0
62
+ if r.get("type") == "work" and r.get("kind") == "routed":
63
+ start = r.get("routed_at") or r.get("added") or end
64
+ if end > start:
65
+ out[step_of(r)].append((end - start) / 60)
66
+ if r.get("reported") and end > r["reported"]:
67
+ out["hub pickup"].append((end - r["reported"]) / 60)
68
+ elif (
69
+ r.get("type") == "order"
70
+ and r.get("outcome") == "closed"
71
+ and end > (r.get("opened") or end)
72
+ ):
73
+ out["order:" + str(r.get("kind"))].append((end - r["opened"]) / 60)
74
+ return out
75
+
76
+
77
+ def usual(
78
+ cfg: dict, hist: dict[str, list[float]], key: str, fallback: float
79
+ ) -> tuple[float, int]:
80
+ """(median minutes, samples). Too little history: the configured stall limit and the sample count it rests on."""
81
+ xs = hist.get(key, [])
82
+ if len(xs) >= int(cfg["slow"]["min_samples"]):
83
+ return round(statistics.median(xs), 1), len(xs)
84
+ return fallback, len(xs)
85
+
86
+
87
+ def item_default(cfg: dict, item: dict) -> tuple[float, str]:
88
+ """Default estimate for a routed item and its basis, used when the brief has no Estimate line."""
89
+ mins, n = usual(cfg, history(cfg), step_of(item), routed.limit(cfg, item))
90
+ return mins, (
91
+ f"median of {n}"
92
+ if n >= int(cfg["slow"]["min_samples"])
93
+ else f"stall limit, {n} samples"
94
+ )
95
+
96
+
97
+ def order_default(cfg: dict, orders: Orders, o: dict) -> tuple[float, str]:
98
+ """Default estimate for an order and its basis."""
99
+ mins, n = usual(
100
+ cfg, history(cfg), "order:" + o["kind"], float(cfg["slow"]["order_default_min"])
101
+ )
102
+ return mins, (
103
+ f"median of {n}"
104
+ if n >= int(cfg["slow"]["min_samples"])
105
+ else f"configured default, {n} samples"
106
+ )
107
+
108
+
109
+ # ---- why: real signals only, never a transcript's text ----
110
+
111
+
112
+ def _epoch(iso: str) -> float:
113
+ return datetime.fromisoformat(iso).timestamp()
114
+
115
+
116
+ def _tail(path: str, kb: int) -> list[str]:
117
+ size = os.path.getsize(path)
118
+ off = max(0, size - kb * 1024)
119
+ with open(path, "rb") as fh:
120
+ fh.seek(off)
121
+ lines = fh.read().decode("utf-8", "replace").splitlines()
122
+ return lines[1:] if off else lines
123
+
124
+
125
+ def _blocks(o: dict) -> list[dict]:
126
+ c = (o.get("message") or {}).get("content")
127
+ return [b for b in c if isinstance(b, dict)] if isinstance(c, list) else []
128
+
129
+
130
+ def activity(path: str, kb: int) -> tuple[dict[str, tuple[str, float]], int]:
131
+ """(tool uses started and not yet answered: id -> (tool name, start epoch), unread queued messages).
132
+ Reads tool names, ids, times and queue operations only."""
133
+ open_tools: dict[str, tuple[str, float]] = {}
134
+ queued = 0
135
+ for line in _tail(path, kb):
136
+ if "tool_use" not in line and "queue-operation" not in line:
137
+ continue
138
+ try:
139
+ o = json.loads(line)
140
+ if o.get("type") == "queue-operation":
141
+ queued += {"enqueue": 1, "dequeue": -1, "remove": -1}.get(
142
+ str(o.get("operation")), 0
143
+ )
144
+ elif o.get("type") == "assistant":
145
+ for b in _blocks(o):
146
+ if b.get("type") == "tool_use":
147
+ open_tools[str(b["id"])] = (
148
+ str(b["name"]),
149
+ _epoch(o["timestamp"]),
150
+ )
151
+ elif o.get("type") == "user":
152
+ for b in _blocks(o):
153
+ if b.get("type") == "tool_result":
154
+ open_tools.pop(str(b.get("tool_use_id")), None)
155
+ except ValueError, KeyError, TypeError:
156
+ continue
157
+ return open_tools, max(queued, 0)
158
+
159
+
160
+ def holder_session(cfg: dict, owner: str) -> dict | None:
161
+ """The live session file of a peer by name, or of the hub (owner "hub")."""
162
+ if owner == "hub":
163
+ b = hooks.read_bind(cfg)
164
+ return hooks.session_file(cfg, int(b["pid"])) if hooks.bind_alive(b) else None
165
+ for f in glob.glob(str(config.sessions_dir(cfg) / "*.json")):
166
+ try:
167
+ s = json.loads(Path(f).read_text(encoding="utf-8"))
168
+ except OSError, ValueError:
169
+ continue
170
+ if s.get("name") == owner and hooks.proc_start(int(s.get("pid", 0))) == str(
171
+ s.get("procStart")
172
+ ):
173
+ return s
174
+ return None
175
+
176
+
177
+ def holder_transcript(cfg: dict, owner: str, sess: dict | None) -> str | None:
178
+ """Transcript file of the holder's session, or of a background agent."""
179
+ if sess:
180
+ return routed.transcript_of(cfg, str(sess["sessionId"]))
181
+ if AGENT_ID.match(owner):
182
+ for pat in routed.opts(cfg)["activity_globs"]:
183
+ hit = sorted(
184
+ glob.glob(os.path.expanduser(pat.format(id=owner))),
185
+ key=os.path.getmtime,
186
+ )
187
+ if hit:
188
+ return hit[-1]
189
+ return None
190
+
191
+
192
+ def ci_state(cfg: dict, url: str, timeout: float) -> str | None:
193
+ """failing, pending or passing from the PR's checks; None when gh cannot say."""
194
+ exe = routed._gh(cfg)
195
+ if exe is None or not url.startswith("http"):
196
+ return None
197
+ try:
198
+ r = subprocess.run(
199
+ [exe, "pr", "view", url, "--json", "statusCheckRollup"],
200
+ capture_output=True,
201
+ encoding="utf-8", errors="replace",
202
+ timeout=timeout,
203
+ check=False,
204
+ )
205
+ checks = json.loads(r.stdout)["statusCheckRollup"]
206
+ except OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError:
207
+ return None
208
+ if not checks:
209
+ return None
210
+ concl = [str(c.get("conclusion") or c.get("state") or "").upper() for c in checks]
211
+ stat = [str(c.get("status") or "").upper() for c in checks]
212
+ if any(x in BAD for x in concl):
213
+ return "failing"
214
+ return (
215
+ "pending"
216
+ if any(s in RUNNING for s in stat) or any(x in RUNNING or not x for x in concl)
217
+ else "passing"
218
+ )
219
+
220
+
221
+ def why(
222
+ cfg: dict, owner: str, item: dict | None, now: float, deep: bool
223
+ ) -> tuple[str, str]:
224
+ """(text, signature) for a slow job. The signature has no durations, so it changes only when the cause does."""
225
+ s = cfg["slow"]
226
+ facts: list[str] = [] # signals that explain a delay
227
+ sig: list[str] = []
228
+ ctx = ""
229
+ if owner == "unknown":
230
+ return "unknown (no routed item is linked to this job)", "nolink"
231
+ sess = holder_session(cfg, owner)
232
+ if sess:
233
+ age = int((now - float(sess.get("statusUpdatedAt", 0)) / 1000) / 60)
234
+ ctx = f"session {sess.get('status', '?')} {age}m"
235
+ if sess.get("waitingFor"):
236
+ facts.append("waiting for " + store.safe(str(sess["waitingFor"]), 40))
237
+ sig.append("waitingFor")
238
+ elif owner != "hub" and not AGENT_ID.match(owner):
239
+ facts.append("no live session of that name")
240
+ sig.append("nosession")
241
+ elif owner == "hub":
242
+ facts.append("hub session not live")
243
+ sig.append("nohub")
244
+ path = holder_transcript(cfg, owner, sess)
245
+ if path:
246
+ try:
247
+ tools, unread = activity(path, int(s["scan_kb"]))
248
+ mt = os.path.getmtime(path)
249
+ except OSError:
250
+ tools, unread, mt = {}, 0, now
251
+ if tools:
252
+ name, t0 = min(tools.values(), key=lambda v: v[1])
253
+ mins = int((now - t0) / 60)
254
+ if mins >= int(s["tool_min"]):
255
+ facts.append(
256
+ f"{name} running {mins}m"
257
+ if len(tools) == 1
258
+ else f"{len(tools)} tools running, oldest {name} {mins}m"
259
+ )
260
+ sig.append("tool:" + name)
261
+ if unread:
262
+ facts.append(f"{unread} message{'s' if unread > 1 else ''} unread")
263
+ sig.append(f"unread:{unread}")
264
+ if AGENT_ID.match(owner) and now - mt > 120:
265
+ facts.append(f"transcript quiet {int((now - mt) / 60)}m")
266
+ sig.append("quiet")
267
+ if item:
268
+ if any(
269
+ e.get("kind") == "head_moved" and e["t"] >= (item.get("routed_at") or 0)
270
+ for e in item.get("events", [])
271
+ ):
272
+ facts.append("PR head moved")
273
+ sig.append("head")
274
+ if deep and item.get("prs"):
275
+ ci = ci_state(cfg, item["prs"][0], float(routed.opts(cfg)["gh_timeout_s"]))
276
+ if ci in ("failing", "pending"):
277
+ facts.append(f"CI {ci}")
278
+ sig.append("ci:" + ci)
279
+ text = "; ".join(facts) if facts else "unknown" + (f" ({ctx})" if ctx else "")
280
+ return text, hashlib.sha1("|".join(sig).encode()).hexdigest()[:6]
281
+
282
+
283
+ # ---- the job records ----
284
+
285
+
286
+ def _addressed_after(its: list[dict], t: float) -> bool:
287
+ return any(a >= t for i in its for a in i.get("addressed", []))
288
+
289
+
290
+ def _linked(cfg: dict, o: dict, live: list[dict]) -> list[dict]:
291
+ """Routed items an order is tied to: a live item id named in the order's words or move notes (`W52`), or a PR
292
+ the order and the item both name (same repo and number). Nothing else links them."""
293
+ text = " ".join([o["quote"], *[n for _, n in o["moves"]]])
294
+ ids = set(re.findall(r"\bW\d+\b", text))
295
+ prs = [*o["refs"], *routed.refs(cfg, text, None)]
296
+ return [i for i in live if i["id"] in ids or routed.overlaps([*i["prs"], *i.get("queue", [])], prs)]
297
+
298
+
299
+ def _owner_job(cfg: dict, item: dict, hist: dict, now: float, live_ids: set[str]) -> dict:
300
+ """The record of one routed item."""
301
+ reported = item.get("reported") or 0
302
+ on_hub = bool(reported) and reported > (item.get("routed_at") or 0)
303
+ step = "hub pickup" if on_hub else step_of(item)
304
+ since = reported if on_hub else routed.started_at(cfg, item)
305
+ started = routed.started_at(cfg, item)
306
+ explicit = (not on_hub) and item.get("estimate_min") is not None
307
+ if explicit:
308
+ est, basis = float(item["estimate_min"]), (
309
+ "defaulted" if item.get("estimate_defaulted") else "estimate"
310
+ )
311
+ else:
312
+ est, n = usual(cfg, hist, step, routed.limit(cfg, item))
313
+ basis = (
314
+ "usual" if n >= int(cfg["slow"]["min_samples"]) else f"limit, {n} samples"
315
+ )
316
+ clock = since if on_hub else started
317
+ thresh = clock + est * 60 * (1 + (0 if on_hub else len(item.get("addressed", []))))
318
+ return {
319
+ "id": item["id"],
320
+ "type": "routed",
321
+ "job": item["title"][:50],
322
+ "status": step,
323
+ "started": started,
324
+ "since": since,
325
+ "estimate": est,
326
+ "basis": basis,
327
+ "holder": "hub" if on_hub else item["owner"],
328
+ "done": False,
329
+ "over": item.get("blocked_on") not in live_ids and now > thresh and (now - clock) > float(cfg["slow"]["floor_min"]) * 60,
330
+ "gate": explicit,
331
+ "owner": item["owner"],
332
+ }
333
+
334
+
335
+ def _order_job(
336
+ cfg: dict, orders: Orders, o: dict, live: list[dict], hist: dict, now: float
337
+ ) -> dict | None:
338
+ if o["state"] not in ("open", "held"):
339
+ return None
340
+ if o["state"] == "held":
341
+ return {
342
+ "id": o["id"],
343
+ "type": "order",
344
+ "job": o["kind"],
345
+ "status": "held by user",
346
+ "started": o["opened"],
347
+ "since": o["opened"],
348
+ "estimate": None,
349
+ "basis": "",
350
+ "holder": "user",
351
+ "done": False,
352
+ "over": False,
353
+ "gate": False,
354
+ "owner": "user",
355
+ }
356
+ link = _linked(cfg, o, live)
357
+ holder = "/".join(dict.fromkeys(i["owner"] for i in link)) if link else "unknown"
358
+ explicit = o.get("estimate_min") is not None
359
+ if explicit:
360
+ est = float(o["estimate_min"])
361
+ basis = "defaulted" if o["estimates"][-1]["source"] == "default" else "estimate"
362
+ else:
363
+ est, basis = order_default(cfg, orders, o)[0], "usual"
364
+ thresh = o["opened"] + est * 60
365
+ last = max([o["opened"], *[t for t, _ in o["moves"]]])
366
+ note = o["moves"][-1][1][:40] if o["moves"] else "no move yet"
367
+ return {
368
+ "id": o["id"],
369
+ "type": "order",
370
+ "job": o["kind"],
371
+ "status": f"open, {note}",
372
+ "started": o["opened"],
373
+ "since": last,
374
+ "estimate": est,
375
+ "basis": basis,
376
+ "holder": holder,
377
+ "done": False,
378
+ "over": now > thresh and not _addressed_after(link, thresh),
379
+ "gate": explicit,
380
+ "owner": holder,
381
+ }
382
+
383
+
384
+ def jobs(cfg: dict, orders: Orders, now: float, deep: bool = False) -> list[dict]:
385
+ """One record per live order and routed item; an overrun job also carries why, sig and its NEXT."""
386
+ hist = history(cfg)
387
+ live = routed.items(cfg)
388
+ out = [
389
+ j for o in orders.live() if (j := _order_job(cfg, orders, o, live, hist, now))
390
+ ]
391
+ out += [_owner_job(cfg, i, hist, now, {x['id'] for x in live}) for i in live]
392
+ by_id = {i["id"]: i for i in live}
393
+ for j in out:
394
+ if j["over"]:
395
+ j["why"], j["sig"] = why(cfg, j["owner"], by_id.get(j["id"]), now, deep)
396
+ j["next"] = next_action(cfg, j, by_id.get(j["id"]), now)
397
+ return out
398
+
399
+
400
+ def next_action(cfg: dict, job: dict, item: dict | None, now: float) -> str:
401
+ """The one thing the hub does about an overrun, using the existing rule on re-briefing a reporting owner."""
402
+ est = f"or hubkit work estimate {job['id']} --minutes N --why \"<reason>\" (N above the {int((now - job['started']) / 60)}m spent)"
403
+ if job["holder"] == "unknown":
404
+ return f"find the holder and name its item id (W<n>) in an order move note, or act on it, {est}"
405
+ if job["holder"] == "hub":
406
+ return f"act on it now, {est}"
407
+ o = job["owner"]
408
+ heard = (item or {}).get("last_heard")
409
+ limit = routed.limit(cfg, item) if item else float(routed.opts(cfg)["stall_min"])
410
+ if heard and now - heard < limit * 60:
411
+ return f"SendMessage {o} for an ETA (it reported {int((now - heard) / 60)} min ago, so do not re-brief it), {est}"
412
+ return f"SendMessage {o} for a one-line state, or re-brief to another owner, {est}"
413
+
414
+
415
+ def line(job: dict, now: float) -> str:
416
+ """The SLOW line for an overrun job."""
417
+ mins = (
418
+ int((now - job["since"]) / 60)
419
+ if job["status"] == "hub pickup"
420
+ else int((now - job["started"]) / 60)
421
+ )
422
+ word = "estimate" if job["basis"] in ("estimate", "defaulted") else "usual"
423
+ return (
424
+ f"SLOW {job['id']} {job['status']} {mins}m, {word} {job['estimate']:g}m ({job['basis']}); holder {job['holder']}; "
425
+ f"why: {job['why']}. NEXT: {job['next']}"
426
+ )
427
+
428
+
429
+ def alerts(cfg: dict, orders: Orders, now: float) -> list[dict]:
430
+ """Tick alerts: one per overrun job, keyed by the cause so a repeat is back-off and a new cause is a new alert."""
431
+ return [
432
+ {"key": f"{j['id']}:slow:{j['sig']}", "msg": line(j, now)}
433
+ for j in jobs(cfg, orders, now, deep=True)
434
+ if j["over"]
435
+ ]
436
+
437
+
438
+ def gate_lines(cfg: dict, now: float) -> list[str]:
439
+ """Stop-gate reasons: overrun jobs that carry an estimate on record and are not addressed."""
440
+ return [
441
+ "hubkit OVERRUN: " + line(j, now)
442
+ for j in jobs(cfg, Orders(cfg), now)
443
+ if j["over"] and j["gate"]
444
+ ]
445
+
446
+
447
+ def block_lines(st_jobs: list[dict], now: float) -> list[str]:
448
+ """One short line per live job for the status block (hub context)."""
449
+ out = []
450
+ for j in st_jobs:
451
+ t = int((now - j["since"]) / 60)
452
+ est = f"/{j['estimate']:g}m" if j.get("estimate") is not None else ""
453
+ s = f"job {j['id']} {j['status'][:28]} {t}m{est} holder {j['holder']}"
454
+ if j.get("over"):
455
+ s += f" SLOW: {j.get('why', '')[:60]}"
456
+ out.append(s)
457
+ return out
458
+
459
+
460
+ # ---- re-estimate: recorded beside the old one, never over it ----
461
+
462
+
463
+ def re_estimate(
464
+ cfg: dict, jid: str, minutes: int, reason: str, now: float | None = None
465
+ ) -> str:
466
+ """Record a new estimate with its reason; refused when it does not exceed the time already spent."""
467
+ now = now or time.time()
468
+ if not reason.strip():
469
+ raise ValueError("a new estimate needs --why")
470
+ if jid.startswith("O-"):
471
+ o = Orders(cfg).get(jid)
472
+ if o["state"] not in ("open", "held"):
473
+ raise ValueError(f"{jid} is {o['state']}; only a live order takes an estimate")
474
+ spent = (now - o["opened"]) / 60
475
+ _need_more(jid, minutes, spent)
476
+ Orders(cfg).estimate(jid, minutes, "hub", reason)
477
+ return (
478
+ f"{jid} estimate {minutes}m recorded ({len(o['estimates']) + 1} on record)"
479
+ )
480
+ sd = store.state(cfg)
481
+ with store.locked(sd / "work.lock"):
482
+ items = work_load(sd)
483
+ hit = next(
484
+ (i for i in items if i["id"] == jid and i.get("kind") == "routed"), None
485
+ )
486
+ if hit is None:
487
+ raise KeyError(f"no live routed item {jid}")
488
+ _need_more(jid, minutes, (now - routed.started_at(cfg, hit)) / 60)
489
+ hit["estimates"] = [
490
+ *hit.get("estimates", []),
491
+ {"t": now, "min": minutes, "source": "hub", "why": store.safe(reason, 200)},
492
+ ]
493
+ hit.update(estimate_min=minutes, estimate_defaulted=False, addressed=[])
494
+ work_save(sd, items)
495
+ return f"{jid} estimate {minutes}m recorded ({len(hit['estimates'])} on record)"
496
+
497
+
498
+ def _need_more(jid: str, minutes: int, spent: float) -> None:
499
+ if minutes <= spent:
500
+ raise ValueError(
501
+ f"{jid}: a new estimate must exceed the {int(spent)}m already spent; chase the holder or re-brief instead"
502
+ )
503
+
504
+
505
+ # ---- end of day: estimate vs actual, for the hub's own learning ----
506
+
507
+
508
+ def reflection_lines(cfg: dict, day: str) -> list[str]:
509
+ """Estimate vs actual per kind for items finished on `day`, and how often they were re-estimated."""
510
+ rows = store.read_rows(store.state(cfg) / "archive" / f"archive_{day}.jsonl")[0]
511
+ per: dict[str, list[tuple[float, float, int]]] = defaultdict(list)
512
+ for r in rows:
513
+ ests = r.get("estimates") or []
514
+ if not ests:
515
+ continue
516
+ start = (
517
+ r.get("routed_at") or r.get("added")
518
+ if r.get("type") == "work"
519
+ else r.get("opened")
520
+ )
521
+ key = step_of(r) if r.get("type") == "work" else "order:" + str(r.get("kind"))
522
+ per[key].append((float(ests[0]["min"]), (r["ts"] - start) / 60, len(ests) - 1))
523
+ if not per:
524
+ return [" estimate vs actual: nothing finished today carried an estimate"]
525
+ out = [" estimate vs actual (hub's own; first estimate vs minutes taken):"]
526
+ for k, v in sorted(per.items()):
527
+ out.append(
528
+ f" {k}: {len(v)} done, estimated {statistics.mean(x[0] for x in v):.0f}m, took {statistics.mean(x[1] for x in v):.0f}m, "
529
+ f"over estimate {sum(x[1] > x[0] for x in v)}, re-estimated {sum(x[2] for x in v)}x"
530
+ )
531
+ return out