hubkit-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hubcore/__init__.py +1 -0
- hubcore/cli.py +273 -0
- hubcore/compat.py +187 -0
- hubcore/config.py +107 -0
- hubcore/data/hub/LICENSE +202 -0
- hubcore/data/hub/SKILL.md +125 -0
- hubcore/data/hub/config.example.toml +84 -0
- hubcore/data/hub/docs/architecture.md +133 -0
- hubcore/data/hub/docs/commands.md +66 -0
- hubcore/data/hub/docs/configuration.md +151 -0
- hubcore/data/hub/docs/troubleshooting.md +21 -0
- hubcore/data/hub/intents.example.toml +74 -0
- hubcore/data/hub/references/design-sources.md +14 -0
- hubcore/data/hub/references/research.md +49 -0
- hubcore/data/hub/scripts/hubcore/__init__.py +1 -0
- hubcore/data/hub/scripts/hubcore/cli.py +273 -0
- hubcore/data/hub/scripts/hubcore/compat.py +187 -0
- hubcore/data/hub/scripts/hubcore/config.py +107 -0
- hubcore/data/hub/scripts/hubcore/gate.py +145 -0
- hubcore/data/hub/scripts/hubcore/hooks.py +326 -0
- hubcore/data/hub/scripts/hubcore/install.py +66 -0
- hubcore/data/hub/scripts/hubcore/learnings.py +232 -0
- hubcore/data/hub/scripts/hubcore/lessons.py +168 -0
- hubcore/data/hub/scripts/hubcore/orders.py +508 -0
- hubcore/data/hub/scripts/hubcore/redact.py +89 -0
- hubcore/data/hub/scripts/hubcore/routed.py +1169 -0
- hubcore/data/hub/scripts/hubcore/slow.py +531 -0
- hubcore/data/hub/scripts/hubcore/status.py +535 -0
- hubcore/data/hub/scripts/hubcore/store.py +172 -0
- hubcore/data/hub/scripts/hubcore/watcher.py +167 -0
- hubcore/data/hub/scripts/hubkit +37 -0
- hubcore/data/hub/scripts/learn.py +84 -0
- hubcore/data/hub-mep/SKILL.md +35 -0
- hubcore/data/hubkit-setup/SKILL.md +50 -0
- hubcore/gate.py +145 -0
- hubcore/hooks.py +326 -0
- hubcore/install.py +66 -0
- hubcore/learnings.py +232 -0
- hubcore/lessons.py +168 -0
- hubcore/orders.py +508 -0
- hubcore/redact.py +89 -0
- hubcore/routed.py +1169 -0
- hubcore/slow.py +531 -0
- hubcore/status.py +535 -0
- hubcore/store.py +172 -0
- hubcore/watcher.py +167 -0
- hubkit_cli-0.1.0.dist-info/METADATA +195 -0
- hubkit_cli-0.1.0.dist-info/RECORD +51 -0
- hubkit_cli-0.1.0.dist-info/WHEEL +4 -0
- hubkit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hubkit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""Orders: urgent asks that get a record and a clock. Close needs evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import glob
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import tomllib
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import config, store
|
|
15
|
+
|
|
16
|
+
PR_URL = re.compile(r"https://github\.com/[\w.-]+/[\w.-]+/pull/\d+")
|
|
17
|
+
PR_REF = re.compile(r"(?i)https://github\.com/[\w.-]+/[\w.-]+/pull/\d+|\bpr\s*#?\d+|#\d{2,}")
|
|
18
|
+
LIVE_STATES = ("open", "unconfirmed", "held")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_intents(cfg: dict) -> dict:
|
|
22
|
+
"""Load the intents.toml in effect."""
|
|
23
|
+
return tomllib.loads(config.intents_path(cfg).read_text(encoding="utf-8"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _word_re(words: list[str]) -> re.Pattern:
|
|
27
|
+
alts = "|".join(re.escape(w) for w in sorted(words, key=len, reverse=True))
|
|
28
|
+
return re.compile(rf"(?<![\w]){alts}(?![\w])", re.I)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def match_kind(intents: dict, text: str) -> str | None:
|
|
32
|
+
"""The first configured kind whose words appear in `text`, else None."""
|
|
33
|
+
for kind, spec in intents.get("kinds", {}).items():
|
|
34
|
+
if _word_re(spec["words"]).search(text):
|
|
35
|
+
return kind
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_deadline(text: str, now: float, cfg: dict) -> float | None:
|
|
40
|
+
"""Epoch deadline from `in N min` or `by HH:MM` in the text, else None."""
|
|
41
|
+
m = re.search(r"\b(?:in|within)\s+(\d{1,3})\s*min", text, re.I)
|
|
42
|
+
if m:
|
|
43
|
+
return now + int(m[1]) * 60
|
|
44
|
+
m = re.search(r"\bby\s+(\d{1,2}):(\d{2})\b", text, re.I)
|
|
45
|
+
if m:
|
|
46
|
+
base = datetime.fromtimestamp(now, store.tzinfo(cfg))
|
|
47
|
+
due = base.replace(hour=int(m[1]), minute=int(m[2]), second=0, microsecond=0)
|
|
48
|
+
if due.timestamp() < now:
|
|
49
|
+
due += timedelta(days=1)
|
|
50
|
+
return due.timestamp()
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fold_block(o: dict, r: dict) -> None:
|
|
55
|
+
"""A block row opens a blocker, or (same `on`) adds a fix to the open one. A new fix restarts the fix clock."""
|
|
56
|
+
cur = o["blocker"]
|
|
57
|
+
if cur is None or cur["on"] != r["on"]:
|
|
58
|
+
cur = o["blocker"] = {"on": r["on"], "since": r["ts"], "fix": "", "fix_at": None, "holder": ""}
|
|
59
|
+
if r.get("holder"):
|
|
60
|
+
cur["holder"] = r["holder"]
|
|
61
|
+
if r.get("fix"):
|
|
62
|
+
cur["fix"], cur["fix_at"] = r["fix"], r["ts"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fold(rows: list[dict]) -> dict[str, dict]:
|
|
66
|
+
"""Fold order rows (file order) into one dict per order id. First close wins."""
|
|
67
|
+
out: dict[str, dict] = {}
|
|
68
|
+
for r in rows:
|
|
69
|
+
oid = r.get("id")
|
|
70
|
+
op = r.get("op")
|
|
71
|
+
if not oid or not op:
|
|
72
|
+
continue
|
|
73
|
+
if op == "open":
|
|
74
|
+
out.setdefault(
|
|
75
|
+
oid,
|
|
76
|
+
{
|
|
77
|
+
"id": oid, "kind": r["kind"], "state": r.get("state", "open"),
|
|
78
|
+
"quote": r.get("quote", ""), "refs": r.get("refs", []),
|
|
79
|
+
"opened": r["ts"], "deadline": r.get("deadline"), "moves": [],
|
|
80
|
+
"chases": 0, "chase_quotes": [], "evidence": {}, "held_quote": "",
|
|
81
|
+
"estimates": [], "estimate_min": None,
|
|
82
|
+
"blocker": None, "results": [], "stoploss": None,
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
continue
|
|
86
|
+
o = out.get(oid)
|
|
87
|
+
if o is None or o["state"] in ("closed", "cancelled", "expired"):
|
|
88
|
+
continue
|
|
89
|
+
if op == "confirm" and o["state"] == "unconfirmed":
|
|
90
|
+
o["state"] = "open"
|
|
91
|
+
elif op == "move":
|
|
92
|
+
o["moves"].append((r["ts"], r.get("note", "")))
|
|
93
|
+
elif op == "block":
|
|
94
|
+
_fold_block(o, r)
|
|
95
|
+
elif op == "result":
|
|
96
|
+
o["results"].append((r["ts"], r.get("note", "")))
|
|
97
|
+
o["blocker"] = None # only a recorded result clears a blocker
|
|
98
|
+
elif op == "stoploss":
|
|
99
|
+
o["stoploss"] = r["ts"]
|
|
100
|
+
elif op == "estimate":
|
|
101
|
+
o["estimates"].append({"t": r["ts"], "min": r["minutes"], "source": r.get("source", "hub"), "why": r.get("why", "")})
|
|
102
|
+
o["estimate_min"] = r["minutes"]
|
|
103
|
+
elif op == "ref":
|
|
104
|
+
o["refs"] = list(o["refs"]) + list(r.get("refs", []))
|
|
105
|
+
elif op == "chase":
|
|
106
|
+
o["last_chase"] = r["ts"]
|
|
107
|
+
o["chases"] += 1
|
|
108
|
+
o["chase_quotes"].append(r.get("quote", ""))
|
|
109
|
+
elif op == "hold":
|
|
110
|
+
o["state"], o["held_quote"] = "held", r.get("quote", "")
|
|
111
|
+
elif op == "resume" and o["state"] == "held":
|
|
112
|
+
o["state"] = "open"
|
|
113
|
+
elif op == "cancel":
|
|
114
|
+
o["state"], o["closed_at"], o["close_quote"] = "cancelled", r["ts"], r.get("quote", "")
|
|
115
|
+
elif op == "expire":
|
|
116
|
+
o["state"], o["closed_at"] = "expired", r["ts"]
|
|
117
|
+
elif op == "close":
|
|
118
|
+
o["state"], o["closed_at"], o["evidence"] = "closed", r["ts"], r.get("evidence", {})
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Orders:
|
|
123
|
+
def __init__(self, cfg: dict):
|
|
124
|
+
self.cfg = cfg
|
|
125
|
+
self.sd = store.state(cfg)
|
|
126
|
+
self.path = self.sd / "orders.jsonl"
|
|
127
|
+
self.intents = load_intents(cfg)
|
|
128
|
+
|
|
129
|
+
def all(self) -> dict[str, dict]:
|
|
130
|
+
"""Every order, folded from the ledger rows."""
|
|
131
|
+
return fold(store.read_rows(self.path)[0])
|
|
132
|
+
|
|
133
|
+
def live(self) -> list[dict]:
|
|
134
|
+
"""Orders that are open, unconfirmed or held."""
|
|
135
|
+
return [o for o in self.all().values() if o["state"] in LIVE_STATES]
|
|
136
|
+
|
|
137
|
+
def _append(self, oid: str, op: str, **kw) -> None:
|
|
138
|
+
store.append_row(self.path, {"id": oid, "op": op, **kw})
|
|
139
|
+
|
|
140
|
+
def open(self, kind: str, quote: str, state: str = "open", now: float | None = None) -> dict:
|
|
141
|
+
"""Open a new order of `kind` quoting the user's words; returns the folded order."""
|
|
142
|
+
now = now or time.time()
|
|
143
|
+
quote = store.safe(quote, 300) # redact first, then cut
|
|
144
|
+
with store.locked(self.sd / "lock"): # blocking: two hooks cannot both open the same order
|
|
145
|
+
for o in self.all().values():
|
|
146
|
+
if o["kind"] == kind and o["state"] in LIVE_STATES and o["quote"] == quote:
|
|
147
|
+
return o
|
|
148
|
+
qhash = hashlib.sha1(f"{kind}|{quote}|{now}".encode()).hexdigest()
|
|
149
|
+
oid = f"O-{store.day_of(self.cfg, now).replace('-', '')[2:]}-{qhash[:4]}"
|
|
150
|
+
self._append(
|
|
151
|
+
oid, "open", kind=kind, quote=quote, state=state, ts=now,
|
|
152
|
+
refs=PR_URL.findall(quote), deadline=parse_deadline(quote, now, self.cfg),
|
|
153
|
+
)
|
|
154
|
+
return self.all()[oid]
|
|
155
|
+
|
|
156
|
+
def get(self, oid: str) -> dict:
|
|
157
|
+
"""One order by id; raises KeyError when unknown."""
|
|
158
|
+
found = self.all().get(oid)
|
|
159
|
+
if found is None:
|
|
160
|
+
raise KeyError(f"no order {oid}")
|
|
161
|
+
return found
|
|
162
|
+
|
|
163
|
+
def confirm(self, oid: str) -> None:
|
|
164
|
+
"""Turn an unconfirmed order into an open one."""
|
|
165
|
+
self._append(oid, "confirm")
|
|
166
|
+
|
|
167
|
+
def move(self, oid: str, note: str, estimate: int | None = None) -> str:
|
|
168
|
+
"""Record real movement on an order. The first action also records its estimate (the usual time unless
|
|
169
|
+
given); returns a line saying so, or an empty string."""
|
|
170
|
+
first = self.get(oid)["estimate_min"] is None
|
|
171
|
+
self._append(oid, "move", note=note)
|
|
172
|
+
if estimate:
|
|
173
|
+
self.estimate(oid, estimate, "brief")
|
|
174
|
+
elif first:
|
|
175
|
+
from . import slow
|
|
176
|
+
|
|
177
|
+
mins, basis = slow.order_default(self.cfg, self, self.get(oid))
|
|
178
|
+
self.estimate(oid, mins, "default")
|
|
179
|
+
return f"{oid} estimate defaulted to {mins:g}m ({basis}); set it: hubkit work estimate {oid} --minutes N --why \"..\""
|
|
180
|
+
return ""
|
|
181
|
+
|
|
182
|
+
def block(self, oid: str, on: str, fix: str = "", holder: str = "") -> str:
|
|
183
|
+
"""Record what blocks an order and the fix that is running for it. A blocker without a fix, or whose fix has
|
|
184
|
+
run past `blocker_fix_min` with no result, alerts and holds the Stop gate."""
|
|
185
|
+
if len(on.strip()) < 3:
|
|
186
|
+
raise ValueError("block needs --on <what is blocked>")
|
|
187
|
+
self.get(oid)
|
|
188
|
+
self._append(oid, "block", on=store.safe(on, 200), fix=store.safe(fix, 200), holder=store.safe(holder, 80))
|
|
189
|
+
return self.blocker_line(self.get(oid)) or ""
|
|
190
|
+
|
|
191
|
+
def result(self, oid: str, note: str) -> None:
|
|
192
|
+
"""Record the result of an action on an order: movement, clears its blocker and any stop-loss."""
|
|
193
|
+
if len(note.strip()) < 3:
|
|
194
|
+
raise ValueError("result needs --note <what was done and what came of it>")
|
|
195
|
+
self.get(oid)
|
|
196
|
+
self._append(oid, "result", note=store.safe(note, 300))
|
|
197
|
+
|
|
198
|
+
def stoploss(self, oid: str) -> None:
|
|
199
|
+
"""Record that a STOP-LOSS line was printed for this order."""
|
|
200
|
+
self._append(oid, "stoploss")
|
|
201
|
+
|
|
202
|
+
def fix_limit_min(self) -> float:
|
|
203
|
+
return float(self.intents.get("intake", {}).get("blocker_fix_min", 3))
|
|
204
|
+
|
|
205
|
+
def blocker_problem(self, o: dict, now: float) -> str | None:
|
|
206
|
+
"""Why the open blocker of an order is not being solved, or None when a fresh fix is running."""
|
|
207
|
+
b = o.get("blocker")
|
|
208
|
+
if not b or o["state"] != "open":
|
|
209
|
+
return None
|
|
210
|
+
if not b["fix"]:
|
|
211
|
+
return f"{o['id']} is blocked on {b['on']} with no fix running"
|
|
212
|
+
age = (now - b["fix_at"]) / 60
|
|
213
|
+
if age > self.fix_limit_min():
|
|
214
|
+
return f"{o['id']} is blocked on {b['on']}; its fix has run {int(age)} min with no result"
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
def blocker_line(self, o: dict, now: float | None = None) -> str | None:
|
|
218
|
+
"""`BLOCKED on X for 4m; fix: Y (2m, holder H)` for the status line, or None."""
|
|
219
|
+
b = o.get("blocker")
|
|
220
|
+
if not b:
|
|
221
|
+
return None
|
|
222
|
+
now = now or time.time()
|
|
223
|
+
fix = f"fix: {b['fix']} ({int((now - b['fix_at']) / 60)}m{', ' + b['holder'] if b['holder'] else ''})" if b["fix"] else "NO FIX RUNNING"
|
|
224
|
+
return f"BLOCKED on {b['on']} for {int((now - b['since']) / 60)}m; {fix}"
|
|
225
|
+
|
|
226
|
+
def stoploss_pending(self, o: dict) -> bool:
|
|
227
|
+
"""A STOP-LOSS line was printed and no result has been recorded since."""
|
|
228
|
+
return bool(o.get("stoploss")) and not any(t > o["stoploss"] for t, _ in o["results"])
|
|
229
|
+
|
|
230
|
+
def estimate(self, oid: str, minutes: float, source: str, why: str = "") -> None:
|
|
231
|
+
"""Record an estimate beside any earlier one; never replaces it."""
|
|
232
|
+
self._append(oid, "estimate", minutes=minutes, source=source, why=store.safe(why, 200))
|
|
233
|
+
|
|
234
|
+
def add_ref(self, oid: str, refs: list[str]) -> None:
|
|
235
|
+
"""Attach PR or ticket references to an order."""
|
|
236
|
+
self._append(oid, "ref", refs=refs)
|
|
237
|
+
|
|
238
|
+
def recent_chase(self, o: dict, now: float, window: float = 60.0) -> bool:
|
|
239
|
+
"""True when the order was chased within `window` seconds."""
|
|
240
|
+
return bool(o.get("last_chase")) and now - o["last_chase"] < window
|
|
241
|
+
|
|
242
|
+
def chase(self, oid: str, quote: str) -> None:
|
|
243
|
+
"""Record that the user asked for status on an order."""
|
|
244
|
+
self._append(oid, "chase", quote=store.safe(quote, 300))
|
|
245
|
+
|
|
246
|
+
def hold(self, oid: str, quote: str) -> None:
|
|
247
|
+
"""Hold an order, quoting the user's explicit words."""
|
|
248
|
+
self._append(oid, "hold", quote=quote)
|
|
249
|
+
|
|
250
|
+
def resume(self, oid: str) -> None:
|
|
251
|
+
"""Resume a held order and restart its clock."""
|
|
252
|
+
self._append(oid, "resume")
|
|
253
|
+
|
|
254
|
+
def cancel(self, oid: str, quote: str) -> None:
|
|
255
|
+
"""Cancel an order, quoting the user's words, and archive it."""
|
|
256
|
+
if not quote.strip():
|
|
257
|
+
raise ValueError("cancel needs the user's words (--quote)")
|
|
258
|
+
with store.locked(self.sd / "lock"):
|
|
259
|
+
o = self.get(oid)
|
|
260
|
+
if o["state"] == "closed":
|
|
261
|
+
raise ValueError(f"{oid} is already closed")
|
|
262
|
+
if o["state"] in ("cancelled", "expired"):
|
|
263
|
+
return
|
|
264
|
+
self._append(oid, "cancel", quote=quote) # the order row first; the archive row is repairable
|
|
265
|
+
self._die_after("cancel")
|
|
266
|
+
self._archive(self.get(oid), "cancelled", {"quote": store.safe(quote, 300)})
|
|
267
|
+
|
|
268
|
+
def expire(self, oid: str, why: str) -> None:
|
|
269
|
+
"""Expire an order (for example an unconfirmed one past its TTL) and archive it."""
|
|
270
|
+
with store.locked(self.sd / "lock"):
|
|
271
|
+
o = self.get(oid)
|
|
272
|
+
if o["state"] in LIVE_STATES:
|
|
273
|
+
self._append(oid, "expire", note=why)
|
|
274
|
+
self._archive(self.get(oid), "expired", {"note": why})
|
|
275
|
+
|
|
276
|
+
@staticmethod
|
|
277
|
+
def _die_after(point: str) -> None:
|
|
278
|
+
if os.environ.get("HUBKIT_DIE_AFTER") == point: # test-only fault point
|
|
279
|
+
os._exit(9)
|
|
280
|
+
|
|
281
|
+
def _archive(self, o: dict, outcome: str, extra: dict) -> None:
|
|
282
|
+
now = time.time()
|
|
283
|
+
row = {
|
|
284
|
+
"type": "order", "id": o["id"], "kind": o["kind"], "outcome": outcome,
|
|
285
|
+
"quote": o["quote"], "opened": o["opened"], "chases": o["chases"],
|
|
286
|
+
"moves": len(o["moves"]), "estimates": o.get("estimates", []), "ts": now, **extra,
|
|
287
|
+
}
|
|
288
|
+
day = store.day_of(self.cfg, now)
|
|
289
|
+
store.append_row(self.sd / "archive" / f"archive_{day}.jsonl", row)
|
|
290
|
+
|
|
291
|
+
def check_evidence(self, o: dict, evidence: dict[str, str]) -> list[str]:
|
|
292
|
+
"""Return a list of problems; empty means the evidence is acceptable."""
|
|
293
|
+
spec = self.intents["kinds"].get(o["kind"], {})
|
|
294
|
+
problems = []
|
|
295
|
+
for need in spec.get("done_when", []):
|
|
296
|
+
val = evidence.get(need, "").strip()
|
|
297
|
+
if len(val) < 3:
|
|
298
|
+
problems.append(f"missing evidence '{need}'")
|
|
299
|
+
elif val.startswith("file:"):
|
|
300
|
+
p = Path(val[5:]).expanduser()
|
|
301
|
+
if not p.exists():
|
|
302
|
+
problems.append(f"'{need}': {p} does not exist")
|
|
303
|
+
elif p.stat().st_mtime < o["opened"]:
|
|
304
|
+
problems.append(f"'{need}': {p} is older than the order")
|
|
305
|
+
problems += self._uncovered_prs(o, evidence)
|
|
306
|
+
return problems
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def _uncovered_prs(o: dict, evidence: dict[str, str]) -> list[str]:
|
|
310
|
+
"""Every PR the order names (its quote and refs) must appear in the evidence, by number."""
|
|
311
|
+
named = dict.fromkeys(re.findall(r"\d+", " ".join(PR_REF.findall(" ".join([o["quote"], *o["refs"]])))))
|
|
312
|
+
given = set(re.findall(r"\b\d{2,}\b", " ".join(evidence.values())))
|
|
313
|
+
missing = [n for n in named if n not in given]
|
|
314
|
+
return [f"evidence does not cover {', '.join('#' + n for n in missing)} (every PR named in the order needs evidence)"] if missing else []
|
|
315
|
+
|
|
316
|
+
def close(self, oid: str, evidence: dict[str, str]) -> str:
|
|
317
|
+
"""Close an order once every `done_when` evidence name is given; returns a one-line result."""
|
|
318
|
+
with store.locked(self.sd / "lock") as _got:
|
|
319
|
+
o = self.get(oid)
|
|
320
|
+
if o["state"] == "closed":
|
|
321
|
+
return f"{oid} already closed"
|
|
322
|
+
if o["state"] in ("cancelled", "expired"):
|
|
323
|
+
raise ValueError(f"{oid} was {o['state']}")
|
|
324
|
+
if o["state"] == "unconfirmed":
|
|
325
|
+
raise ValueError(f"{oid} is unconfirmed; run 'order confirm {oid}' first")
|
|
326
|
+
problems = self.check_evidence(o, evidence)
|
|
327
|
+
if problems:
|
|
328
|
+
raise ValueError("close refused: " + "; ".join(problems))
|
|
329
|
+
self._append(oid, "close", evidence=evidence) # source of truth first
|
|
330
|
+
self._die_after("close")
|
|
331
|
+
self._archive(self.get(oid), "closed", {"evidence": evidence})
|
|
332
|
+
return f"{oid} closed"
|
|
333
|
+
|
|
334
|
+
def repair_archive(self) -> int:
|
|
335
|
+
"""Archive closed or cancelled orders whose archive row is missing (crash repair)."""
|
|
336
|
+
archived = set()
|
|
337
|
+
for f in (self.sd / "archive").glob("archive_*.jsonl"):
|
|
338
|
+
archived |= {r.get("id") for r in store.read_rows(f)[0] if r.get("type") == "order"}
|
|
339
|
+
fixed = 0
|
|
340
|
+
for o in self.all().values():
|
|
341
|
+
if o["state"] in ("closed", "cancelled", "expired") and o["id"] not in archived:
|
|
342
|
+
self._archive(o, o["state"], {"evidence": o["evidence"], "repaired": True})
|
|
343
|
+
fixed += 1
|
|
344
|
+
return fixed
|
|
345
|
+
|
|
346
|
+
def last_movement(self, o: dict) -> float:
|
|
347
|
+
"""Epoch of the latest movement: a `move`, the open time, or a touched movement file."""
|
|
348
|
+
times = [o["opened"], *[t for t, _ in o["results"]]]
|
|
349
|
+
if o.get("blocker"): # notes and touched files are not progress on a blocked order; only a result is
|
|
350
|
+
return max(times + [t for t, _ in o["moves"] if t <= o["blocker"]["since"]])
|
|
351
|
+
times += [t for t, _ in o["moves"]]
|
|
352
|
+
spec = self.intents["kinds"].get(o["kind"], {})
|
|
353
|
+
for pat in spec.get("movement_files", []):
|
|
354
|
+
for f in glob.glob(os.path.expanduser(pat)):
|
|
355
|
+
m = os.path.getmtime(f)
|
|
356
|
+
if m >= o["opened"]:
|
|
357
|
+
times.append(m)
|
|
358
|
+
return max(times)
|
|
359
|
+
|
|
360
|
+
def status_line(self, o: dict, now: float | None = None) -> str:
|
|
361
|
+
"""One line: id, kind, state, ages, chases and what done still needs."""
|
|
362
|
+
now = now or time.time()
|
|
363
|
+
spec = self.intents["kinds"].get(o["kind"], {})
|
|
364
|
+
age = int((now - o["opened"]) / 60)
|
|
365
|
+
idle = int((now - self.last_movement(o)) / 60)
|
|
366
|
+
ddl = f" deadline {store.clock(self.cfg, o['deadline'])}" if o.get("deadline") else ""
|
|
367
|
+
need = ",".join(spec.get("done_when", [])) or "-"
|
|
368
|
+
blocked = f" | {self.blocker_line(o, now)}" if o.get("blocker") else ""
|
|
369
|
+
return (
|
|
370
|
+
f"{o['id']} {o['kind']} [{o['state']}] open {age}m, idle {idle}m, "
|
|
371
|
+
f"chases {o['chases']}{ddl}; done needs: {need}{blocked}"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def metrics(self, day: str) -> dict:
|
|
375
|
+
"""Chases per order for orders opened on `day` (local day)."""
|
|
376
|
+
mine = [o for o in self.all().values() if store.day_of(self.cfg, o["opened"]) == day]
|
|
377
|
+
chases = sum(o["chases"] for o in mine)
|
|
378
|
+
return {
|
|
379
|
+
"orders": len(mine), "chases": chases,
|
|
380
|
+
"chases_per_order": round(chases / len(mine), 2) if mine else 0.0,
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def hook_intake(orders: Orders, text: str, now: float | None = None) -> list[str]:
|
|
385
|
+
"""Apply one human prompt to the order book. Returns lines for the model."""
|
|
386
|
+
now = now or time.time()
|
|
387
|
+
intake = orders.intents.get("intake", {})
|
|
388
|
+
chase_re = intake.get("chase")
|
|
389
|
+
# "what is the hold up?" is a chase, not a hold: drop chase phrases before looking for verbs
|
|
390
|
+
verbs_text = re.sub(chase_re, " ", text) if chase_re else text
|
|
391
|
+
|
|
392
|
+
def has(key: str, in_text: str | None = None) -> bool:
|
|
393
|
+
words = intake.get(key, [])
|
|
394
|
+
return bool(words) and bool(_word_re(words).search(in_text if in_text is not None else verbs_text))
|
|
395
|
+
|
|
396
|
+
lines: list[str] = []
|
|
397
|
+
short = len(text) <= int(intake.get("verb_max_chars", 120))
|
|
398
|
+
kind_text = text
|
|
399
|
+
for phrase in intake.get("not_order_phrases", []): # "release notes" is not a release order
|
|
400
|
+
kind_text = re.sub(re.escape(phrase), " ", kind_text, flags=re.I)
|
|
401
|
+
kind = match_kind(orders.intents, kind_text)
|
|
402
|
+
live = orders.live()
|
|
403
|
+
start = intake.get("question_start", r"(?i)^\W*(why|what|how|did|does|do you|is|are|was|were|can|could|would|should|when|where|who|which|has|have)\b")
|
|
404
|
+
statements = [s for s in re.split(r"(?<=[.!?])\s+|\n", text) if s.strip() and not (s.rstrip().endswith("?") or re.match(start, s))]
|
|
405
|
+
act = " ".join(statements) # only a statement can order; a question never does, urgent word or not
|
|
406
|
+
for phrase in intake.get("not_order_phrases", []):
|
|
407
|
+
act = re.sub(re.escape(phrase), " ", act, flags=re.I)
|
|
408
|
+
act_kind = match_kind(orders.intents, act)
|
|
409
|
+
urgent = has("urgent_words", text) # "don't wait, merge it now" keeps or restarts the clock
|
|
410
|
+
hold = has("hold_words") and not urgent
|
|
411
|
+
cancel, resume = has("cancel_words"), has("resume_words")
|
|
412
|
+
held = [o for o in live if o["state"] == "held" and kind in (None, o["kind"])]
|
|
413
|
+
if (hold or cancel or resume) and short:
|
|
414
|
+
targets = _verb_targets(orders, text, kind, live, intake)
|
|
415
|
+
for o in targets:
|
|
416
|
+
if cancel:
|
|
417
|
+
orders.cancel(o["id"], text)
|
|
418
|
+
lines.append(f"hubkit: {o['id']} cancelled on the user's words")
|
|
419
|
+
elif hold and o["state"] != "held":
|
|
420
|
+
orders.hold(o["id"], text)
|
|
421
|
+
lines.append(f"hubkit: {o['id']} held; clock stopped")
|
|
422
|
+
elif resume and o["state"] == "held":
|
|
423
|
+
orders.resume(o["id"])
|
|
424
|
+
lines.append(f"hubkit: {o['id']} resumed")
|
|
425
|
+
if targets:
|
|
426
|
+
return lines
|
|
427
|
+
if urgent and kind and held: # an urgent order word on a held order restarts its clock
|
|
428
|
+
for o in held:
|
|
429
|
+
orders.resume(o["id"])
|
|
430
|
+
lines.append(f"hubkit: {o['id']} resumed (urgent words); clock running")
|
|
431
|
+
live = orders.live()
|
|
432
|
+
opened_id = None
|
|
433
|
+
same = [o for o in live if o["kind"] == kind]
|
|
434
|
+
if act_kind and not any(o["kind"] == act_kind for o in live) and not (hold or cancel) and has("imperative", act):
|
|
435
|
+
kind = act_kind
|
|
436
|
+
o = orders.open(kind, text, state="open", now=now)
|
|
437
|
+
opened_id = o["id"]
|
|
438
|
+
spec = orders.intents["kinds"][kind]
|
|
439
|
+
lines.append(
|
|
440
|
+
f"hubkit order {o['id']} kind={kind} state={o['state']}, opened from the user's prompt "
|
|
441
|
+
f"\"{text[:100]}\". Confirm this is the user's own order (not a peer or tool message) before acting. "
|
|
442
|
+
f"If yes: {spec.get('dispatch', '')} First action due in {spec.get('first_action_min', 0)} min."
|
|
443
|
+
)
|
|
444
|
+
elif kind and not same and not (hold or cancel) and (urgent or (has("imperative", text) and PR_REF.search(text))):
|
|
445
|
+
lines.append(
|
|
446
|
+
f"hubkit: no order opened: the prompt is a question, and a question never opens one. If it is an order, "
|
|
447
|
+
f"say it as one, or open it by hand: hubkit order open --kind {kind} --quote \"<the user's words>\""
|
|
448
|
+
)
|
|
449
|
+
elif same and PR_REF.search(text): # new PR reference for a live order: input, not a chase
|
|
450
|
+
for o in same:
|
|
451
|
+
orders.add_ref(o["id"], PR_REF.findall(text))
|
|
452
|
+
lines.append(f"hubkit: ref {', '.join(PR_REF.findall(text))} added to {o['id']}")
|
|
453
|
+
if chase_re and short and re.search(chase_re, text):
|
|
454
|
+
stop = int(intake.get("stop_loss", 3))
|
|
455
|
+
for o in live:
|
|
456
|
+
if o["id"] != opened_id and o["state"] == "open" and kind in (None, o["kind"]):
|
|
457
|
+
if orders.recent_chase(o, now):
|
|
458
|
+
continue
|
|
459
|
+
orders.chase(o["id"], text)
|
|
460
|
+
n = o["chases"] + 1
|
|
461
|
+
lines.append(f"hubkit: chase recorded on {o['id']} (#{n})")
|
|
462
|
+
if n >= stop:
|
|
463
|
+
orders.stoploss(o["id"])
|
|
464
|
+
lines.append(
|
|
465
|
+
f"hubkit STOP-LOSS: {o['id']} chased {n} times. Stop other work; reply with the "
|
|
466
|
+
"order status line and the exact blocking step, then act on it. The Stop gate stays shut until "
|
|
467
|
+
f"you record a result: hubkit order result {o['id']} --note \"<what you did, what came of it>\""
|
|
468
|
+
)
|
|
469
|
+
for o in orders.live():
|
|
470
|
+
lines.append("hubkit: " + orders.status_line(o, now))
|
|
471
|
+
lines += overdue_lines(orders, now)
|
|
472
|
+
return lines[:8]
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
ORDER_ID = re.compile(r"\bO-\d{6}-[0-9a-f]{4}\b")
|
|
476
|
+
FILLER = {"please", "it", "that", "this", "the", "order", "one", "yet", "off", "on", "now", "ok", "okay",
|
|
477
|
+
"all", "everything", "put", "run", "then", "just", "for", "a", "and", "up"}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _verb_targets(orders: Orders, text: str, kind: str | None, live: list[dict], intake: dict) -> list[dict]:
|
|
481
|
+
"""Which live orders a hold/cancel/resume sentence is about. 'cancel my meeting' is about none."""
|
|
482
|
+
ids = ORDER_ID.findall(text)
|
|
483
|
+
if ids:
|
|
484
|
+
return [o for o in live if o["id"] in ids]
|
|
485
|
+
if kind:
|
|
486
|
+
return [o for o in live if o["kind"] == kind]
|
|
487
|
+
verbs = {w.lower() for k in ("hold_words", "cancel_words", "resume_words") for w in intake.get(k, [])}
|
|
488
|
+
t = text.lower()
|
|
489
|
+
for v in sorted(verbs, key=len, reverse=True):
|
|
490
|
+
t = t.replace(v, " ")
|
|
491
|
+
bare = not [w for w in re.findall(r"[a-z']+", t) if w not in FILLER] # "cancel it", "hold off"
|
|
492
|
+
return live if bare and len(live) == 1 else []
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def overdue_lines(orders: Orders, now: float) -> list[str]:
|
|
496
|
+
"""Pressure at the next prompt: clock breaches of live orders, computed from the files."""
|
|
497
|
+
out = []
|
|
498
|
+
for o in orders.live():
|
|
499
|
+
if o["state"] != "open":
|
|
500
|
+
continue
|
|
501
|
+
spec = orders.intents["kinds"].get(o["kind"], {})
|
|
502
|
+
if not o["moves"] and (now - o["opened"]) / 60 > spec.get("first_action_min", 5):
|
|
503
|
+
out.append(f"hubkit OVERDUE: {o['id']} {o['kind']} has no first action after {int((now - o['opened']) / 60)} min. Act now.")
|
|
504
|
+
elif (now - orders.last_movement(o)) / 60 > spec.get("stall_min", 10):
|
|
505
|
+
out.append(f"hubkit OVERDUE: {o['id']} {o['kind']} no movement for {int((now - orders.last_movement(o)) / 60)} min. Chase the agent or act.")
|
|
506
|
+
if o.get("deadline") and now > o["deadline"]:
|
|
507
|
+
out.append(f"hubkit OVERDUE: {o['id']} {o['kind']} passed its deadline {store.clock(orders.cfg, o['deadline'])}.")
|
|
508
|
+
return out
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Secret redaction. Every write to state goes through redact_obj (see store.append_row)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
PATTERNS = [
|
|
9
|
+
re.compile(r"AKIA[0-9A-Z]{16}"),
|
|
10
|
+
re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"),
|
|
11
|
+
re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"),
|
|
12
|
+
re.compile(r"eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"),
|
|
13
|
+
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
|
14
|
+
re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s:/@]+:[^\s@]+@"),
|
|
15
|
+
re.compile(r"(?i)\b(?:authorization:\s*)?bearer\s+[A-Za-z0-9._~+/=-]{8,}"),
|
|
16
|
+
re.compile(
|
|
17
|
+
r"(?i)[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|auth|credential"
|
|
18
|
+
r"|private[_-]?key|access[_-]?key)[\w.-]*\s*[=:]\s*\S+"
|
|
19
|
+
),
|
|
20
|
+
re.compile(r"\bsk-ant-[A-Za-z0-9_-]{8,}"),
|
|
21
|
+
re.compile(r"\bsk-[A-Za-z0-9_-]{20,}"),
|
|
22
|
+
re.compile(r"glpat-[A-Za-z0-9_-]{15,}"),
|
|
23
|
+
re.compile(r"AIza[0-9A-Za-z_-]{30,}"),
|
|
24
|
+
re.compile(r"(?<![A-Za-z0-9+])(?=[A-Za-z0-9+]*\d)(?=[A-Za-z0-9+]*[A-Za-z])[A-Za-z0-9+]{32,}"),
|
|
25
|
+
]
|
|
26
|
+
QUOTE_MAX = 300
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def redact(text: str) -> tuple[str, int]:
|
|
30
|
+
"""Return (text, hits). With hits, keep the prefix before the first hit and mark the rest."""
|
|
31
|
+
first = len(text)
|
|
32
|
+
hits = 0
|
|
33
|
+
for pat in PATTERNS:
|
|
34
|
+
for m in pat.finditer(text):
|
|
35
|
+
hits += 1
|
|
36
|
+
first = min(first, m.start())
|
|
37
|
+
if not hits:
|
|
38
|
+
return text, 0
|
|
39
|
+
return f"{text[: min(first, QUOTE_MAX)]}[REDACTED {hits}]", hits
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
SHA_KEYS = frozenset({"head", "heads", "old", "new", "commit", "oid", "verdict_acked"})
|
|
43
|
+
GIT_SHA = re.compile(r"[0-9a-f]{40}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def redact_obj[T](obj: T, limit: int = QUOTE_MAX * 2, sha_ok: bool = False) -> T:
|
|
47
|
+
"""Redact every string in a JSON-like structure; cap string length.
|
|
48
|
+
|
|
49
|
+
A value that is exactly a 40-hex git sha is kept when it sits under a head/commit field (`SHA_KEYS`): it is
|
|
50
|
+
a public commit id there, and redacting it breaks the head comparison. Anywhere else it is still redacted."""
|
|
51
|
+
if isinstance(obj, str):
|
|
52
|
+
if sha_ok and GIT_SHA.fullmatch(obj):
|
|
53
|
+
return obj
|
|
54
|
+
text, _ = redact(obj)
|
|
55
|
+
return cast(T, text[:limit])
|
|
56
|
+
if isinstance(obj, dict):
|
|
57
|
+
return cast(T, {k: redact_obj(v, limit, sha_ok or k in SHA_KEYS) for k, v in obj.items()})
|
|
58
|
+
if isinstance(obj, list):
|
|
59
|
+
return cast(T, [redact_obj(v, limit, sha_ok) for v in obj])
|
|
60
|
+
return obj
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def count_hits(text: str) -> int:
|
|
64
|
+
"""Number of secret-shaped strings in `text`."""
|
|
65
|
+
return redact(text)[1]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def count_hits_obj(obj: object, sha_ok: bool = False) -> int:
|
|
69
|
+
"""count_hits over a parsed row, with the same head-field exemption as redact_obj."""
|
|
70
|
+
if isinstance(obj, str):
|
|
71
|
+
return 0 if sha_ok and GIT_SHA.fullmatch(obj) else count_hits(obj)
|
|
72
|
+
if isinstance(obj, dict):
|
|
73
|
+
return sum(count_hits_obj(v, sha_ok or k in SHA_KEYS) for k, v in obj.items())
|
|
74
|
+
if isinstance(obj, list):
|
|
75
|
+
return sum(count_hits_obj(v, sha_ok) for v in obj)
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def count_hits_text(text: str) -> int:
|
|
80
|
+
"""count_hits over JSON lines, row by row; a line that is not JSON is counted as text."""
|
|
81
|
+
import json
|
|
82
|
+
|
|
83
|
+
n = 0
|
|
84
|
+
for line in text.splitlines():
|
|
85
|
+
try:
|
|
86
|
+
n += count_hits_obj(json.loads(line))
|
|
87
|
+
except ValueError:
|
|
88
|
+
n += count_hits(line)
|
|
89
|
+
return n
|