@r1z4x/tezgah 1.3.0 → 1.5.0
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.
- package/CHANGELOG.md +103 -1
- package/MANIFEST +3 -0
- package/agents/tezgah-reviewer.md +19 -10
- package/bin/tezgah-docs +3 -3
- package/bin/tezgah-doctor +75 -18
- package/bin/tezgah-route +5 -2
- package/bin/tezgah-setup +30 -18
- package/bin/tezgah-status +3 -1
- package/bin/tezgah-task +130 -37
- package/bin/tezgah-taste +74 -55
- package/bin/tezgah-triage +12 -7
- package/docs/architecture.md +4 -4
- package/docs/clarity-baseline.json +9 -9
- package/docs/contract.md +22 -12
- package/docs/environment.md +4 -1
- package/docs/evidence.md +71 -34
- package/docs/gate.md +21 -21
- package/docs/glossary.md +5 -5
- package/docs/hosts.md +8 -8
- package/docs/judge.md +93 -27
- package/docs/operations.md +69 -30
- package/docs/skills.md +2 -2
- package/docs/status-line.md +7 -7
- package/docs/vision.md +10 -5
- package/hooks/tezgah_agents.py +22 -11
- package/hooks/tezgah_attest.py +15 -22
- package/hooks/tezgah_context.py +84 -124
- package/hooks/tezgah_gate.py +26 -23
- package/hooks/tezgah_guard.py +9 -11
- package/hooks/tezgah_integrity.py +77 -178
- package/hooks/tezgah_judge.py +270 -122
- package/hooks/tezgah_lessons.py +18 -63
- package/hooks/tezgah_paths.py +183 -55
- package/hooks/tezgah_policy.py +35 -8
- package/hooks/tezgah_replay.py +57 -33
- package/hooks/tezgah_shapes.py +4 -4
- package/hooks/tezgah_skill_pick.py +6 -35
- package/hooks/tezgah_snapshot.py +25 -46
- package/hooks/tezgah_store.py +1268 -0
- package/hooks/tezgah_task.py +146 -40
- package/hooks/tezgah_taste.py +21 -18
- package/hooks/tezgah_taste_ledger.py +63 -88
- package/hooks/tezgah_update.py +6 -26
- package/hosts/codex/hook.py +5 -0
- package/hosts/cursor/hook.py +19 -31
- package/hosts/opencode/plugins/tezgah.js +309 -108
- package/package.json +1 -1
- package/skills/harness/SKILL.md +4 -0
- package/skills/plan-add/SKILL.md +23 -7
- package/skills/plan-add/render_table.py +3 -2
- package/skills/plan-status/SKILL.md +12 -4
- package/skills/plan-sync/SKILL.md +19 -9
- package/skills/tezgah-contract/SKILL.md +25 -12
package/hooks/tezgah_attest.py
CHANGED
|
@@ -27,6 +27,7 @@ import json
|
|
|
27
27
|
import os
|
|
28
28
|
|
|
29
29
|
import tezgah_paths as tp
|
|
30
|
+
import tezgah_store
|
|
30
31
|
|
|
31
32
|
ROOT = tp.PLUGIN_ROOT
|
|
32
33
|
CONTRACT_SHA = os.path.join(tp.CONFIG_DIR, "contract.sha256")
|
|
@@ -194,13 +195,13 @@ def drift(host, root=ROOT, shas=None):
|
|
|
194
195
|
return found, unknown
|
|
195
196
|
|
|
196
197
|
|
|
197
|
-
def
|
|
198
|
-
"""The mark one host's drifted session start leaves
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
start on one must not clear
|
|
202
|
-
|
|
203
|
-
|
|
198
|
+
def drift_key(session_id, host):
|
|
199
|
+
"""The key of the mark one host's drifted session start leaves in the
|
|
200
|
+
`harness_drift` store; its value is the drift list. Keyed by session AND
|
|
201
|
+
host: two hosts can share a session id (a `tezgah-context attest` run by
|
|
202
|
+
hand, a host whose ids collide), and a clean start on one must not clear
|
|
203
|
+
the other's mark."""
|
|
204
|
+
return "%s.%s" % (hashlib.sha256(str(session_id).encode()).hexdigest()[:16], host)
|
|
204
205
|
|
|
205
206
|
|
|
206
207
|
def mark_text(session_id):
|
|
@@ -209,11 +210,8 @@ def mark_text(session_id):
|
|
|
209
210
|
return ""
|
|
210
211
|
texts = []
|
|
211
212
|
for host in CODE_TARGETS:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
text = fh.read().strip()
|
|
215
|
-
except OSError:
|
|
216
|
-
continue
|
|
213
|
+
text = tezgah_store.doc("harness_drift", drift_key(session_id, host))
|
|
214
|
+
text = text.strip() if isinstance(text, str) else ""
|
|
217
215
|
if text and text not in texts:
|
|
218
216
|
texts.append(text)
|
|
219
217
|
return "; ".join(texts)
|
|
@@ -242,14 +240,9 @@ def run(host, session_id, cwd=None):
|
|
|
242
240
|
import tezgah_integrity
|
|
243
241
|
tezgah_integrity.note(session_id, "attest", detail, host=host,
|
|
244
242
|
switches=",".join(switches()) or None)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
fh.write("; ".join(found) + "\n")
|
|
251
|
-
elif os.path.exists(mark):
|
|
252
|
-
os.remove(mark)
|
|
253
|
-
except OSError:
|
|
254
|
-
pass
|
|
243
|
+
key = drift_key(session_id, host)
|
|
244
|
+
if found:
|
|
245
|
+
tezgah_store.put_doc("harness_drift", key, "; ".join(found))
|
|
246
|
+
else:
|
|
247
|
+
tezgah_store.drop("harness_drift", key)
|
|
255
248
|
return found
|
package/hooks/tezgah_context.py
CHANGED
|
@@ -17,7 +17,8 @@ import time
|
|
|
17
17
|
import tezgah_embed
|
|
18
18
|
import tezgah_orca
|
|
19
19
|
import tezgah_research
|
|
20
|
-
|
|
20
|
+
import tezgah_store
|
|
21
|
+
from tezgah_guard import import_crash_key
|
|
21
22
|
from tezgah_integrity import (STEP_KINDS, _heredocs, _path as _ledger_path,
|
|
22
23
|
_shell_segments, bind_session,
|
|
23
24
|
changed_files, cut, last_check, note,
|
|
@@ -25,8 +26,9 @@ from tezgah_integrity import (STEP_KINDS, _heredocs, _path as _ledger_path,
|
|
|
25
26
|
UNTRUSTED_CHANNEL)
|
|
26
27
|
from tezgah_lessons import (lesson_key, lines as lesson_lines,
|
|
27
28
|
tainted as tainted_lessons)
|
|
28
|
-
from tezgah_policy import (CONDITIONAL_KEYS, CORE,
|
|
29
|
-
REPLY_LANG_TEXT, open_lines_note,
|
|
29
|
+
from tezgah_policy import (CONDITIONAL_KEYS, CORE, FANOUT_ACTION, POINTERS,
|
|
30
|
+
PROMPT_REMINDER, REPLY_LANG_TEXT, open_lines_note,
|
|
31
|
+
pony_level_line)
|
|
30
32
|
from tezgah_paths import (CACHE, REPO_MARKS, SWITCHES, ai_research_dir, armed,
|
|
31
33
|
cache_dir, codegraph_bin, consult_options,
|
|
32
34
|
ensure_workspace, fallback_cache, have_judge_key, off,
|
|
@@ -105,6 +107,21 @@ def _tr_ui_ask(word):
|
|
|
105
107
|
return (r"(?:\b(?:%s)\w*[^.!?\n]{0,40}\b(?:%s))" % (TR_UI_OBJECTS, word))
|
|
106
108
|
|
|
107
109
|
|
|
110
|
+
# The fan-out hint's parts (PROMPT_HINTS["fanout"]). A list item's marker:
|
|
111
|
+
# `1.`, `1)`, `1 -`, `1:`, `a)`, or a `-`/`*`/`+`/`•` bullet, then its text.
|
|
112
|
+
_FAN_ITEM = r"(?:\d+[ \t]*[.):\u2013-]|[a-z]\)|[-*+\u2022])[ \t]+\S"
|
|
113
|
+
# A file a prompt names: a path with a directory, or a name with an extension.
|
|
114
|
+
# Anchored at a token start: unanchored, a long run of word characters made
|
|
115
|
+
# every position a start and the search quadratic (100 KB took minutes).
|
|
116
|
+
_FAN_FILE = (r"(?<![\w./-])[`'\"]?(?:(?:[\w.-]+/)+[\w.-]*\w|"
|
|
117
|
+
r"\w[\w-]*\.[a-z]{1,4}\b)[`'\"]?")
|
|
118
|
+
# The imperative that opens an English clause and closes a Turkish one.
|
|
119
|
+
_FAN_VERB_EN = (r"(?:fix|add|update|write|remove|delete|rename|move|refactor|"
|
|
120
|
+
r"document|bump|create|implement|migrate|replace)")
|
|
121
|
+
_FAN_VERB_TR = (r"(?:düzelt|güncelle|ekle|yaz|sil|kaldır|taşı|bitir|tamamla|"
|
|
122
|
+
r"incele|oluştur|değiştir)")
|
|
123
|
+
|
|
124
|
+
|
|
108
125
|
PROMPT_HINTS = (
|
|
109
126
|
("spec", r"\b(normal (user )?behaviou?r|clean ui|nicer|more intuitive|"
|
|
110
127
|
r"un?professional|polish(ed)?|improve the (ui|ux)|make it (better|"
|
|
@@ -339,9 +356,22 @@ PROMPT_HINTS = (
|
|
|
339
356
|
# had to say "do everything with subagents and in parallel" on every task
|
|
340
357
|
# because nothing armed the fan-out rule from the shape of the request.
|
|
341
358
|
# `parallelize` alone is code work, so only the delegation phrasings count.
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
359
|
+
# The owner's own shapes armed nothing until 2026-10-09: `1 - `, `1- `,
|
|
360
|
+
# `1:`, `a)`, `+` items, an inline `1. ... 2. ...`, "subagentlerle", three
|
|
361
|
+
# files named as one list, "First, ... Second, ...", and three imperative
|
|
362
|
+
# clauses in one sentence (clause-initial in English, clause-final in
|
|
363
|
+
# Turkish). A timestamp, a range and a traceback stay out (the corpus).
|
|
364
|
+
("fanout", r"(?ms:^[ \t]*" + _FAN_ITEM + r".*?^[ \t]*" + _FAN_ITEM + r")|"
|
|
365
|
+
r"(?<![\w.])1[ \t]*[.)-][ \t]+\S[^\n]*?(?<![\w.])2[ \t]*[.)-][ \t]+\S|"
|
|
366
|
+
+ _FAN_FILE + r"(?:[ \t]*(?:,[ \t]*(?:(?:and|ve)[ \t]+)?|"
|
|
367
|
+
r"(?:and|ve|&)[ \t]+)" + _FAN_FILE + r"){2,}|"
|
|
368
|
+
r"(?s:\bfirst(?:ly)?[,:].*?\bsecond(?:ly)?[,:])|"
|
|
369
|
+
r"(?s:\bbirinci(?:si)?[,:].*?\bikinci(?:si)?[,:])|"
|
|
370
|
+
r"(?m:(?:(?:^|[,;]|\band\b)[ \t]*(?:and[ \t]+)?" + _FAN_VERB_EN
|
|
371
|
+
+ r"\b[^\n]*?){3})|"
|
|
372
|
+
r"(?m:(?:\b" + _FAN_VERB_TR + r"\w{0,4}[ \t]*(?:[,;.]|\bve\b|$)"
|
|
373
|
+
r"[^\n]*?){3})|"
|
|
374
|
+
r"\b(?:in parallel|paralel\w*|sub-?agent\w*|alt ?ajan\w*|"
|
|
345
375
|
r"worktrees?|fan[- ]?out|orchestrat\w*|orkestra\w*)\b"),
|
|
346
376
|
)
|
|
347
377
|
|
|
@@ -1055,32 +1085,18 @@ def state_stamp(root):
|
|
|
1055
1085
|
"lessons": _lessons_state(root)}
|
|
1056
1086
|
|
|
1057
1087
|
|
|
1058
|
-
def _stamp_path(session_id):
|
|
1059
|
-
return os.path.join(cache_dir(), "turns", slug(str(session_id)) + ".json")
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
1088
|
def read_stamp(session_id):
|
|
1063
1089
|
"""The stamp this session's previous turn wrote, or None when there is none."""
|
|
1064
1090
|
if not session_id:
|
|
1065
1091
|
return None
|
|
1066
|
-
|
|
1067
|
-
with open(_stamp_path(session_id), encoding="utf-8") as fh:
|
|
1068
|
-
got = json.load(fh)
|
|
1069
|
-
except (OSError, ValueError):
|
|
1070
|
-
return None
|
|
1092
|
+
got = tezgah_store.doc("turns", slug(str(session_id)))
|
|
1071
1093
|
return got if isinstance(got, dict) and got.get("root") else None
|
|
1072
1094
|
|
|
1073
1095
|
|
|
1074
1096
|
def write_stamp(session_id, root, stamp):
|
|
1075
1097
|
"""Remember this turn's stamp. Best effort: a host may sandbox hook writes."""
|
|
1076
|
-
if
|
|
1077
|
-
|
|
1078
|
-
try:
|
|
1079
|
-
os.makedirs(os.path.join(cache_dir(), "turns"), exist_ok=True)
|
|
1080
|
-
with open(_stamp_path(session_id), "w", encoding="utf-8") as fh:
|
|
1081
|
-
json.dump(dict(stamp, root=root), fh)
|
|
1082
|
-
except OSError:
|
|
1083
|
-
pass
|
|
1098
|
+
if session_id:
|
|
1099
|
+
tezgah_store.put_doc("turns", slug(str(session_id)), dict(stamp, root=root))
|
|
1084
1100
|
|
|
1085
1101
|
|
|
1086
1102
|
def forget_seen(session_id):
|
|
@@ -1107,8 +1123,13 @@ ARMED_RESURFACE = 20
|
|
|
1107
1123
|
def armed_again(key):
|
|
1108
1124
|
"""The one line a matching prompt pays for a paragraph this session was
|
|
1109
1125
|
already shown. The research rule keeps its `{OPEN_LINES}` slot: the open
|
|
1110
|
-
lines are a fact about the repo now, not rule text the session holds.
|
|
1126
|
+
lines are a fact about the repo now, not rule text the session holds. The
|
|
1127
|
+
fan-out rule keeps its action (`FANOUT_ACTION`): the rule is a thing to do
|
|
1128
|
+
on this turn's list, and a bare reminder that it exists was not done."""
|
|
1111
1129
|
label = dict(CORE_RULES)[key]
|
|
1130
|
+
if key == "fanout":
|
|
1131
|
+
return ("%s Armed again: %s The full rule is in the `tezgah-contract` "
|
|
1132
|
+
"skill." % (label, FANOUT_ACTION))
|
|
1112
1133
|
return ("%s Armed again: the full rule was given earlier this session and "
|
|
1113
1134
|
"is in the `tezgah-contract` skill.%s"
|
|
1114
1135
|
% (label, "{OPEN_LINES}" if key == "research" else ""))
|
|
@@ -1627,10 +1648,11 @@ POST_TOOL_ROWS = frozenset(k.encode() for k in STEP_KINDS + ("external", "unknow
|
|
|
1627
1648
|
|
|
1628
1649
|
|
|
1629
1650
|
def _ledger_lines(path):
|
|
1651
|
+
"""The ledger's rows at `path` as their JSON bytes, oldest first."""
|
|
1630
1652
|
try:
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
except
|
|
1653
|
+
return [text.encode("utf-8", "replace")
|
|
1654
|
+
for text in tezgah_store.evidence_rows(path)]
|
|
1655
|
+
except tezgah_store.ERRORS:
|
|
1634
1656
|
return []
|
|
1635
1657
|
|
|
1636
1658
|
|
|
@@ -1672,12 +1694,10 @@ def _ledger_since(session_id):
|
|
|
1672
1694
|
return None, False
|
|
1673
1695
|
|
|
1674
1696
|
|
|
1675
|
-
def
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
def _switch_baseline(session_id):
|
|
1680
|
-
return os.path.join(cache_dir(), "switches", slug(str(session_id)) + ".json")
|
|
1697
|
+
def gate_marked(session_id):
|
|
1698
|
+
"""True when the prompt hook left the disarmed-gate mark for this session
|
|
1699
|
+
(`gate_inactive`)."""
|
|
1700
|
+
return tezgah_store.marked("gate_inactive", slug(str(session_id)))
|
|
1681
1701
|
|
|
1682
1702
|
|
|
1683
1703
|
def disarmed(session_id):
|
|
@@ -1694,24 +1714,17 @@ def disarmed(session_id):
|
|
|
1694
1714
|
# the files alone (`armed`, unlatched): a switch the latch ignores is still
|
|
1695
1715
|
# the tamper shape this records
|
|
1696
1716
|
now = sorted(n for n in SWITCHES if armed(n))
|
|
1697
|
-
|
|
1717
|
+
state = tezgah_store.doc("switches", slug(str(session_id)))
|
|
1698
1718
|
try:
|
|
1699
|
-
with open(path, encoding="utf-8") as fh:
|
|
1700
|
-
state = json.load(fh)
|
|
1701
1719
|
start, seen = list(state["start"]), list(state["seen"])
|
|
1702
|
-
except (
|
|
1720
|
+
except (KeyError, TypeError):
|
|
1703
1721
|
start, seen = now, []
|
|
1704
1722
|
moved = [n for n in now if n not in start]
|
|
1705
1723
|
for name in moved:
|
|
1706
1724
|
if name not in seen:
|
|
1707
1725
|
note(session_id, "disarm", name)
|
|
1708
1726
|
seen.append(name)
|
|
1709
|
-
|
|
1710
|
-
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
1711
|
-
with open(path, "w", encoding="utf-8") as fh:
|
|
1712
|
-
json.dump({"start": start, "seen": seen}, fh)
|
|
1713
|
-
except OSError:
|
|
1714
|
-
pass
|
|
1727
|
+
tezgah_store.put_doc("switches", slug(str(session_id)), {"start": start, "seen": seen})
|
|
1715
1728
|
return moved
|
|
1716
1729
|
|
|
1717
1730
|
|
|
@@ -1730,14 +1743,13 @@ def gate_inactive(session_id, payload):
|
|
|
1730
1743
|
calls = 0 if since is None or rows else _transcript_calls(
|
|
1731
1744
|
p.get("transcript_path"), since)
|
|
1732
1745
|
inactive = calls >= GATE_MIN_CALLS
|
|
1733
|
-
|
|
1746
|
+
key = slug(str(session_id))
|
|
1734
1747
|
try:
|
|
1735
1748
|
if inactive or moved:
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
except OSError:
|
|
1749
|
+
tezgah_store.mark("gate_inactive", key)
|
|
1750
|
+
else:
|
|
1751
|
+
tezgah_store.drop("gate_inactive", key)
|
|
1752
|
+
except tezgah_store.ERRORS:
|
|
1741
1753
|
pass
|
|
1742
1754
|
return GATE_INACTIVE % calls if inactive else ""
|
|
1743
1755
|
|
|
@@ -2210,6 +2222,10 @@ def context_for(event, cwd, payload=None, with_core=True):
|
|
|
2210
2222
|
tezgah_taste.learn_later(taste_root)
|
|
2211
2223
|
except Exception:
|
|
2212
2224
|
pass
|
|
2225
|
+
# The legacy JSONL ledgers into the evidence database: a detached
|
|
2226
|
+
# bulk import at most daily (tezgah_store.import_later).
|
|
2227
|
+
if event == "session_start":
|
|
2228
|
+
tezgah_store.import_later(cache_dir())
|
|
2213
2229
|
broken = tezgah_research.failing(root) if not off("research-off") else []
|
|
2214
2230
|
if broken:
|
|
2215
2231
|
line_slug, err = broken[0]
|
|
@@ -2408,62 +2424,22 @@ def record(session_id, kind):
|
|
|
2408
2424
|
if kind == "orch":
|
|
2409
2425
|
note(session_id, "orch")
|
|
2410
2426
|
try:
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
with open(os.path.join(d, slug(session_id) + ".jsonl"), "a", encoding="utf-8") as fh:
|
|
2414
|
-
fh.write(json.dumps({"kind": kind}) + "\n")
|
|
2415
|
-
except OSError:
|
|
2427
|
+
tezgah_store.use(slug(session_id), kind)
|
|
2428
|
+
except tezgah_store.ERRORS:
|
|
2416
2429
|
pass
|
|
2417
2430
|
|
|
2418
2431
|
|
|
2419
|
-
# Past this size `used()` compacts the store to one row per kind. Every reader
|
|
2420
|
-
# of the store takes it as a set, so nothing is lost, and the next read is a
|
|
2421
|
-
# few hundred bytes however long the session ran.
|
|
2422
|
-
USED_MAX_BYTES = 65536
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
2432
|
def used(session_id):
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
out = set()
|
|
2429
|
-
path = os.path.join(cache_dir(), "sessions", slug(session_id) + ".jsonl")
|
|
2430
|
-
try:
|
|
2431
|
-
with open(path, encoding="utf-8") as fh:
|
|
2432
|
-
size = os.fstat(fh.fileno()).st_size
|
|
2433
|
-
for line in fh:
|
|
2434
|
-
try:
|
|
2435
|
-
out.add(json.loads(line)["kind"])
|
|
2436
|
-
except (ValueError, KeyError):
|
|
2437
|
-
pass
|
|
2438
|
-
# a row appended since the open changes the size: skip, never lose it
|
|
2439
|
-
if size > USED_MAX_BYTES and os.path.getsize(path) == size:
|
|
2440
|
-
tmp = path + ".tmp"
|
|
2441
|
-
with open(tmp, "w", encoding="utf-8") as fh:
|
|
2442
|
-
fh.writelines(json.dumps({"kind": k}) + "\n"
|
|
2443
|
-
for k in sorted(k for k in out
|
|
2444
|
-
if isinstance(k, str)))
|
|
2445
|
-
os.replace(tmp, path)
|
|
2446
|
-
except OSError:
|
|
2447
|
-
pass
|
|
2448
|
-
return out
|
|
2433
|
+
"""The kinds this session recorded, a set (one `used` row per kind)."""
|
|
2434
|
+
return tezgah_store.used(slug(session_id)) if session_id else set()
|
|
2449
2435
|
|
|
2450
2436
|
|
|
2451
|
-
# How many of the
|
|
2452
|
-
#
|
|
2453
|
-
#
|
|
2454
|
-
# store is its last event.
|
|
2437
|
+
# How many of the sessions that recorded last a fitness report reads, by the
|
|
2438
|
+
# time each last recorded anything, which for a used-kind store is its last
|
|
2439
|
+
# event.
|
|
2455
2440
|
FITNESS_WINDOW = 200
|
|
2456
2441
|
|
|
2457
2442
|
|
|
2458
|
-
def _mtime(path):
|
|
2459
|
-
"""A file's mtime, or 0 when it is gone: the window is a sort key, and a
|
|
2460
|
-
session that vanished mid-report must not crash the report."""
|
|
2461
|
-
try:
|
|
2462
|
-
return os.path.getmtime(path)
|
|
2463
|
-
except OSError:
|
|
2464
|
-
return 0.0
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
2443
|
def skill_fitness(window=FITNESS_WINDOW):
|
|
2468
2444
|
"""Which shipped skills the recorded sessions opened, and which opened none.
|
|
2469
2445
|
|
|
@@ -2474,45 +2450,29 @@ def skill_fitness(window=FITNESS_WINDOW):
|
|
|
2474
2450
|
skill a read opened). This is a measurement, not a gate - it names the
|
|
2475
2451
|
candidates, and retiring one stays the owner's call.
|
|
2476
2452
|
|
|
2477
|
-
The window is the
|
|
2478
|
-
|
|
2453
|
+
The window is the `window` sessions that recorded last. A session counts
|
|
2454
|
+
as having opened a skill when it
|
|
2479
2455
|
recorded that skill's kind: `skill:<name>`, or the mark the status line draws
|
|
2480
2456
|
for the two marked skills, so reads recorded before `SKILL_KIND` existed
|
|
2481
2457
|
still count. Sessions on a host that cannot see a read record neither, and
|
|
2482
2458
|
the report cannot tell that session from one that read nothing - the number
|
|
2483
2459
|
is a floor, not a census.
|
|
2484
2460
|
|
|
2485
|
-
Returns `sessions` and `recorded` (the
|
|
2486
|
-
`skills` (the opened ones ranked by session count, each with the
|
|
2487
|
-
line draws for it, or None), and `never` (the shipped skills no
|
|
2488
|
-
the window opened)."""
|
|
2461
|
+
Returns `sessions` and `recorded` (the sessions read and the sessions
|
|
2462
|
+
recorded), `skills` (the opened ones ranked by session count, each with the
|
|
2463
|
+
mark the line draws for it, or None), and `never` (the shipped skills no
|
|
2464
|
+
session in the window opened)."""
|
|
2489
2465
|
catalog = shipped_skills()
|
|
2490
|
-
|
|
2491
|
-
try:
|
|
2492
|
-
files = sorted((os.path.join(d, n) for n in os.listdir(d)
|
|
2493
|
-
if n.endswith(".jsonl")),
|
|
2494
|
-
key=_mtime, reverse=True)
|
|
2495
|
-
except OSError:
|
|
2496
|
-
files = []
|
|
2466
|
+
window_kinds, recorded = tezgah_store.used_sessions(window)
|
|
2497
2467
|
opened = {}
|
|
2498
|
-
for
|
|
2499
|
-
kinds = set()
|
|
2500
|
-
try:
|
|
2501
|
-
with open(path, encoding="utf-8") as fh:
|
|
2502
|
-
for line in fh:
|
|
2503
|
-
try:
|
|
2504
|
-
kinds.add(json.loads(line)["kind"])
|
|
2505
|
-
except (ValueError, KeyError, TypeError):
|
|
2506
|
-
pass # a torn line, the way `used` reads one
|
|
2507
|
-
except OSError:
|
|
2508
|
-
continue
|
|
2468
|
+
for kinds in window_kinds:
|
|
2509
2469
|
for name in catalog:
|
|
2510
2470
|
mark = SKILL_MARKS.get(name)
|
|
2511
2471
|
if (SKILL_KIND + name) in kinds or (mark and mark in kinds):
|
|
2512
2472
|
opened[name] = opened.get(name, 0) + 1
|
|
2513
2473
|
return {
|
|
2514
|
-
"sessions": len(
|
|
2515
|
-
"recorded":
|
|
2474
|
+
"sessions": len(window_kinds),
|
|
2475
|
+
"recorded": recorded,
|
|
2516
2476
|
"skills": [{"name": name, "mark": SKILL_MARKS.get(name),
|
|
2517
2477
|
"sessions": count}
|
|
2518
2478
|
for name, count in sorted(opened.items(),
|
|
@@ -2801,7 +2761,7 @@ def health_segments(cwd, session_id=None, used_override=None, idx_override=None,
|
|
|
2801
2761
|
segs = with_update_notice(version_segment())
|
|
2802
2762
|
# Shown only when the prompt hook found the gate disarmed (gate_inactive):
|
|
2803
2763
|
# first, because it says every other mark is not being enforced.
|
|
2804
|
-
if session_id and
|
|
2764
|
+
if session_id and gate_marked(session_id):
|
|
2805
2765
|
segs.append({"key": "gate", "state": "off", "glyph": GLYPHS["off"],
|
|
2806
2766
|
"text": "gate", "group": 0})
|
|
2807
2767
|
# Shown when this session's start found tezgah's own hook entries changed
|
|
@@ -2819,7 +2779,7 @@ def health_segments(cwd, session_id=None, used_override=None, idx_override=None,
|
|
|
2819
2779
|
"text": "drift", "group": 0})
|
|
2820
2780
|
# A hook of this session could not import its core (tezgah_guard.
|
|
2821
2781
|
# import_failed): it failed open, so what it guards did not run.
|
|
2822
|
-
if session_id and
|
|
2782
|
+
if session_id and tezgah_store.doc("import_crash", import_crash_key(session_id)) is not None:
|
|
2823
2783
|
segs.append({"key": "crash", "state": "off", "glyph": GLYPHS["off"],
|
|
2824
2784
|
"text": "crash", "group": 0})
|
|
2825
2785
|
for name, on, meas in flags:
|
package/hooks/tezgah_gate.py
CHANGED
|
@@ -98,6 +98,8 @@ import re
|
|
|
98
98
|
import shlex
|
|
99
99
|
import time
|
|
100
100
|
|
|
101
|
+
import tezgah_store
|
|
102
|
+
|
|
101
103
|
from tezgah_integrity import (BASH_TOOLS, BEGAN_KIND, ENV_WORD, GIT_VALUE_OPTS,
|
|
102
104
|
HEREDOC_RISK, HOOKS_KEY, PATCH_FILE,
|
|
103
105
|
PLAIN_TAIL, SECRET_PREFIXED, STEP_KINDS,
|
|
@@ -587,25 +589,22 @@ def git_head(repo):
|
|
|
587
589
|
return None
|
|
588
590
|
|
|
589
591
|
|
|
590
|
-
def
|
|
591
|
-
"""The once-per-session mark: sha1(id)[:16],
|
|
592
|
-
|
|
593
|
-
|
|
592
|
+
def nudge_key(session_id):
|
|
593
|
+
"""The once-per-session mark's key in the `nudged` store: sha1(id)[:16],
|
|
594
|
+
the key opencode's oncePerSession writes."""
|
|
595
|
+
return hashlib.sha1((session_id or "nosession").encode("utf-8")).hexdigest()[:16]
|
|
594
596
|
|
|
595
597
|
|
|
596
598
|
def first_nudge(session_id):
|
|
597
599
|
"""Consume the once-per-session nudge. False when already spent/unwritable.
|
|
598
600
|
|
|
599
|
-
The mark lands in cache_dir(), so a sandboxed host (dsh) still
|
|
600
|
-
one-shot nudge instead of the write failing open.
|
|
601
|
-
|
|
602
|
-
if os.path.exists(mark):
|
|
603
|
-
return False
|
|
601
|
+
The mark lands in cache_dir()'s database, so a sandboxed host (dsh) still
|
|
602
|
+
gets the one-shot nudge instead of the write failing open. One INSERT, so
|
|
603
|
+
of two calls at once exactly one is first."""
|
|
604
604
|
try:
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
except OSError:
|
|
605
|
+
# consume BEFORE denying: later greps pass
|
|
606
|
+
return tezgah_store.mark("nudged", nudge_key(session_id))
|
|
607
|
+
except tezgah_store.ERRORS:
|
|
609
608
|
return False
|
|
610
609
|
|
|
611
610
|
|
|
@@ -1841,11 +1840,13 @@ HOOK_ENTRIES = frozenset(("projects-auto-init", "projects-pretooluse",
|
|
|
1841
1840
|
HOOK_HOSTS = frozenset(("codex", "cursor", "omp"))
|
|
1842
1841
|
# Programs that change a path named in their arguments. Every positional is a
|
|
1843
1842
|
# target, except for the copiers, whose last one is (`cp switch /tmp` reads it).
|
|
1844
|
-
# `sed`/`perl` count only with an in-place flag.
|
|
1843
|
+
# `sed`/`perl` count only with an in-place flag. `sqlite3` rewrites the cache
|
|
1844
|
+
# stores, rows of `tezgah.db`, as `rm` would remove the files they were; its
|
|
1845
|
+
# SQL argument names no protected path.
|
|
1845
1846
|
CONTROL_PROGRAMS = frozenset(("touch", "rm", "rmdir", "unlink", "shred",
|
|
1846
1847
|
"truncate", "chmod", "chown", "chgrp", "mv",
|
|
1847
1848
|
"mkdir", "tee", "cp", "ln", "install", "rsync",
|
|
1848
|
-
"dd", "sed", "perl"))
|
|
1849
|
+
"dd", "sed", "perl", "sqlite3"))
|
|
1849
1850
|
CONTROL_COPIERS = frozenset(("cp", "mv", "ln", "install", "rsync"))
|
|
1850
1851
|
# The programs that remove, disable or move a path. A directory that holds
|
|
1851
1852
|
# protected state, a repository's husky hooks and the open plans (evidence) are
|
|
@@ -1867,13 +1868,15 @@ SHELL_NAMES = frozenset(("sh", "bash", "zsh", "dash", "ksh"))
|
|
|
1867
1868
|
SHELL_KEYWORDS = frozenset(("if", "then", "elif", "else", "do", "while",
|
|
1868
1869
|
"until", "!", "{", "time"))
|
|
1869
1870
|
SHELL_OPS = re.compile(r"^[;&|()<>]+$")
|
|
1870
|
-
# The cache subtrees whose rows the rules read: the
|
|
1871
|
-
#
|
|
1872
|
-
#
|
|
1873
|
-
#
|
|
1874
|
-
#
|
|
1875
|
-
|
|
1876
|
-
|
|
1871
|
+
# The cache subtrees whose rows the rules read: the cache database with its WAL
|
|
1872
|
+
# files (the evidence ledger, the used-kind marks, the switch baselines of
|
|
1873
|
+
# tezgah_context.disarmed, the gate mark, and the marks the status line reads
|
|
1874
|
+
# for a drifted hook entry and a core that failed to import), the evidence
|
|
1875
|
+
# JSONL files it imports, and the files the other stores it imports once
|
|
1876
|
+
# (tezgah_store.CACHE_LEGACY) had, until that import reads them.
|
|
1877
|
+
CONTROL_CACHE = ("evidence", "tezgah.db", "tezgah.db-wal", "tezgah.db-shm", "sessions",
|
|
1878
|
+
"switches", "gate-inactive", "harness-drift", "import-crash",
|
|
1879
|
+
"workspace-index.json")
|
|
1877
1880
|
# Top-level keys tezgah writes into Claude's settings (bin/tezgah-setup
|
|
1878
1881
|
# wire_claude_statusline, wire_claude_attribution), and the one that turns every
|
|
1879
1882
|
# hook off at once; any other key or entry is tezgah's only when it names tezgah.
|
|
@@ -2830,7 +2833,7 @@ def _dry_decision(tool, inp, cwd, session_id, agent=None):
|
|
|
2830
2833
|
|
|
2831
2834
|
g.update(note=lambda *a, **k: None, write_taint=lambda *a, **k: None,
|
|
2832
2835
|
capture=None,
|
|
2833
|
-
first_nudge=lambda sid: not
|
|
2836
|
+
first_nudge=lambda sid: not tezgah_store.marked("nudged", nudge_key(sid)))
|
|
2834
2837
|
try:
|
|
2835
2838
|
return decision(tool, inp, cwd, session_id, agent=agent)
|
|
2836
2839
|
finally:
|
package/hooks/tezgah_guard.py
CHANGED
|
@@ -135,13 +135,11 @@ def attest_session(host, session_id, cwd):
|
|
|
135
135
|
return tezgah_attest.run(host, session_id, cwd)
|
|
136
136
|
|
|
137
137
|
|
|
138
|
-
def
|
|
139
|
-
"""The mark an import failure leaves for the status line, one
|
|
140
|
-
|
|
141
|
-
`tezgah_paths.CACHE`), because the module that failed may be tezgah_paths."""
|
|
138
|
+
def import_crash_key(session_id):
|
|
139
|
+
"""The key of the mark an import failure leaves for the status line, one
|
|
140
|
+
per session: the `import_crash` store of the cache database."""
|
|
142
141
|
import hashlib
|
|
143
|
-
return
|
|
144
|
-
hashlib.sha256(str(session_id).encode()).hexdigest()[:16])
|
|
142
|
+
return hashlib.sha256(str(session_id).encode()).hexdigest()[:16]
|
|
145
143
|
|
|
146
144
|
|
|
147
145
|
def import_failed(exc, code=0):
|
|
@@ -154,7 +152,7 @@ def import_failed(exc, code=0):
|
|
|
154
152
|
the rename that crashed every omp hook). A hook fails open (`code` 0) like
|
|
155
153
|
every other caught fault, so the dead core must be visible elsewhere: one
|
|
156
154
|
stderr line always, a `crash` row when the ledger's own modules still
|
|
157
|
-
import, and a mark the status line draws (`
|
|
155
|
+
import, and a mark the status line draws (`import_crash_key`). A CLI a
|
|
158
156
|
person or a tool asks for a verdict (`tezgah-gate check`) passes a non-zero
|
|
159
157
|
`code`: an empty answer from it would read as a pass."""
|
|
160
158
|
detail = "import: %s: %s" % (type(exc).__name__, str(exc)[:120])
|
|
@@ -163,10 +161,10 @@ def import_failed(exc, code=0):
|
|
|
163
161
|
% (os.path.basename(sys.argv[0] if sys.argv else "hook"), detail))
|
|
164
162
|
session = _payload_session()
|
|
165
163
|
try:
|
|
166
|
-
mark
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
164
|
+
# the store's own import may be what failed: then no mark, as no
|
|
165
|
+
# status line that imports it can draw one either
|
|
166
|
+
import tezgah_store
|
|
167
|
+
tezgah_store.put_doc("import_crash", import_crash_key(session), detail)
|
|
170
168
|
except Exception:
|
|
171
169
|
pass
|
|
172
170
|
try:
|