@r1z4x/tezgah 1.1.0 → 1.3.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.
@@ -0,0 +1,628 @@
1
+ #!/usr/bin/env python3
2
+ """Taste learning: the symbolic layer and the meta loop of taste-1's architecture
3
+ (research line taste-architecture, design T1P), and what the hooks inject from it.
4
+
5
+ The decision - preference, defect or none; which category; which scope; how the
6
+ signal relates to each learning already held - is a typed System One call made
7
+ by `bin/tezgah-taste learn`. This module never calls out: it takes those typed
8
+ answers and keeps the ledger.
9
+
10
+ - Fixed categories (`CATEGORIES`); a learning outside them is never created.
11
+ - Confidence is the beta expectation (r+1)/(r+s+2) over supporting (r) and
12
+ contradicting (s) evidence, each weighted by the probability the decision gave
13
+ it, with a per-scope forgetting factor per day (Beta Reputation System).
14
+ - States: candidate -> active after `SESSIONS_TO_ACTIVE` sessions; a
15
+ contradiction flags an active learning `conflicted` and stores the contrary
16
+ claim `quarantined`; `narrows` adds a narrower learning beside the broader one;
17
+ a user's reject retires one. A conflict ends when one side falls below
18
+ `RESOLVE_BELOW` or the user decides.
19
+ - Meta loop: a learning injected in a session whose category then gets a
20
+ preference correction that does not support it takes s += weight.
21
+ - Apply: a rule needs confidence >= `RULE_AT` and a calibration bound >=
22
+ `BOUND_AT` (Clopper-Pearson on an independently labelled sample); a hint needs
23
+ `HINT_AT`; a learning whose evidence paths are all gone from the tree is
24
+ suspended. The benefit gate's stop file turns injection off.
25
+
26
+ Store: `<repo>/.tezgah/taste/ledger.json` for path, language and repository
27
+ learnings, `~/.config/tezgah/taste/ledger.json` for user learnings.
28
+ """
29
+ import datetime
30
+ import errno
31
+ import fnmatch
32
+ import json
33
+ import math
34
+ import os
35
+
36
+ import tezgah_integrity as ti
37
+ from tezgah_paths import CONFIG_DIR, cache_dir
38
+
39
+ try:
40
+ import fcntl
41
+ except ImportError: # Windows
42
+ fcntl = None
43
+
44
+ CATEGORIES = ("architecture", "naming", "structure", "error-handling", "testing",
45
+ "dependencies", "language-idiom", "formatting", "docs-and-comments",
46
+ "tooling", "workflow-output")
47
+ SCOPES = ("path", "language", "repository", "user") # narrowest first
48
+ FORGET = {"path": 0.98, "language": 0.98, "repository": 0.99, "user": 0.995}
49
+ RELATIONS = ("supports", "contradicts", "narrows", "unrelated")
50
+ RULE_AT, HINT_AT, RESOLVE_BELOW, BOUND_AT = 0.8, 0.6, 0.4, 0.8
51
+ SESSIONS_TO_ACTIVE = 2
52
+ FORGOTTEN_BELOW = 0.25 # a one-session candidate or quarantined claim whose r fell under this is dropped
53
+ INJECT_MAX, TEXT_MAX = 12, 160
54
+
55
+
56
+ def now_day():
57
+ """Days since the epoch, as a float: the unit forgetting is counted in."""
58
+ return datetime.datetime.now(datetime.timezone.utc).timestamp() / 86400.0
59
+
60
+
61
+ def store_dir(root):
62
+ return os.path.join(root, ".tezgah", "taste")
63
+
64
+
65
+ def user_dir():
66
+ return os.path.join(CONFIG_DIR, "taste")
67
+
68
+
69
+ def _load(path):
70
+ try:
71
+ with open(path, encoding="utf-8") as fh:
72
+ data = json.load(fh)
73
+ if isinstance(data, dict) and isinstance(data.get("learnings"), dict):
74
+ data.setdefault("meta", {})
75
+ return data
76
+ except (OSError, ValueError):
77
+ pass
78
+ return {"v": 1, "learnings": {}, "meta": {}}
79
+
80
+
81
+ def _save(path, data):
82
+ os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
83
+ tmp = "%s.%d.tmp" % (path, os.getpid())
84
+ with open(tmp, "w", encoding="utf-8") as fh:
85
+ json.dump(data, fh, ensure_ascii=False, indent=1, sort_keys=True)
86
+ os.replace(tmp, path)
87
+
88
+
89
+ def load(root):
90
+ """{"repo": ledger, "user": ledger}."""
91
+ return {"repo": _load(os.path.join(store_dir(root), "ledger.json")),
92
+ "user": _load(os.path.join(user_dir(), "ledger.json"))}
93
+
94
+
95
+ def save(root, ledgers):
96
+ _save(os.path.join(store_dir(root), "ledger.json"), ledgers["repo"])
97
+ _save(os.path.join(user_dir(), "ledger.json"), ledgers["user"])
98
+
99
+
100
+ def every(ledgers):
101
+ """Both ledgers' learnings in one id map (the ids are kept distinct)."""
102
+ return dict(ledgers["repo"]["learnings"], **ledgers["user"]["learnings"])
103
+
104
+
105
+ # --- the evidence calculus -------------------------------------------------------
106
+
107
+ def decayed(learning, day):
108
+ """(r, s) forgotten forward to `day` at the learning's scope rate."""
109
+ rate = FORGET.get(learning.get("scope"), FORGET["repository"])
110
+ age = max(0.0, day - float(learning.get("updated", day)))
111
+ keep = rate ** age
112
+ return learning.get("r", 0.0) * keep, learning.get("s", 0.0) * keep
113
+
114
+
115
+ def confidence(learning, day=None):
116
+ r, s = decayed(learning, now_day() if day is None else day)
117
+ return (r + 1.0) / (r + s + 2.0)
118
+
119
+
120
+ def _touch(learning, day):
121
+ learning["r"], learning["s"] = decayed(learning, day)
122
+ learning["updated"] = day
123
+
124
+
125
+ def cp_lower(x, n, alpha=0.05):
126
+ """The two-sided (1-alpha) Clopper-Pearson lower bound on a proportion x/n:
127
+ the p at which P(X >= x | n, p) = alpha/2. 0 when there is no evidence."""
128
+ if n <= 0 or x <= 0:
129
+ return 0.0
130
+ if x >= n:
131
+ return (alpha / 2.0) ** (1.0 / n)
132
+
133
+ def tail(p):
134
+ return sum(math.comb(n, k) * p ** k * (1 - p) ** (n - k) for k in range(x, n + 1))
135
+ lo, hi = 0.0, x / n
136
+ for _ in range(60):
137
+ mid = (lo + hi) / 2.0
138
+ if tail(mid) < alpha / 2.0:
139
+ lo = mid
140
+ else:
141
+ hi = mid
142
+ return lo
143
+
144
+
145
+ def rows(path):
146
+ try:
147
+ with open(path, encoding="utf-8") as fh:
148
+ for line in fh:
149
+ try:
150
+ row = json.loads(line)
151
+ except ValueError:
152
+ continue
153
+ if isinstance(row, dict):
154
+ yield row
155
+ except OSError:
156
+ return
157
+
158
+
159
+ def append(path, row, strict=False):
160
+ """One row through the ledger's own writer: flock, torn-tail repair, 0600.
161
+ That writer drops a row it cannot write, which a hook wants; `strict` (the
162
+ CLI's decision, defect and label rows) reads the tail back and raises
163
+ OSError when the row is not there, so `learn` stops before it saves a
164
+ ledger whose decision was never recorded and would be re-applied."""
165
+ line = json.dumps(row, ensure_ascii=False) + "\n"
166
+ ti._append(path, line)
167
+ if strict:
168
+ data = line.encode("utf-8")
169
+ with open(path, "rb") as fh:
170
+ fh.seek(max(0, os.path.getsize(path) - len(data)))
171
+ if fh.read() != data:
172
+ raise OSError("taste row not written to %s" % path)
173
+
174
+
175
+ class Busy(Exception):
176
+ """Another command holds the taste ledgers' write lock."""
177
+
178
+
179
+ class locked:
180
+ """The one writer of every taste ledger on this machine: an exclusive,
181
+ non-blocking flock on `~/.config/tezgah/taste/ledger.lock`, held across a
182
+ command's whole read-change-write. One lock for all repositories, because
183
+ every command also rewrites the user ledger beside its repository's, and
184
+ two worktrees of one repository have two stores. A second writer fails
185
+ with `Busy` rather than overwriting the first. The hooks only read the
186
+ ledgers and never take it. Where flock does not exist (Windows) it is not
187
+ taken, as for the evidence ledger."""
188
+
189
+ def __init__(self):
190
+ self.path = os.path.join(user_dir(), "ledger.lock")
191
+ self.fh = None
192
+
193
+ def __enter__(self):
194
+ os.makedirs(os.path.dirname(self.path), mode=0o700, exist_ok=True)
195
+ self.fh = open(self.path, "a+", encoding="utf-8")
196
+ if fcntl is not None:
197
+ try:
198
+ fcntl.flock(self.fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
199
+ except OSError as exc:
200
+ self.fh.seek(0)
201
+ holder = self.fh.read().strip() or "another process"
202
+ self.fh.close()
203
+ if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN):
204
+ raise Busy(holder)
205
+ raise # a filesystem that cannot lock is not a busy ledger
206
+ self.fh.seek(0)
207
+ self.fh.truncate()
208
+ self.fh.write("pid %d" % os.getpid())
209
+ self.fh.flush()
210
+ return self
211
+
212
+ def __exit__(self, *exc):
213
+ self.fh.close() # closing the descriptor releases the flock
214
+ return False
215
+
216
+
217
+ def calibration(root):
218
+ """(x, n, bound): of the labelled signals the typed decision called a
219
+ preference, how many the label agrees with, and the Clopper-Pearson lower
220
+ bound on that precision. Only typed-provider decisions count; the labels are
221
+ a sample drawn independently of what was injected (`tezgah-taste label`)."""
222
+ x = n = 0
223
+ for row in rows(os.path.join(store_dir(root), "labels.jsonl")):
224
+ if row.get("provider") != "typesafe" or row.get("decided") != "preference":
225
+ continue
226
+ n += 1
227
+ x += row.get("label") == "preference"
228
+ return x, n, cp_lower(x, n)
229
+
230
+
231
+ # --- the symbolic layer -------------------------------------------------------------
232
+
233
+ def where_for(scope, paths):
234
+ """The scope's value for a signal's written paths: a directory glob, an
235
+ extension glob, the repository or every repository."""
236
+ paths = [p for p in paths or () if p]
237
+ if scope == "path" and paths:
238
+ common = os.path.commonpath([os.path.dirname(p) or "." for p in paths])
239
+ return (common.rstrip("/") + "/**") if common not in ("", ".") else "**"
240
+ if scope == "language" and paths:
241
+ ext = os.path.splitext(paths[0])[1]
242
+ return "*" + ext if ext else "*"
243
+ return "*" if scope == "user" else "."
244
+
245
+
246
+ def _evidence_row(sig):
247
+ return {"signal": sig.get("id"), "session": sig.get("session"),
248
+ "paths": list(sig.get("paths") or [])[:8]}
249
+
250
+
251
+ def _new(ledgers, sig, category, scope, weight, day, state="candidate", parent=None):
252
+ book = ledgers["user" if scope == "user" else "repo"]
253
+ taken = every(ledgers)
254
+ prefix = "u" if scope == "user" else "t"
255
+ # a monotonic counter: an id a forgotten learning held is never issued
256
+ # again, so a stale `conflicts` entry or injected row cannot name a stranger
257
+ n = max(int(book["meta"].get("next_id", 1)), len(book["learnings"]) + 1)
258
+ while "%s%04d" % (prefix, n) in taken:
259
+ n += 1
260
+ book["meta"]["next_id"] = n + 1
261
+ lid = "%s%04d" % (prefix, n)
262
+ book["learnings"][lid] = {
263
+ "id": lid, "category": category, "scope": scope,
264
+ "where": where_for(scope, sig.get("paths")),
265
+ "text": " ".join((sig.get("text") or "").split())[:400],
266
+ "written": False, "r": weight, "s": 0.0, "sessions": [sig.get("session") or ""],
267
+ "evidence": [_evidence_row(sig)], "state": state, "conflicts": [],
268
+ "parent": parent, "accepted": False, "edited": False,
269
+ "created": day, "updated": day}
270
+ return book["learnings"][lid]
271
+
272
+
273
+ def _support(learning, sig):
274
+ learning["evidence"] = (learning.get("evidence") or [])[-19:] + [_evidence_row(sig)]
275
+ if sig.get("session") and sig["session"] not in learning["sessions"]:
276
+ learning["sessions"].append(sig["session"])
277
+
278
+
279
+ def apply(ledgers, sig, dec, day, applied=()):
280
+ """Fold one decided signal into the ledgers; returns what happened:
281
+ `unverified`, `defect`, `none` or `learned`.
282
+
283
+ `dec` is the typed decision: `kind`, `category`, `scope` as (choice,
284
+ probability), `relations` {learning id: (relation, probability)} and
285
+ `verified`, True only when TypeSafe answered - any other provider's decision
286
+ is never applied. `applied` are the ids injected in the signal's session."""
287
+ if not dec.get("verified"):
288
+ return "unverified"
289
+ kind, p_kind = dec["kind"]
290
+ if kind != "preference":
291
+ return kind if kind in ("defect", "none") else "none"
292
+ category = dec["category"][0]
293
+ if category not in CATEGORIES:
294
+ return "none"
295
+ scope = dec["scope"][0] if dec["scope"][0] in SCOPES else "repository"
296
+ w = float(p_kind)
297
+ known = every(ledgers)
298
+ relations = dec.get("relations") or {}
299
+ contrary, linked = None, False
300
+ for lid, (rel, p) in relations.items():
301
+ learning = known.get(lid)
302
+ if learning is None or learning["state"] == "retired":
303
+ continue
304
+ _touch(learning, day)
305
+ if rel == "supports":
306
+ learning["r"] += w * p
307
+ _support(learning, sig)
308
+ if learning["state"] == "quarantined" and \
309
+ len(learning["sessions"]) >= SESSIONS_TO_ACTIVE:
310
+ # corroborated, but its partner still stands: withheld as a
311
+ # conflict until one side falls below RESOLVE_BELOW or the user decides
312
+ learning["state"] = "conflicted" if _live_partners(learning, known) \
313
+ else "candidate"
314
+ linked = True
315
+ elif rel == "contradicts":
316
+ learning["s"] += w * p
317
+ if contrary is None:
318
+ contrary = _new(ledgers, sig, category, scope, w, day, state=(
319
+ "quarantined" if learning["state"] in ("active", "conflicted")
320
+ else "candidate"))
321
+ if learning["state"] == "active":
322
+ learning["state"] = "conflicted"
323
+ for a, b in ((learning, contrary), (contrary, learning)):
324
+ if b["id"] not in a["conflicts"]:
325
+ a["conflicts"].append(b["id"])
326
+ linked = True
327
+ elif rel == "narrows" and SCOPES.index(scope) < SCOPES.index(learning["scope"]):
328
+ _new(ledgers, sig, category, scope, w * p, day, parent=lid)
329
+ linked = True
330
+ # the meta loop: a learning applied in this session and then corrected in
331
+ # its category and scope by a signal that neither supports nor contradicts it
332
+ for lid in applied:
333
+ learning = known.get(lid)
334
+ if learning is None or learning["category"] != category \
335
+ or not in_scope(learning, sig.get("paths")):
336
+ continue
337
+ if relations.get(lid, ("unrelated", 0.0))[0] in ("unrelated", "narrows"):
338
+ _touch(learning, day)
339
+ learning["s"] += w
340
+ if not linked:
341
+ _new(ledgers, sig, category, scope, w, day)
342
+ settle(ledgers, day)
343
+ return "learned"
344
+
345
+
346
+ def _live_partners(learning, known):
347
+ return [known[c] for c in learning["conflicts"]
348
+ if c in known and known[c]["state"] != "retired"]
349
+
350
+
351
+ def in_scope(learning, paths):
352
+ """True when a path or language learning covers one of `paths`; a
353
+ repository or user learning covers everything."""
354
+ if learning["scope"] not in ("path", "language"):
355
+ return True
356
+ return any(fnmatch.fnmatch(p, learning["where"]) for p in paths or ())
357
+
358
+
359
+ def settle(ledgers, day):
360
+ """Promote, resolve and forget: the transitions that depend on totals."""
361
+ known = every(ledgers)
362
+ for learning in known.values():
363
+ if learning["state"] == "candidate" and \
364
+ len(learning["sessions"]) >= SESSIONS_TO_ACTIVE:
365
+ # two claims that contradict each other never go active together
366
+ learning["state"] = "conflicted" if _live_partners(learning, known) \
367
+ else "active"
368
+ if learning["state"] == "conflicted":
369
+ others = _live_partners(learning, known)
370
+ if confidence(learning, day) < RESOLVE_BELOW:
371
+ learning["state"] = "retired"
372
+ for other in others:
373
+ if other["state"] == "quarantined":
374
+ other["state"] = "candidate"
375
+ elif all(confidence(o, day) < RESOLVE_BELOW for o in others):
376
+ for other in others:
377
+ other["state"] = "retired"
378
+ learning["state"] = "active"
379
+ gone = set()
380
+ for book in (ledgers["repo"], ledgers["user"]):
381
+ for lid in [k for k, v in book["learnings"].items()
382
+ if v["state"] in ("candidate", "quarantined")
383
+ and len(v["sessions"]) < SESSIONS_TO_ACTIVE
384
+ and not v.get("accepted")
385
+ and decayed(v, day)[0] < FORGOTTEN_BELOW]:
386
+ del book["learnings"][lid]
387
+ gone.add(lid)
388
+ if gone:
389
+ for learning in every(ledgers).values():
390
+ learning["conflicts"] = [c for c in learning["conflicts"] if c not in gone]
391
+
392
+
393
+ def decide_user(ledgers, lid, verdict, day, text=None):
394
+ """The user's own call on one learning: accept, reject or edit. An accepted
395
+ learning beats any learned one, so the learnings it conflicts with retire.
396
+ True when the id was found."""
397
+ known = every(ledgers)
398
+ learning = known.get(lid)
399
+ if learning is None:
400
+ return False
401
+ _touch(learning, day)
402
+ if verdict == "accept":
403
+ learning.update(state="active", accepted=True)
404
+ learning["r"] += 1.0
405
+ for c in learning["conflicts"]:
406
+ if c in known:
407
+ known[c]["state"] = "retired"
408
+ elif verdict == "reject":
409
+ learning["state"] = "retired"
410
+ elif verdict == "edit" and text and text.strip():
411
+ learning.update(text=" ".join(text.split())[:400], edited=True, written=True)
412
+ else:
413
+ return False
414
+ return True
415
+
416
+
417
+ # --- apply ----------------------------------------------------------------------------
418
+
419
+ def suspended(learning, root):
420
+ """True when every evidence path the learning cites is gone from `root`.
421
+ ponytail: capture records paths, not symbols, so a renamed symbol in a file
422
+ that still exists is not caught; add symbols when capture records them."""
423
+ if learning["scope"] == "user":
424
+ return False
425
+ paths = [p for ev in learning.get("evidence") or () for p in ev.get("paths") or ()]
426
+ return bool(paths) and not any(os.path.exists(os.path.join(root, p)) for p in paths)
427
+
428
+
429
+ def stopped(root):
430
+ """True when the benefit gate stopped injection for this repository."""
431
+ try:
432
+ with open(os.path.join(store_dir(root), "gate.json"), encoding="utf-8") as fh:
433
+ return bool(json.load(fh).get("stopped"))
434
+ except (OSError, ValueError, AttributeError):
435
+ return False
436
+
437
+
438
+ def usable(root, day=None):
439
+ """[(mode, learning)] that may be injected, narrowest scope first, then by
440
+ confidence: mode `rule` or `hint`."""
441
+ day = now_day() if day is None else day
442
+ bound = calibration(root)[2]
443
+ out = []
444
+ for learning in every(load(root)).values():
445
+ if learning["state"] != "active" or suspended(learning, root):
446
+ continue
447
+ conf = confidence(learning, day)
448
+ if learning.get("accepted") or (conf >= RULE_AT and bound >= BOUND_AT):
449
+ out.append(("rule", learning))
450
+ elif conf >= HINT_AT:
451
+ out.append(("hint", learning))
452
+ out.sort(key=lambda m: (SCOPES.index(m[1]["scope"]), -confidence(m[1], day)))
453
+ return out[:INJECT_MAX]
454
+
455
+
456
+ def _line(mode, learning):
457
+ where = "" if learning["scope"] in ("repository", "user") else " in %s" % learning["where"]
458
+ text = " ".join((learning.get("text") or "").split())[:TEXT_MAX]
459
+ return "- %s [%s%s, %s]: %s" % (mode, learning["category"], where, learning["id"], text)
460
+
461
+
462
+ def block(root, day=None):
463
+ """(text, ids): the session-start text and the learning ids in it, or
464
+ ("", []). The caller records the ids with `record_injection` only once the
465
+ text survived its context budget, so a dropped block never counts against
466
+ the learnings the model did not see."""
467
+ if stopped(root):
468
+ return "", []
469
+ picked = usable(root, day)
470
+ if not picked:
471
+ return "", []
472
+ return (("Taste (learned from this user's own corrections; follow a rule, treat a "
473
+ "hint as the default; the narrower scope wins - path, then language, then "
474
+ "repository, then user - and AGENTS.md/CLAUDE.md beat every learning; "
475
+ "`tezgah-taste reject <id>` drops one):\n"
476
+ + "\n".join(_line(mode, learning) for mode, learning in picked)),
477
+ [m[1]["id"] for m in picked])
478
+
479
+
480
+ def record_injection(root, session_id, ids, day=None):
481
+ """One `injected.jsonl` row: the meta loop counts a later correction in this
482
+ session against these ids, and the benefit gate splits its arms at the first."""
483
+ if ids:
484
+ append(os.path.join(store_dir(root), "injected.jsonl"),
485
+ {"session": str(session_id or ""), "day": now_day() if day is None else day,
486
+ "ids": list(ids)})
487
+
488
+
489
+ def injected(root, session_id):
490
+ """The learning ids injected in one session."""
491
+ ids = set()
492
+ for row in rows(os.path.join(store_dir(root), "injected.jsonl")):
493
+ if row.get("session") == session_id:
494
+ ids.update(row.get("ids") or ())
495
+ return ids
496
+
497
+
498
+ def write_note(root, session_id, rel_path):
499
+ """The in-scope learnings for a write to `rel_path` not yet shown in this
500
+ session, or "" - each learning once per session, so a long turn does not
501
+ repeat it and a path learning still shows at its directory's first write."""
502
+ if not session_id or not rel_path or stopped(root):
503
+ return ""
504
+ kind = os.path.splitext(rel_path)[1] or os.path.basename(rel_path)
505
+ seen_path = os.path.join(cache_dir(), "taste-notes",
506
+ "%s.json" % str(session_id).replace(os.sep, "_"))
507
+ try:
508
+ with open(seen_path, encoding="utf-8") as fh:
509
+ seen = set(json.load(fh))
510
+ except (OSError, ValueError, TypeError):
511
+ seen = set()
512
+ hits = [(mode, learning) for mode, learning in usable(root)
513
+ if learning["scope"] in ("path", "language") and learning["id"] not in seen
514
+ and fnmatch.fnmatch(rel_path, learning["where"])]
515
+ if not hits:
516
+ return ""
517
+ seen.update(m[1]["id"] for m in hits)
518
+ try:
519
+ os.makedirs(os.path.dirname(seen_path), mode=0o700, exist_ok=True)
520
+ with open(seen_path, "w", encoding="utf-8") as fh:
521
+ json.dump(sorted(seen), fh)
522
+ except OSError:
523
+ return ""
524
+ record_injection(root, session_id, [m[1]["id"] for m in hits])
525
+ return ("Taste for %s files (narrower scope wins):\n" % kind
526
+ + "\n".join(_line(mode, learning) for mode, learning in hits))
527
+
528
+
529
+ # --- markdown and export ----------------------------------------------------------------
530
+
531
+ def markdown(root, day=None):
532
+ """Rewrite `<store>/<category>/taste.md` from the active learnings; the JSON
533
+ ledger stays the source of truth. Returns the files written."""
534
+ day = now_day() if day is None else day
535
+ by_cat = {}
536
+ for learning in every(load(root)).values():
537
+ if learning["state"] == "active":
538
+ by_cat.setdefault(learning["category"], []).append(learning)
539
+ written = []
540
+ for category in CATEGORIES:
541
+ path = os.path.join(store_dir(root), category, "taste.md")
542
+ items = by_cat.get(category)
543
+ if not items:
544
+ if os.path.exists(path):
545
+ os.remove(path)
546
+ continue
547
+ lines = ["# %s" % category, ""]
548
+ for learning in sorted(items, key=lambda v: -confidence(v, day)):
549
+ scope = learning["scope"] + ("" if learning["scope"] in ("repository", "user")
550
+ else " " + learning["where"])
551
+ lines.append("- %s (%s, %s). Confidence: %.2f" % (
552
+ " ".join(learning["text"].split()), scope, learning["id"],
553
+ confidence(learning, day)))
554
+ os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
555
+ with open(path, "w", encoding="utf-8") as fh:
556
+ fh.write("\n".join(lines) + "\n")
557
+ written.append(path)
558
+ return written
559
+
560
+
561
+ START, END = "<!-- tezgah-taste:start -->", "<!-- tezgah-taste:end -->"
562
+
563
+
564
+ def export_agents(root):
565
+ """Write the accepted learnings into the repository's AGENTS.md between the
566
+ two markers, creating the file or the block. Returns how many were written."""
567
+ picked = [v for v in every(load(root)).values()
568
+ if v.get("accepted") and v["state"] == "active"]
569
+ path = os.path.join(root, "AGENTS.md")
570
+ try:
571
+ with open(path, encoding="utf-8") as fh:
572
+ text = fh.read()
573
+ except OSError:
574
+ text = ""
575
+ has_block = START in text and END in text
576
+ if not picked and not has_block:
577
+ return 0
578
+ body = "\n".join([START, "## Taste (accepted learnings)", ""]
579
+ + ["- %s" % " ".join(v["text"].split()) for v in picked] + [END])
580
+ if has_block:
581
+ text = text[:text.index(START)] + body + text[text.index(END) + len(END):]
582
+ else:
583
+ text = (text.rstrip("\n") + "\n\n" if text else "") + body + "\n"
584
+ with open(path, "w", encoding="utf-8") as fh:
585
+ fh.write(text)
586
+ return len(picked)
587
+
588
+
589
+ # --- the benefit gate --------------------------------------------------------------------
590
+
591
+ def first_injection(root):
592
+ """The day of the first injection, or None: where the gate's arms split."""
593
+ days = [r["day"] for r in rows(os.path.join(store_dir(root), "injected.jsonl"))
594
+ if isinstance(r.get("day"), (int, float))]
595
+ return min(days) if days else None
596
+
597
+
598
+ def gate(root, need=980, alpha=0.1):
599
+ """The pooled before/after test on preference corrections per writing turn.
600
+
601
+ Every decided signal is one prompt after a writing turn. The arms split at
602
+ the first injection, by when the turn happened (`at`), not by when `learn`
603
+ decided it, so a backfill of old sessions stays in the before arm. Once both
604
+ arms hold `need` turns, `gate.json` says stopped unless the after-rate is
605
+ lower at one-sided p < `alpha`. Returns the report."""
606
+ first = first_injection(root)
607
+ arms = {"before": [0, 0], "after": [0, 0]}
608
+ for row in rows(os.path.join(store_dir(root), "decisions.jsonl")):
609
+ at = row.get("at")
610
+ if row.get("provider") != "typesafe" or not isinstance(at, (int, float)):
611
+ continue
612
+ arm = arms["after" if first is not None and at >= first else "before"]
613
+ arm[0] += 1
614
+ arm[1] += row.get("kind") == "preference"
615
+ (n1, x1), (n2, x2) = arms["before"], arms["after"]
616
+ report = {"first_injection": first, "before": {"turns": n1, "preference": x1},
617
+ "after": {"turns": n2, "preference": x2}, "need": need, "p": None,
618
+ "stopped": stopped(root)}
619
+ if n1 and n2:
620
+ pool = (x1 + x2) / (n1 + n2)
621
+ se = math.sqrt(pool * (1 - pool) * (1 / n1 + 1 / n2)) if 0 < pool < 1 else 0.0
622
+ z = ((x1 / n1) - (x2 / n2)) / se if se else 0.0
623
+ report["p"] = round(0.5 * math.erfc(z / math.sqrt(2)), 4)
624
+ if n1 >= need and n2 >= need:
625
+ report["stopped"] = not (report["p"] is not None and report["p"] < alpha)
626
+ _save(os.path.join(store_dir(root), "gate.json"),
627
+ {"stopped": report["stopped"], "report": report})
628
+ return report
@@ -34,6 +34,7 @@ try:
34
34
  SUBAGENT_CHANNEL, bind_session, changed_files_notice, note_tool, ran_nothing,
35
35
  report_bytes, stop_reason, untrusted_source)
36
36
  from tezgah_paths import HOST_DIRS, off, root_for # noqa: E402
37
+ from tezgah_taste import write_note # noqa: E402
37
38
  from tezgah_untrusted import marks # noqa: E402
38
39
  except Exception as exc:
39
40
  import_failed(exc)
@@ -200,7 +201,11 @@ def main():
200
201
  # carries a deny and nothing else.
201
202
  advice = (safe(session_id, graph_advice, tool, inp, cwd, "codex")
202
203
  if root_for(cwd) else None)
203
- notice = "\n".join(p for p in (notice, advice) if p)
204
+ # the opt-in taste note for the first write to this file type
205
+ # (hooks/tezgah_taste.write_note), on the same channel
206
+ taste = (safe(session_id, write_note, session_id, inp, cwd, tool)
207
+ if root_for(cwd) and not verify_outcome(payload) else None)
208
+ notice = "\n".join(p for p in (notice, advice, taste) if p)
204
209
  if notice:
205
210
  # Codex's PostToolUse output carries `additionalContext` with the
206
211
  # result - the field is part of its own hook output schema
@@ -53,6 +53,7 @@ try:
53
53
  SUBAGENT_CHANNEL, bind_session, note, note_tool, ran_nothing, report_bytes,
54
54
  stop_reason)
55
55
  from tezgah_paths import cache_dir, off # noqa: E402
56
+ from tezgah_taste import write_note # noqa: E402
56
57
  from tezgah_untrusted import marks # noqa: E402
57
58
  except Exception as exc:
58
59
  import_failed(exc)
@@ -323,7 +324,11 @@ def dispatch(payload):
323
324
  # result they belong to, read together rather than one replacing another
324
325
  advice = (graph_advice(gate_name(payload.get("tool_name", "")), inp, cwd,
325
326
  "cursor") if under(cwd) and not quiet else None)
326
- text = "\n".join(t for t in (notice, reinforce, advice) if t)
327
+ # the opt-in taste note for the first write to this file type
328
+ # (hooks/tezgah_taste.write_note)
329
+ taste = (write_note(session_id, inp, cwd, gate_name(payload.get("tool_name", "")))
330
+ if under(cwd) and not call_failed(payload) else None)
331
+ text = "\n".join(t for t in (notice, reinforce, advice, taste) if t)
327
332
  out = {"additional_context": text} if text else {}
328
333
  elif event == "postToolUseFailure":
329
334
  if kind: