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,232 @@
|
|
|
1
|
+
"""Learnings log: one block per class of mistake. Locked, atomic, redacted."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import hashlib
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import config, store
|
|
12
|
+
from .redact import redact
|
|
13
|
+
|
|
14
|
+
HEADER_RE = re.compile(r"^## (?P<cls>[a-z0-9][a-z0-9-]*) \| gate: (?P<gate>.+)$")
|
|
15
|
+
SEEN_RE = re.compile(r"^ - (\d{4}-\d{2}-\d{2}) (.*)$")
|
|
16
|
+
BASIS_RE = re.compile(r"^- basis: (\w+)$")
|
|
17
|
+
ADDR_RE = re.compile(r"^- addressed: (\d{4}-\d{2}-\d{2}) (.*)$")
|
|
18
|
+
PREAMBLE = (
|
|
19
|
+
"# Hub learnings\n\n"
|
|
20
|
+
"One block per class of mistake. A repeat appends to `seen`.\n"
|
|
21
|
+
"A class seen 2+ times is promoted into the overlay (`learn.py promote-candidates`, then `learn.py promote`).\n"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_file(cfg: dict) -> Path:
|
|
26
|
+
"""The learnings file: paths.learnings, else <state dir>/learnings.md."""
|
|
27
|
+
return config.path_of(cfg, "learnings", store.state(cfg) / "learnings.md")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_blocks(path: Path) -> tuple[str, list[list[str]]]:
|
|
31
|
+
"""Split the learnings file into its preamble and one block of lines per class."""
|
|
32
|
+
if not path.exists():
|
|
33
|
+
return PREAMBLE, []
|
|
34
|
+
pre: list[str] = []
|
|
35
|
+
blocks: list[list[str]] = []
|
|
36
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
37
|
+
if line.startswith("## "):
|
|
38
|
+
blocks.append([line])
|
|
39
|
+
elif blocks:
|
|
40
|
+
blocks[-1].append(line)
|
|
41
|
+
else:
|
|
42
|
+
pre.append(line)
|
|
43
|
+
return ("\n".join(pre) + "\n" if pre else PREAMBLE), blocks
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_blocks(path: Path, pre: str, blocks: list[list[str]]) -> None:
|
|
47
|
+
"""Write the preamble and blocks back atomically."""
|
|
48
|
+
body = "\n\n".join("\n".join(b).rstrip() for b in blocks)
|
|
49
|
+
store.write_atomic(path, pre.rstrip() + "\n\n" + body + "\n")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def block_class(block: list[str]) -> str:
|
|
53
|
+
"""The class name of a block (from its header line)."""
|
|
54
|
+
m = HEADER_RE.match(block[0])
|
|
55
|
+
return m["cls"] if m else ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def seen_lines(block: list[str]) -> list[str]:
|
|
59
|
+
"""The dated `seen` lines of a block."""
|
|
60
|
+
return [ln for ln in block if SEEN_RE.match(ln)]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def seen_dates(block: list[str]) -> list[str]:
|
|
64
|
+
"""The dates a block's class was seen."""
|
|
65
|
+
return [m[1] for ln in seen_lines(block) if (m := SEEN_RE.match(ln))]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def addressed(block: list[str]) -> tuple[str, str] | None:
|
|
69
|
+
"""(date, how) when the block is marked addressed, else None."""
|
|
70
|
+
for ln in block:
|
|
71
|
+
if m := ADDR_RE.match(ln):
|
|
72
|
+
return m[1], m[2]
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def field(block: list[str], name: str) -> str:
|
|
77
|
+
"""The text of a `- name: ...` line in a block, or an empty string."""
|
|
78
|
+
for ln in block:
|
|
79
|
+
if ln.startswith(f"- {name}: "):
|
|
80
|
+
return ln[len(name) + 4 :]
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def clean(text: str) -> str:
|
|
85
|
+
"""Collapse whitespace and redact secrets from user-supplied text."""
|
|
86
|
+
return redact(" ".join(text.split()))[0]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def locked_edit(path: Path) -> contextlib.AbstractContextManager[bool]:
|
|
90
|
+
"""Exclusive lock around an edit of `path`."""
|
|
91
|
+
return store.locked(path.with_name(f".{path.name}.lock"))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def overlay_path(cfg: dict) -> Path:
|
|
95
|
+
"""The overlay file (personal rules): paths.overlay, else <config dir>/overlay.md."""
|
|
96
|
+
return config.path_of(cfg, "overlay", Path(cfg["_config_dir"]) / "overlay.md")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def rules_hash(cfg: dict) -> str:
|
|
100
|
+
"""Hash of the rule texts a learning can be promoted into (overlay + SKILL.md)."""
|
|
101
|
+
h = hashlib.sha1()
|
|
102
|
+
for f in (overlay_path(cfg), config.SKILL_DIR / "SKILL.md"):
|
|
103
|
+
h.update(f.read_bytes() if f.exists() else b"")
|
|
104
|
+
return h.hexdigest()[:12]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _set_basis(block: list[str], basis: str) -> None:
|
|
108
|
+
if not basis:
|
|
109
|
+
return
|
|
110
|
+
block[:] = [ln for ln in block if not BASIS_RE.match(ln)]
|
|
111
|
+
block.insert(next(i for i, ln in enumerate(block) if ln == "- seen:"), f"- basis: {basis}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def add(path: Path, cls: str, quote: str, day: str, gate: str = "?", basis: str = "", **fields: str) -> str:
|
|
115
|
+
"""Record a correction: new block for a new class, a dated `seen` line for a repeat."""
|
|
116
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", cls):
|
|
117
|
+
raise ValueError("--class must be kebab-case (a-z, 0-9, -)")
|
|
118
|
+
q = clean(quote).replace('"', "'")
|
|
119
|
+
seen = f' - {day} "{q}"'
|
|
120
|
+
with locked_edit(path):
|
|
121
|
+
pre, blocks = read_blocks(path)
|
|
122
|
+
for block in blocks:
|
|
123
|
+
if block_class(block) != cls:
|
|
124
|
+
continue
|
|
125
|
+
if seen in block:
|
|
126
|
+
return f"skip: duplicate of {cls}"
|
|
127
|
+
idx = max(i for i, ln in enumerate(block) if SEEN_RE.match(ln))
|
|
128
|
+
block.insert(idx + 1, seen)
|
|
129
|
+
_set_basis(block, basis)
|
|
130
|
+
write_blocks(path, pre, blocks)
|
|
131
|
+
return f"repeat: {cls} now seen {len(seen_lines(block))}x"
|
|
132
|
+
for name in ("situation", "wrong", "right", "evidence"):
|
|
133
|
+
if not fields.get(name):
|
|
134
|
+
raise ValueError(f"new class needs --{name}")
|
|
135
|
+
blocks.append(
|
|
136
|
+
[f"## {cls} | gate: {clean(gate)}"]
|
|
137
|
+
+ [f"- {n}: {clean(fields[n])}" for n in ("situation", "wrong", "right", "evidence")]
|
|
138
|
+
+ ["- seen:", seen]
|
|
139
|
+
)
|
|
140
|
+
_set_basis(blocks[-1], basis)
|
|
141
|
+
write_blocks(path, pre, blocks)
|
|
142
|
+
return f"added: {cls}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def mark_addressed(path: Path, cls: str, day: str, how: str, current: str, rules_mtime: float, rule_text: str = "") -> str:
|
|
146
|
+
"""Mark a class fixed. Refused unless the overlay or SKILL.md changed since the entry was seen."""
|
|
147
|
+
with locked_edit(path):
|
|
148
|
+
pre, blocks = read_blocks(path)
|
|
149
|
+
for block in blocks:
|
|
150
|
+
if block_class(block) != cls:
|
|
151
|
+
continue
|
|
152
|
+
basis = next((m[1] for ln in block if (m := BASIS_RE.match(ln))), "")
|
|
153
|
+
if basis:
|
|
154
|
+
changed = current != basis
|
|
155
|
+
else: # old entry without a basis: file must be newer than the last sighting's day
|
|
156
|
+
last = last_seen(block)
|
|
157
|
+
changed = bool(last) and rules_mtime >= datetime.strptime(last, "%Y-%m-%d").timestamp()
|
|
158
|
+
if f"[{cls}]" not in rule_text:
|
|
159
|
+
raise ValueError(f"refused: no rule line tagged [{cls}] in the overlay or SKILL.md; run learn.py promote first")
|
|
160
|
+
if not changed:
|
|
161
|
+
raise ValueError(f"refused: overlay and SKILL.md unchanged since {cls} was seen; promote the rule first")
|
|
162
|
+
block[:] = [ln for ln in block if not ADDR_RE.match(ln)]
|
|
163
|
+
block.insert(next(i for i, ln in enumerate(block) if ln == "- seen:"), f"- addressed: {day} {clean(how)}")
|
|
164
|
+
write_blocks(path, pre, blocks)
|
|
165
|
+
return f"addressed: {cls}"
|
|
166
|
+
raise KeyError(f"no class {cls}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
PROMOTED = "## Promoted rules (learn.py promote)"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def promote(path: Path, overlay: Path, cls: str, gate: str, rule: str) -> str:
|
|
173
|
+
"""Write one short rule line into the overlay under its gate heading. User data stays out of SKILL.md."""
|
|
174
|
+
_, blocks = read_blocks(path)
|
|
175
|
+
if not any(block_class(b) == cls for b in blocks):
|
|
176
|
+
raise KeyError(f"no class {cls}")
|
|
177
|
+
if not re.fullmatch(r"G[1-9]", gate):
|
|
178
|
+
raise ValueError("--gate must look like G1..G9")
|
|
179
|
+
line = f"- {clean(rule)} [{cls}]"
|
|
180
|
+
with locked_edit(overlay):
|
|
181
|
+
lines = overlay.read_text(encoding="utf-8").splitlines() if overlay.exists() else []
|
|
182
|
+
if line in lines:
|
|
183
|
+
return f"skip: {cls} already promoted"
|
|
184
|
+
if PROMOTED not in lines:
|
|
185
|
+
lines += ["", PROMOTED, ""]
|
|
186
|
+
head = f"### {gate}"
|
|
187
|
+
start = lines.index(PROMOTED)
|
|
188
|
+
end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("## ")), len(lines))
|
|
189
|
+
if head not in lines[start:end]:
|
|
190
|
+
lines[end:end] = [head, ""]
|
|
191
|
+
end += 2
|
|
192
|
+
at = lines.index(head, start)
|
|
193
|
+
stop = next((i for i in range(at + 1, len(lines)) if lines[i].startswith("#")), len(lines))
|
|
194
|
+
while stop > at + 1 and not lines[stop - 1].strip():
|
|
195
|
+
stop -= 1
|
|
196
|
+
lines.insert(stop, line)
|
|
197
|
+
store.write_atomic(overlay, "\n".join(lines) + "\n")
|
|
198
|
+
return f"promoted: {cls} -> {gate}"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def last_seen(block: list[str]) -> str:
|
|
202
|
+
"""Latest date a block was seen, or an empty string."""
|
|
203
|
+
return max(seen_dates(block), default="")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def recent(path: Path, n: int) -> list[list[str]]:
|
|
207
|
+
"""The `n` most recently seen blocks."""
|
|
208
|
+
_, blocks = read_blocks(path)
|
|
209
|
+
return sorted(blocks, key=last_seen)[-n:]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def review(path: Path, day: str) -> dict[str, list[str]]:
|
|
213
|
+
"""Compare learnings with the day: new today, repeated today, and addressed status."""
|
|
214
|
+
_, blocks = read_blocks(path)
|
|
215
|
+
out: dict[str, list[str]] = {
|
|
216
|
+
"new": [], "repeated": [], "regressed": [], "holding": [], "unaddressed": []
|
|
217
|
+
}
|
|
218
|
+
for b in blocks:
|
|
219
|
+
cls, dates = block_class(b), seen_dates(b)
|
|
220
|
+
if dates and min(dates) == day:
|
|
221
|
+
out["new"].append(cls)
|
|
222
|
+
elif day in dates:
|
|
223
|
+
out["repeated"].append(cls)
|
|
224
|
+
addr = addressed(b)
|
|
225
|
+
if addr:
|
|
226
|
+
after = [d for d in dates if d > addr[0]]
|
|
227
|
+
(out["regressed"] if after else out["holding"]).append(
|
|
228
|
+
f"{cls} (addressed {addr[0]}" + (f", seen again {max(after)})" if after else ")")
|
|
229
|
+
)
|
|
230
|
+
elif len(dates) >= 2:
|
|
231
|
+
out["unaddressed"].append(f"{cls} ({len(dates)}x)")
|
|
232
|
+
return out
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Checks that come from repeated corrections: re-reading the user's asks, unchecked claims, briefs that forbid the
|
|
2
|
+
fix, and an unasked merge. Every check is a warning or a Stop reason; none denies a tool call."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import store
|
|
12
|
+
from .orders import Orders
|
|
13
|
+
|
|
14
|
+
# ---- asks: the user's recent words, one line each ----
|
|
15
|
+
|
|
16
|
+
DONE_WORDS = re.compile(r"(?i)\b(done|finished|complete[d]?|all set|shipped)\b")
|
|
17
|
+
SPAN = re.compile(r"^(\d+)\s*([mhd])$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_span(text: str) -> float:
|
|
21
|
+
"""`30m`, `12h` or `2d` as seconds; raises ValueError otherwise."""
|
|
22
|
+
m = SPAN.match(text.strip().lower())
|
|
23
|
+
if not m:
|
|
24
|
+
raise ValueError(f"--since takes a number and m, h or d (for example 12h), not {text!r}")
|
|
25
|
+
return int(m[1]) * {"m": 60, "h": 3600, "d": 86400}[m[2]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def recent_prompts(cfg: dict, since_s: float, now: float | None = None) -> list[dict]:
|
|
29
|
+
"""The human prompts held in recent-prompts.jsonl within the span, oldest first."""
|
|
30
|
+
now = now or time.time()
|
|
31
|
+
rows = store.read_rows(store.state(cfg) / "recent-prompts.jsonl")[0]
|
|
32
|
+
return [r for r in rows if now - r["ts"] <= since_s]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def asks_lines(cfg: dict, span: str) -> list[str]:
|
|
36
|
+
"""`HH:MM words` per prompt, newest last. Reading them marks the asks as swept for the Stop gate."""
|
|
37
|
+
rows = recent_prompts(cfg, parse_span(span))
|
|
38
|
+
store.write_atomic(store.state(cfg) / "asks-swept", str(time.time()))
|
|
39
|
+
return [f"{store.clock(cfg, r['ts'])} {r['text']}" for r in rows] or ["(no prompts in that span)"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _last_work_done(cfg: dict, now: float) -> float:
|
|
43
|
+
last = 0.0
|
|
44
|
+
for back in (0, 1):
|
|
45
|
+
day = store.day_of(cfg, now - back * 86400)
|
|
46
|
+
for r in store.read_rows(store.state(cfg) / "archive" / f"archive_{day}.jsonl")[0]:
|
|
47
|
+
if r.get("type") == "work" and r.get("outcome") == "done":
|
|
48
|
+
last = max(last, float(r["ts"]))
|
|
49
|
+
return last
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def unswept_asks(cfg: dict, last: str, now: float | None = None) -> str | None:
|
|
53
|
+
"""A reason when the message says done while the user's newest prompt is newer than the last `work done` and
|
|
54
|
+
the last `hubkit asks ls`: the requirements were not re-read."""
|
|
55
|
+
now = now or time.time()
|
|
56
|
+
if not DONE_WORDS.search(last):
|
|
57
|
+
return None
|
|
58
|
+
prompts = recent_prompts(cfg, 86400, now)
|
|
59
|
+
if not prompts:
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
swept = float((store.state(cfg) / "asks-swept").read_text(encoding="utf-8"))
|
|
63
|
+
except (OSError, ValueError):
|
|
64
|
+
swept = 0.0
|
|
65
|
+
if prompts[-1]["ts"] <= max(swept, _last_work_done(cfg, now)):
|
|
66
|
+
return None
|
|
67
|
+
return ("hubkit: you say it is done but have not re-read his words since his last prompt. NEXT: hubkit asks ls --since 12h; "
|
|
68
|
+
"list every requirement in them and tick each with evidence before saying done")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---- claims that need a lookup in the same turn ----
|
|
72
|
+
|
|
73
|
+
UNCHECKED_WORDS = ["gone", "lost", "absent", "vanished", "disappeared", "does not exist", "doesn't exist", "never written", "was never there",
|
|
74
|
+
"a person's call", "needs your call", "needs his call", "needs her call", "needs a human decision"]
|
|
75
|
+
LOOKUP_TOOLS = ("Read", "Grep", "Glob", "Bash")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def lookups_this_turn(transcript: str | Path | None) -> int | None:
|
|
79
|
+
"""Count of Read/Grep/Glob/Bash calls after the last typed prompt in the transcript, or None when unreadable."""
|
|
80
|
+
if not transcript:
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
lines = Path(transcript).read_text(encoding="utf-8", errors="replace").splitlines()
|
|
84
|
+
except OSError:
|
|
85
|
+
return None
|
|
86
|
+
n = 0
|
|
87
|
+
for line in reversed(lines):
|
|
88
|
+
try:
|
|
89
|
+
o = json.loads(line)
|
|
90
|
+
except ValueError:
|
|
91
|
+
continue
|
|
92
|
+
c = (o.get("message") or {}).get("content")
|
|
93
|
+
blocks = [b for b in c if isinstance(b, dict)] if isinstance(c, list) else []
|
|
94
|
+
if o.get("type") == "user" and (isinstance(c, str) or any(b.get("type") == "text" for b in blocks)) \
|
|
95
|
+
and (o.get("origin") or {}).get("kind", "human") == "human":
|
|
96
|
+
break
|
|
97
|
+
if o.get("type") == "assistant":
|
|
98
|
+
n += sum(1 for b in blocks if b.get("type") == "tool_use" and b.get("name") in LOOKUP_TOOLS)
|
|
99
|
+
return n
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def unchecked_claim(cfg: dict, last: str, transcript: str | Path | None) -> str | None:
|
|
103
|
+
"""A reason when the message calls something gone/lost/absent, or a person's call, and this turn made no lookup."""
|
|
104
|
+
from .gate import _asserted
|
|
105
|
+
|
|
106
|
+
words = Orders(cfg).intents.get("stop_gate", {}).get("unchecked_words", UNCHECKED_WORDS)
|
|
107
|
+
hit = next((s.strip()[:100] for s in re.split(r"(?<=[.!?])\s+|\n", last) if any(_asserted(s.lower(), w.lower()) for w in words)), None)
|
|
108
|
+
if not hit or lookups_this_turn(transcript) != 0:
|
|
109
|
+
return None
|
|
110
|
+
return (f"hubkit: your message claims {hit!r} but this turn read nothing. A report that something is gone, or is a person's call, "
|
|
111
|
+
"is checked, not relayed. NEXT: read the code or record that writes it (or what he already decided), then say it")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---- a brief must not forbid the step that clears a blocker ----
|
|
115
|
+
|
|
116
|
+
FORBID = re.compile(r"(?i)\b(do not|don't|dont|never|must not|mustn't|not allowed|forbidden|banned)\b")
|
|
117
|
+
STOP_WORDS = {"with", "that", "this", "from", "have", "been", "when", "then", "than", "into", "does", "host", "need", "needs", "start",
|
|
118
|
+
"running", "after", "before", "about", "their", "there", "which", "would", "could", "should"}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _keys(text: str) -> set[str]:
|
|
122
|
+
return {w for w in re.findall(r"[a-z][a-z0-9_-]{3,}", text.lower()) if w not in STOP_WORDS}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def brief_forbids_fix(cfg: dict, brief: str) -> str | None:
|
|
126
|
+
"""The line of a brief that forbids a step an open order is blocked on (shares a key word with its blocker or fix)."""
|
|
127
|
+
blockers = [(o["id"], b) for o in Orders(cfg).live() if o["state"] == "open" and (b := o.get("blocker"))]
|
|
128
|
+
for line in brief.splitlines():
|
|
129
|
+
if not FORBID.search(line):
|
|
130
|
+
continue
|
|
131
|
+
for oid, b in blockers:
|
|
132
|
+
shared = _keys(line) & _keys(b["on"] + " " + b["fix"])
|
|
133
|
+
if shared:
|
|
134
|
+
return (f"hubkit WARNING: this brief says {line.strip()[:100]!r}, which touches what {oid} is blocked on ({b['on'][:60]}; "
|
|
135
|
+
f"shared: {', '.join(sorted(shared)[:3])}). A brief must not forbid the one action that clears the blocker. "
|
|
136
|
+
"NEXT: reword it, or allow that step explicitly")
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---- merge guard (warn only) ----
|
|
141
|
+
|
|
142
|
+
MERGE_CMD = re.compile(r"\bgh\s+pr\s+merge\b([^\n;&|]*)")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def merge_target(command: str) -> str | None:
|
|
146
|
+
"""The PR number a `gh pr merge` command names ("" when it names none), or None when it is not a merge."""
|
|
147
|
+
m = MERGE_CMD.search(command)
|
|
148
|
+
if not m:
|
|
149
|
+
return None
|
|
150
|
+
n = re.search(r"(?:/pull/|\s)(\d{1,7})\b", " " + m[1])
|
|
151
|
+
return n[1] if n else ""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def merge_warning(cfg: dict, command: str, now: float | None = None) -> str | None:
|
|
155
|
+
"""A warning when a merge has neither a live order nor a typed word of his naming that PR. Never denies."""
|
|
156
|
+
num = merge_target(command)
|
|
157
|
+
if num is None:
|
|
158
|
+
return None
|
|
159
|
+
now = now or time.time()
|
|
160
|
+
for o in Orders(cfg).live():
|
|
161
|
+
if o["state"] == "open" and (re.search(rf"(?<!\d){num}(?!\d)", " ".join([o["quote"], *o["refs"]])) if num else o["kind"] == "merge"):
|
|
162
|
+
return None
|
|
163
|
+
for r in recent_prompts(cfg, 7200, now):
|
|
164
|
+
if re.search(r"(?i)\bmerge", r["text"]) and (not num or re.search(rf"(?<!\d){num}(?!\d)", r["text"])):
|
|
165
|
+
return None
|
|
166
|
+
what = f"#{num}" if num else "a PR"
|
|
167
|
+
return (f"hubkit WARNING: merging {what} with no live order and none of his typed words naming it. A merge is done on his order only. "
|
|
168
|
+
"NEXT: stop unless he ordered it; if he did, open the order: hubkit order open --kind merge --quote \"<his words>\"")
|