@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +103 -1
  2. package/MANIFEST +3 -0
  3. package/agents/tezgah-reviewer.md +19 -10
  4. package/bin/tezgah-docs +3 -3
  5. package/bin/tezgah-doctor +75 -18
  6. package/bin/tezgah-route +5 -2
  7. package/bin/tezgah-setup +30 -18
  8. package/bin/tezgah-status +3 -1
  9. package/bin/tezgah-task +130 -37
  10. package/bin/tezgah-taste +74 -55
  11. package/bin/tezgah-triage +12 -7
  12. package/docs/architecture.md +4 -4
  13. package/docs/clarity-baseline.json +9 -9
  14. package/docs/contract.md +22 -12
  15. package/docs/environment.md +4 -1
  16. package/docs/evidence.md +71 -34
  17. package/docs/gate.md +21 -21
  18. package/docs/glossary.md +5 -5
  19. package/docs/hosts.md +8 -8
  20. package/docs/judge.md +93 -27
  21. package/docs/operations.md +69 -30
  22. package/docs/skills.md +2 -2
  23. package/docs/status-line.md +7 -7
  24. package/docs/vision.md +10 -5
  25. package/hooks/tezgah_agents.py +22 -11
  26. package/hooks/tezgah_attest.py +15 -22
  27. package/hooks/tezgah_context.py +84 -124
  28. package/hooks/tezgah_gate.py +26 -23
  29. package/hooks/tezgah_guard.py +9 -11
  30. package/hooks/tezgah_integrity.py +77 -178
  31. package/hooks/tezgah_judge.py +270 -122
  32. package/hooks/tezgah_lessons.py +18 -63
  33. package/hooks/tezgah_paths.py +183 -55
  34. package/hooks/tezgah_policy.py +35 -8
  35. package/hooks/tezgah_replay.py +57 -33
  36. package/hooks/tezgah_shapes.py +4 -4
  37. package/hooks/tezgah_skill_pick.py +6 -35
  38. package/hooks/tezgah_snapshot.py +25 -46
  39. package/hooks/tezgah_store.py +1268 -0
  40. package/hooks/tezgah_task.py +146 -40
  41. package/hooks/tezgah_taste.py +21 -18
  42. package/hooks/tezgah_taste_ledger.py +63 -88
  43. package/hooks/tezgah_update.py +6 -26
  44. package/hosts/codex/hook.py +5 -0
  45. package/hosts/cursor/hook.py +19 -31
  46. package/hosts/opencode/plugins/tezgah.js +309 -108
  47. package/package.json +1 -1
  48. package/skills/harness/SKILL.md +4 -0
  49. package/skills/plan-add/SKILL.md +23 -7
  50. package/skills/plan-add/render_table.py +3 -2
  51. package/skills/plan-status/SKILL.md +12 -4
  52. package/skills/plan-sync/SKILL.md +19 -9
  53. package/skills/tezgah-contract/SKILL.md +25 -12
@@ -21,14 +21,19 @@ before it happens is decided once. This module is on the gate's hot path for
21
21
  every write, so it imports nothing from tezgah_context and shells out to
22
22
  nothing.
23
23
 
24
- A plan's body is read here too, and only here: the acceptance report
24
+ A plan's body is read here too: the acceptance report
25
25
  (`tezgah-render-table --acceptance`) counts the items that name no command, so
26
26
  the record the gate reads and the items the report counts have one reader. Two
27
27
  readers of the body were added beside it and read in the same place - the
28
- `## Acceptance` items a plan cannot enter a writing phase without
29
- (`acceptance_gap`), and the spike a plan declares and has not answered
30
- (`spike_unanswered`) - for the same reason: the CLI refuses the phase move and
31
- the CI report counts the same items, and one module keeps them agreeing.
28
+ `## Acceptance` items a plan cannot enter a writing phase with while any of them
29
+ names no command (`acceptance_gap`, the predicate `--strict` refuses on too),
30
+ and the spike a plan declares and has not answered (`spike_unanswered`) - for
31
+ the same reason: the CLI refuses the phase move and the CI report counts the
32
+ same items, and one module keeps them agreeing. The open plans' progress (the
33
+ ticked boxes), the plans each one waits on (`after:`) and which ready plans can
34
+ run at once (`queue`, `parallel`) are read here for `tezgah-task status`. The
35
+ injected open-plans block keeps its own small reader
36
+ (`hooks/tezgah_context.py::_plan_row`: id, title, the first `## Next` line).
32
37
 
33
38
  The `checkpoint:` key is read here for the same reason again: bin/tezgah-task
34
39
  writes it when the phase moves to `implementation` - the pre-work commit's sha
@@ -100,8 +105,9 @@ def frontmatter(text):
100
105
  return out
101
106
 
102
107
 
103
- def allowed_paths(text):
104
- """The `allowed_paths:` list as a list of globs, [] when absent."""
108
+ def _list_field(text, key):
109
+ """One frontmatter key's `- item` block as a list of strings, [] when the key
110
+ is absent or carries no block."""
105
111
  out = []
106
112
  collecting = False
107
113
  for line in _frontmatter_lines(text):
@@ -110,10 +116,24 @@ def allowed_paths(text):
110
116
  out.append(stripped[2:].strip())
111
117
  continue
112
118
  collecting = (not stripped.startswith("- ") and ":" in stripped
113
- and stripped.partition(":")[0].strip() == "allowed_paths")
119
+ and stripped.partition(":")[0].strip() == key)
114
120
  return out
115
121
 
116
122
 
123
+ def allowed_paths(text):
124
+ """The `allowed_paths:` list as a list of globs, [] when absent."""
125
+ return _list_field(text, "allowed_paths")
126
+
127
+
128
+ def after(text):
129
+ """The plan ids this plan waits on (`after:`), in the order written: the
130
+ `- NNN` block `set_fields` writes, or the inline `after: 012, 015` a person
131
+ types. [] when the plan waits on nothing - which is what lets it run beside
132
+ the others."""
133
+ return (_list_field(text, "after")
134
+ or frontmatter(text).get("after", "").replace(",", " ").split())
135
+
136
+
117
137
  def section_lines(text, heading):
118
138
  """The lines of one `## <heading>` section with their 1-based numbers, []
119
139
  when the file has no such heading: the lines after the heading up to the next
@@ -129,7 +149,7 @@ def section_lines(text, heading):
129
149
  return out
130
150
 
131
151
 
132
- _ITEM = re.compile(r"^\s*- \[[ xX]\]\s*(.*)$")
152
+ _ITEM = re.compile(r"^\s*- \[([ xX])\]\s*(.*)$")
133
153
  # The word `unverifiable`, as a word: the lookahead is a boundary a hyphen and a
134
154
  # letter both fail, so `unverifiable-ness` is not a marker.
135
155
  _MARKER = re.compile(r"unverifiable(?![-\w])", re.I)
@@ -191,10 +211,11 @@ def unverifiable_reason(text):
191
211
 
192
212
  def acceptance_items(text):
193
213
  """One plan file's `## Acceptance` items: a list of
194
- {"line", "text", "state"}, plus "reason" when the state is "unverifiable".
195
- One field and not two - "checkable" (the item names a command),
196
- "unverifiable" (it says so and why) or "missing" (neither) - so no caller can
197
- read two answers about one item.
214
+ {"line", "text", "checked", "state"}, plus "reason" when the state is
215
+ "unverifiable". One field and not two - "checkable" (the item names a
216
+ command), "unverifiable" (it says so and why) or "missing" (neither) - so no
217
+ caller can read two answers about one item. "checked" is the box (`- [x]`),
218
+ which is the plan's progress and says nothing about the item's proof.
198
219
 
199
220
  An item is one `- [ ]`/`- [x]` line plus the lines it wraps onto, up to the
200
221
  next item or a blank line: the format wraps at 79 columns, so an item is more
@@ -204,8 +225,8 @@ def acceptance_items(text):
204
225
  for number, line in section_lines(text, "Acceptance"):
205
226
  found = _ITEM.match(line)
206
227
  if found:
207
- out.append({"line": number, "text": " ".join(found.group(1).split()),
208
- "state": "missing"})
228
+ out.append({"line": number, "text": " ".join(found.group(2).split()),
229
+ "checked": found.group(1) != " ", "state": "missing"})
209
230
  elif out and line.strip():
210
231
  out[-1]["text"] = " ".join((out[-1]["text"] + " " + line).split())
211
232
  for item in out:
@@ -217,19 +238,27 @@ def acceptance_items(text):
217
238
  return out
218
239
 
219
240
 
241
+ def unproven(items):
242
+ """The items that name no command and declare no `unverifiable` - the one
243
+ predicate both the phase move (`acceptance_gap`) and `render_table
244
+ --acceptance --strict` refuse on, so the two cannot let through different
245
+ plans."""
246
+ return [item for item in items if item["state"] == "missing"]
247
+
248
+
220
249
  def acceptance_gap(text):
221
- """The plan's Acceptance items when nothing in them can check the work: every
222
- item the section carries is `missing`, so none names a command and none
223
- declares itself `unverifiable`. [] otherwise, and [] when the plan has no
224
- Acceptance items at all.
250
+ """The plan's Acceptance items that nothing can check: each one names no
251
+ command and declares no `unverifiable`. [] when every item names its proof,
252
+ and [] when the plan has no Acceptance items at all.
225
253
 
226
- A plan with an empty or absent section is a different defect - the format asks
227
- plan-add to write the items, and the render_table report does not gate that
228
- case either - so refusing it here would tax every plan that predates the rule
229
- while the two readers still agreed about every item either of them can see.
230
- Returns the items so a refusal can count them."""
231
- items = acceptance_items(text)
232
- return items if items and all(item["state"] == "missing" for item in items) else []
254
+ Any such item is a gap, not only a section made of them: the format asks for
255
+ the proof per item, and the strict report refuses an open plan on one, so a
256
+ phase move that let one through would be refused later by the reader it was
257
+ meant to answer first. A plan with an empty or absent section is a different
258
+ defect - the format asks plan-add to write the items, and the report does not
259
+ gate that case either - so refusing it here would tax every plan that
260
+ predates the rule. Returns the items so a refusal can name them."""
261
+ return unproven(acceptance_items(text))
233
262
 
234
263
 
235
264
  # The `checkpoint:` values a plan can carry: a commit sha (the tree was clean at
@@ -295,6 +324,30 @@ def spike_unanswered(text):
295
324
 
296
325
 
297
326
  ACCEPTANCE_DIRS = ("open", "done")
327
+ # The `status:` a plan carries in each directory (plan-add's format): a plan
328
+ # under open/ is open or blocked, one under done/ is done or discarded.
329
+ OPEN_STATUSES = ("open", "blocked")
330
+
331
+
332
+ def _plan_files(root, sub):
333
+ """(name, path, text) for every readable `*.md` under
334
+ `<root>/.tezgah/plans/<sub>`, sorted by name; an absent directory or an
335
+ unreadable file is skipped, so a reader built on it never raises."""
336
+ directory = os.path.join(root, ".tezgah", "plans", sub)
337
+ try:
338
+ names = sorted(os.listdir(directory))
339
+ except OSError:
340
+ return
341
+ for name in names:
342
+ if not name.endswith(".md"):
343
+ continue
344
+ path = os.path.join(directory, name)
345
+ try:
346
+ with open(path, "r", encoding="utf-8") as fh:
347
+ text = fh.read()
348
+ except OSError:
349
+ continue
350
+ yield name, path, text
298
351
 
299
352
 
300
353
  def acceptance_report(root):
@@ -310,20 +363,7 @@ def acceptance_report(root):
310
363
  items = []
311
364
  plans = 0
312
365
  for sub in ACCEPTANCE_DIRS:
313
- directory = os.path.join(root, ".tezgah", "plans", sub)
314
- try:
315
- names = sorted(os.listdir(directory))
316
- except OSError:
317
- continue
318
- for name in names:
319
- if not name.endswith(".md"):
320
- continue
321
- path = os.path.join(directory, name)
322
- try:
323
- with open(path, "r", encoding="utf-8") as fh:
324
- text = fh.read()
325
- except OSError:
326
- continue
366
+ for name, _, text in _plan_files(root, sub):
327
367
  plans += 1
328
368
  for item in acceptance_items(text):
329
369
  item["plan"] = ".tezgah/plans/%s/%s" % (sub, name)
@@ -331,6 +371,72 @@ def acceptance_report(root):
331
371
  return {"plans": plans, "items": items}
332
372
 
333
373
 
374
+ def _plan_id(fields, name):
375
+ return fields.get("id") or name.split("-")[0]
376
+
377
+
378
+ def _prefix(glob):
379
+ """The literal head of a glob: everything before its first `*`."""
380
+ return glob.split("*", 1)[0]
381
+
382
+
383
+ def scopes_overlap(first, second):
384
+ """Could two allowlists name one file? An empty list is every path, and two
385
+ globs can only meet when one literal head is a prefix of the other's - so
386
+ `hooks/a*.py` and `hooks/b*.py` never do, and `**/x` meets everything.
387
+ ponytail: prefix test, not glob intersection - it says "overlap" for
388
+ `hooks/*.py` against `hooks/x/y.md`, which only costs a missed pairing."""
389
+ if not first or not second:
390
+ return True
391
+ return any(a.startswith(b) or b.startswith(a)
392
+ for a in map(_prefix, first) for b in map(_prefix, second))
393
+
394
+
395
+ def queue(root):
396
+ """Every open plan as a row: {"id", "status", "path", "checked", "items" (the
397
+ Acceptance boxes ticked, and their count), "after" ([id, state] per plan it
398
+ waits on: `done`, `discarded`, `open` or `missing` when no plan carries the
399
+ id), "ready" (not blocked, and everything it waits on is done), "overlaps"
400
+ (the other open plans whose allowed_paths can name one of its files)}.
401
+
402
+ The plans' progress and order had no reader: the boxes were counted nowhere
403
+ and nothing said which plans could run beside each other. Never raises."""
404
+ state = {}
405
+ for name, _, text in _plan_files(root, "done"):
406
+ fields = frontmatter(text)
407
+ state[_plan_id(fields, name)] = ("discarded" if fields.get("status") == "discarded"
408
+ else "done")
409
+ rows = []
410
+ scopes = {}
411
+ for name, path, text in _plan_files(root, "open"):
412
+ fields = frontmatter(text)
413
+ ident = _plan_id(fields, name)
414
+ state[ident] = "open"
415
+ items = acceptance_items(text)
416
+ scopes[ident] = allowed_paths(text)
417
+ rows.append({"id": ident, "status": fields.get("status", ""), "path": path,
418
+ "checked": sum(1 for item in items if item["checked"]),
419
+ "items": len(items), "after": after(text)})
420
+ for row in rows:
421
+ row["after"] = [[ident, state.get(ident, "missing")] for ident in row["after"]]
422
+ row["ready"] = (row["status"] != "blocked"
423
+ and all(dep == "done" for _, dep in row["after"]))
424
+ row["overlaps"] = [other["id"] for other in rows if other is not row
425
+ and scopes_overlap(scopes[row["id"]], scopes[other["id"]])]
426
+ return rows
427
+
428
+
429
+ def parallel(rows):
430
+ """The ready plans that can run at once, in id order: each one shares no
431
+ allowed_paths prefix with any other picked (greedy, first id first). [] when
432
+ fewer than two qualify - one plan is not a fan-out."""
433
+ picked = []
434
+ for row in rows:
435
+ if row["ready"] and not any(other in row["overlaps"] for other in picked):
436
+ picked.append(row["id"])
437
+ return picked if len(picked) > 1 else []
438
+
439
+
334
440
  def active(cwd, base):
335
441
  """The active task as a dict, or None. Scans `<repo_root>/.tezgah/plans/open/*.md`
336
442
  sorted by name, first file whose frontmatter carries a valid `phase`.
@@ -6,8 +6,8 @@ Opt-in and off by default. A row is written only when the `taste-on` marker is
6
6
  armed (`tezgah_paths.armed`), the repository carries no `.no-taste` mark, its
7
7
  `.tezgah/` already exists and is the user's own (`workspace_from_repo`: a
8
8
  workspace that came with the clone is data, and nothing of the user's is
9
- written into it). The store is `<git root>/.tezgah/taste/signals.jsonl`, one
10
- JSON object per line, owner-only.
9
+ written into it). The store is the `signals` table of
10
+ `<git root>/.tezgah/taste/taste.db` (`tezgah_store.taste`), owner-only.
11
11
 
12
12
  Every public function is total: it never raises and makes no network call.
13
13
  The capture functions append at most one row each. They run on the prompt and
@@ -86,9 +86,10 @@ def _write(root, kind, session_id, host, **fields):
86
86
  .strftime("%Y-%m-%dT%H:%M:%SZ"),
87
87
  "kind": kind, "session": str(session_id or ""), "host": host or _host()}
88
88
  row.update(fields)
89
- # the ledger's own writer: its flock, torn-tail repair and 0700/0600 modes
90
- ti._append(os.path.join(root, ".tezgah", "taste", "signals.jsonl"),
91
- json.dumps(row, ensure_ascii=False) + "\n")
89
+ # imported here, not at the top: the off path never loads sqlite3
90
+ import tezgah_store as store
91
+ with store.taste(os.path.join(root, ".tezgah", "taste")) as db:
92
+ store.append(db, "signals", row)
92
93
 
93
94
 
94
95
  def _host():
@@ -215,8 +216,10 @@ def write_note(session_id, inp, cwd, tool=None):
215
216
  def learn_later(root):
216
217
  """Start `tezgah-taste learn --repo <root> --no-fallback` detached when the
217
218
  signals of `root` (the git root `enabled` returned) grew since the last
218
- start and an hour has passed. Only when TypeSafe resolves: the automatic run
219
- never pays a generative provider for a decision it may not apply. Not on a
219
+ start and an hour has passed; the stamp (a `taste_learn` document of the
220
+ cache database) keeps the newest signal's append number. Only when
221
+ the Jev model resolves through any carrier: the automatic run never pays a
222
+ generative provider for a decision it may not apply. Not on a
220
223
  machine without flock (Windows), where two background runs in two
221
224
  repositories would race on the user ledger. ponytail: no Windows lock; add
222
225
  one (msvcrt.locking) before enabling it there. Total; the stamp is written
@@ -224,27 +227,27 @@ def learn_later(root):
224
227
  try:
225
228
  if not root:
226
229
  return
227
- size = os.path.getsize(os.path.join(root, ".tezgah", "taste", "signals.jsonl"))
230
+ import tezgah_store as store
231
+ with store.taste(os.path.join(root, ".tezgah", "taste"), create=False) as db:
232
+ if db is None:
233
+ return
234
+ count = store.last(db, "signals")
228
235
  base = os.path.join(tezgah_paths.cache_dir(), "taste-learn")
229
236
  name = re.sub(r"[^A-Za-z0-9]+", "-", root).strip("-")
230
- stamp = os.path.join(base, name + ".json")
231
- try:
232
- with open(stamp, encoding="utf-8") as fh:
233
- last = json.load(fh)
234
- except (OSError, ValueError):
235
- last = {}
237
+ last = store.doc("taste_learn", name)
238
+ last = last if isinstance(last, dict) else {}
236
239
  now = time.time()
237
- if size <= last.get("size", -1) or now - last.get("at", 0) < LEARN_EVERY:
240
+ if count <= last.get("rows", 0) or now - last.get("at", 0) < LEARN_EVERY:
238
241
  return
239
242
  # the costlier checks run only once a start is due: at most hourly
240
243
  import tezgah_judge
241
244
  import tezgah_taste_ledger
242
245
  if tezgah_taste_ledger.fcntl is None or tezgah_paths.off("judge-off") \
243
- or not tezgah_judge.named(("typesafe",)):
246
+ or not tezgah_judge.named(("jev",)):
247
+ return
248
+ if not store.put_doc("taste_learn", name, {"rows": count, "at": now}):
244
249
  return
245
250
  os.makedirs(base, exist_ok=True)
246
- with open(stamp, "w", encoding="utf-8") as fh:
247
- json.dump({"size": size, "at": now}, fh)
248
251
  with open(os.path.join(base, name + ".log"), "ab") as log:
249
252
  subprocess.Popen([sys.executable, BIN, "learn", "--repo", root,
250
253
  "--no-fallback"],
@@ -21,20 +21,20 @@ answers and keeps the ledger.
21
21
  - Apply: a rule needs confidence >= `RULE_AT` and a calibration bound >=
22
22
  `BOUND_AT` (Clopper-Pearson on an independently labelled sample); a hint needs
23
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.
24
+ suspended. The benefit gate's stopped state turns injection off.
25
25
 
26
- Store: `<repo>/.tezgah/taste/ledger.json` for path, language and repository
27
- learnings, `~/.config/tezgah/taste/ledger.json` for user learnings.
26
+ Store: `tezgah_store.taste` - `<repo>/.tezgah/taste/taste.db` for path,
27
+ language and repository learnings and every taste row, and
28
+ `~/.config/tezgah/taste/taste.db` for user learnings.
28
29
  """
29
30
  import datetime
30
31
  import errno
31
32
  import fnmatch
32
- import json
33
33
  import math
34
34
  import os
35
35
 
36
- import tezgah_integrity as ti
37
- from tezgah_paths import CONFIG_DIR, cache_dir
36
+ import tezgah_store as store
37
+ from tezgah_paths import CONFIG_DIR
38
38
 
39
39
  try:
40
40
  import fcntl
@@ -66,35 +66,21 @@ def user_dir():
66
66
  return os.path.join(CONFIG_DIR, "taste")
67
67
 
68
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)
69
+ def _load(dirpath, user=False):
70
+ with store.taste(dirpath, user, create=False) as db:
71
+ return store.load_ledger(db) if db else {"v": 1, "learnings": {}, "meta": {}}
87
72
 
88
73
 
89
74
  def load(root):
90
75
  """{"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"))}
76
+ return {"repo": _load(store_dir(root)), "user": _load(user_dir(), user=True)}
93
77
 
94
78
 
95
79
  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"])
80
+ with store.taste(store_dir(root)) as db:
81
+ store.save_ledger(db, ledgers["repo"])
82
+ with store.taste(user_dir(), user=True) as db:
83
+ store.save_ledger(db, ledgers["user"])
98
84
 
99
85
 
100
86
  def every(ledgers):
@@ -142,34 +128,33 @@ def cp_lower(x, n, alpha=0.05):
142
128
  return lo
143
129
 
144
130
 
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
131
+ def rows(root, table, where="", args=()):
132
+ """One taste table's rows in append order (`tezgah_store.TASTE_ROWS`); []
133
+ when the repository has no store yet."""
134
+ with store.taste(store_dir(root), create=False) as db:
135
+ return store.rows(db, table, where, args) if db else []
157
136
 
158
137
 
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)
138
+ def jev_rows():
139
+ """(where, args) for `rows`: the rows a Jev carrier (`tezgah_judge.JEV_CARRIERS`)
140
+ decided, whichever one answered - an old `typesafe` row included. Imported
141
+ here, not at the top: the write hook loads this module and never asks."""
142
+ import tezgah_judge
143
+ carriers = tuple(tezgah_judge.JEV_CARRIERS)
144
+ return "WHERE provider IN (%s)" % ", ".join("?" * len(carriers)), carriers
145
+
146
+
147
+ def append(root, table, row, strict=False):
148
+ """One row into a taste table. A hook drops a row it cannot write; `strict`
149
+ (the CLI's decision, defect and label rows) raises instead, so `learn` stops
150
+ before it saves a ledger whose decision was never recorded and would be
151
+ re-applied."""
152
+ try:
153
+ with store.taste(store_dir(root)) as db:
154
+ store.append(db, table, row)
155
+ except store.ERRORS:
156
+ if strict:
157
+ raise
173
158
 
174
159
 
175
160
  class Busy(Exception):
@@ -217,11 +202,11 @@ class locked:
217
202
  def calibration(root):
218
203
  """(x, n, bound): of the labelled signals the typed decision called a
219
204
  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
205
+ bound on that precision. Only Jev decisions count; the labels are
221
206
  a sample drawn independently of what was injected (`tezgah-taste label`)."""
222
207
  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":
208
+ for row in rows(root, "labels", *jev_rows()):
209
+ if row.get("decided") != "preference":
225
210
  continue
226
211
  n += 1
227
212
  x += row.get("label") == "preference"
@@ -282,7 +267,7 @@ def apply(ledgers, sig, dec, day, applied=()):
282
267
 
283
268
  `dec` is the typed decision: `kind`, `category`, `scope` as (choice,
284
269
  probability), `relations` {learning id: (relation, probability)} and
285
- `verified`, True only when TypeSafe answered - any other provider's decision
270
+ `verified`, True only when the Jev model answered - any other provider's decision
286
271
  is never applied. `applied` are the ids injected in the signal's session."""
287
272
  if not dec.get("verified"):
288
273
  return "unverified"
@@ -428,11 +413,8 @@ def suspended(learning, root):
428
413
 
429
414
  def stopped(root):
430
415
  """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
416
+ with store.taste(store_dir(root), create=False) as db:
417
+ return bool(db) and store.gate_stopped(db)
436
418
 
437
419
 
438
420
  def usable(root, day=None):
@@ -478,10 +460,10 @@ def block(root, day=None):
478
460
 
479
461
 
480
462
  def record_injection(root, session_id, ids, day=None):
481
- """One `injected.jsonl` row: the meta loop counts a later correction in this
463
+ """One `injected` row: the meta loop counts a later correction in this
482
464
  session against these ids, and the benefit gate splits its arms at the first."""
483
465
  if ids:
484
- append(os.path.join(store_dir(root), "injected.jsonl"),
466
+ append(root, "injected",
485
467
  {"session": str(session_id or ""), "day": now_day() if day is None else day,
486
468
  "ids": list(ids)})
487
469
 
@@ -489,9 +471,8 @@ def record_injection(root, session_id, ids, day=None):
489
471
  def injected(root, session_id):
490
472
  """The learning ids injected in one session."""
491
473
  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 ())
474
+ for row in rows(root, "injected", "WHERE session = ?", (session_id,)):
475
+ ids.update(row.get("ids") or ())
495
476
  return ids
496
477
 
497
478
 
@@ -502,24 +483,18 @@ def write_note(root, session_id, rel_path):
502
483
  if not session_id or not rel_path or stopped(root):
503
484
  return ""
504
485
  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()
486
+ session_id = str(session_id)
487
+ with store.taste(store_dir(root), create=False) as db:
488
+ seen = store.seen(db, session_id) if db else set()
512
489
  hits = [(mode, learning) for mode, learning in usable(root)
513
490
  if learning["scope"] in ("path", "language") and learning["id"] not in seen
514
491
  and fnmatch.fnmatch(rel_path, learning["where"])]
515
492
  if not hits:
516
493
  return ""
517
- seen.update(m[1]["id"] for m in hits)
518
494
  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:
495
+ with store.taste(store_dir(root)) as db:
496
+ store.mark_seen(db, session_id, [m[1]["id"] for m in hits])
497
+ except store.ERRORS:
523
498
  return ""
524
499
  record_injection(root, session_id, [m[1]["id"] for m in hits])
525
500
  return ("Taste for %s files (narrower scope wins):\n" % kind
@@ -529,8 +504,8 @@ def write_note(root, session_id, rel_path):
529
504
  # --- markdown and export ----------------------------------------------------------------
530
505
 
531
506
  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."""
507
+ """Rewrite `<store>/<category>/taste.md` from the active learnings; the
508
+ database stays the source of truth. Returns the files written."""
534
509
  day = now_day() if day is None else day
535
510
  by_cat = {}
536
511
  for learning in every(load(root)).values():
@@ -590,7 +565,7 @@ def export_agents(root):
590
565
 
591
566
  def first_injection(root):
592
567
  """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"))
568
+ days = [r["day"] for r in rows(root, "injected")
594
569
  if isinstance(r.get("day"), (int, float))]
595
570
  return min(days) if days else None
596
571
 
@@ -601,13 +576,13 @@ def gate(root, need=980, alpha=0.1):
601
576
  Every decided signal is one prompt after a writing turn. The arms split at
602
577
  the first injection, by when the turn happened (`at`), not by when `learn`
603
578
  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
579
+ arms hold `need` turns, the gate's state says stopped unless the after-rate is
605
580
  lower at one-sided p < `alpha`. Returns the report."""
606
581
  first = first_injection(root)
607
582
  arms = {"before": [0, 0], "after": [0, 0]}
608
- for row in rows(os.path.join(store_dir(root), "decisions.jsonl")):
583
+ for row in rows(root, "decisions", *jev_rows()):
609
584
  at = row.get("at")
610
- if row.get("provider") != "typesafe" or not isinstance(at, (int, float)):
585
+ if not isinstance(at, (int, float)):
611
586
  continue
612
587
  arm = arms["after" if first is not None and at >= first else "before"]
613
588
  arm[0] += 1
@@ -623,6 +598,6 @@ def gate(root, need=980, alpha=0.1):
623
598
  report["p"] = round(0.5 * math.erfc(z / math.sqrt(2)), 4)
624
599
  if n1 >= need and n2 >= need:
625
600
  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})
601
+ with store.taste(store_dir(root)) as db:
602
+ store.set_gate(db, report["stopped"], report)
628
603
  return report