@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_task.py
CHANGED
|
@@ -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
|
|
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
|
|
29
|
-
(`acceptance_gap
|
|
30
|
-
|
|
31
|
-
the
|
|
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
|
|
104
|
-
"""
|
|
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() ==
|
|
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
|
|
195
|
-
One field and not two - "checkable" (the item names a
|
|
196
|
-
"unverifiable" (it says so and why) or "missing" (neither) - so no
|
|
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(
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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`.
|
package/hooks/tezgah_taste.py
CHANGED
|
@@ -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
|
|
10
|
-
|
|
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
|
-
#
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
|
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(("
|
|
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
|
|
24
|
+
suspended. The benefit gate's stopped state turns injection off.
|
|
25
25
|
|
|
26
|
-
Store: `<repo>/.tezgah/taste/
|
|
27
|
-
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
|
|
37
|
-
from tezgah_paths import CONFIG_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(
|
|
70
|
-
|
|
71
|
-
|
|
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(
|
|
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
|
-
|
|
97
|
-
|
|
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(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
160
|
-
"""
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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(
|
|
224
|
-
if row.get("
|
|
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
|
|
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
|
-
|
|
432
|
-
|
|
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
|
|
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(
|
|
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(
|
|
493
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
|
533
|
-
|
|
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(
|
|
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,
|
|
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(
|
|
583
|
+
for row in rows(root, "decisions", *jev_rows()):
|
|
609
584
|
at = row.get("at")
|
|
610
|
-
if
|
|
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
|
-
|
|
627
|
-
|
|
601
|
+
with store.taste(store_dir(root)) as db:
|
|
602
|
+
store.set_gate(db, report["stopped"], report)
|
|
628
603
|
return report
|