@r1z4x/tezgah 1.2.0 → 1.4.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 +44 -1
- package/MANIFEST +3 -0
- package/bin/tezgah-doctor +75 -18
- package/bin/tezgah-setup +6 -17
- package/bin/tezgah-taste +38 -32
- package/docs/architecture.md +4 -4
- package/docs/clarity-baseline.json +4 -4
- package/docs/contract.md +1 -1
- package/docs/environment.md +1 -0
- package/docs/evidence.md +67 -31
- package/docs/gate.md +21 -21
- package/docs/glossary.md +4 -4
- package/docs/hosts.md +8 -8
- package/docs/judge.md +10 -5
- package/docs/operations.md +53 -17
- package/docs/skills.md +1 -1
- package/docs/status-line.md +7 -7
- package/hooks/tezgah_attest.py +15 -22
- package/hooks/tezgah_context.py +52 -118
- 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 +25 -43
- package/hooks/tezgah_lessons.py +18 -63
- package/hooks/tezgah_paths.py +18 -28
- package/hooks/tezgah_replay.py +31 -18
- 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_taste.py +59 -6
- package/hooks/tezgah_taste_ledger.py +52 -86
- package/hooks/tezgah_update.py +6 -26
- package/hosts/cursor/hook.py +19 -31
- package/hosts/opencode/plugins/tezgah.js +309 -108
- package/package.json +1 -1
package/docs/operations.md
CHANGED
|
@@ -202,8 +202,8 @@ fetch, the flip and the re-arm and runs none of the three
|
|
|
202
202
|
|
|
203
203
|
The status line says when a newer release is out. A `↑X.Y.Z` chip sits beside
|
|
204
204
|
the logo, in the version's own group, on every surface (`notice_segments()`,
|
|
205
|
-
`hooks/tezgah_update.py`). A redraw only reads
|
|
206
|
-
When that
|
|
205
|
+
`hooks/tezgah_update.py`). A redraw only reads the `update_check` row of
|
|
206
|
+
`~/.cache/tezgah/tezgah.db`. When that row is a day old, the redraw stamps it and starts one detached
|
|
207
207
|
check against the GitHub releases endpoint. An offline check writes nothing,
|
|
208
208
|
and the next day asks again. `tezgah update` is the command the chip points
|
|
209
209
|
at, and it is the only thing that moves the install. The
|
|
@@ -225,7 +225,7 @@ After a change to the contract text (`hooks/tezgah_policy.py`,
|
|
|
225
225
|
- `--refresh` does that without a reinstall, for every armed host's static
|
|
226
226
|
block and not only opencode's (`refresh_contract`). opencode has no
|
|
227
227
|
session-start hook, so its plugin runs it once per session
|
|
228
|
-
(`hosts/opencode/plugins/tezgah.js:
|
|
228
|
+
(`hosts/opencode/plugins/tezgah.js:2666-2670`). Until 2026-10-02 it
|
|
229
229
|
re-rendered opencode's files alone and reset the one shared hash, so a rule
|
|
230
230
|
edit left the other static files stale while `--report` went green (audit
|
|
231
231
|
M-5).
|
|
@@ -370,7 +370,8 @@ does not hold.
|
|
|
370
370
|
| Invocation | What it does |
|
|
371
371
|
|---|---|
|
|
372
372
|
| `tezgah-doctor` | nothing: sizes, session and event counts, whether opencode is running, the two context-hygiene settings, the `.codegraph` bytes and index presence per repository, and the three host state dirs (`bin/tezgah-doctor::collect`, `bin/tezgah-doctor::report`) |
|
|
373
|
-
| `tezgah-doctor --clean` | vacuums opencode's database, and only when opencode is not running (`bin/tezgah-doctor::vacuum_db`, `bin/tezgah-doctor::main`);
|
|
373
|
+
| `tezgah-doctor --clean` | vacuums opencode's database, and only when opencode is not running (`bin/tezgah-doctor::vacuum_db`, `bin/tezgah-doctor::main`); deletes every evidence ledger whose newest row is older than `--retention-days` (default 30), rows, import offsets and old JSONL file together (`hooks/tezgah_store.py::sweep_evidence`), and the turn stamps and used-tool marks last written before then (`hooks/tezgah_store.py::sweep_cache`); and deletes the files not modified for that long - `classify.log`, `context-drops.log`, `debug.log` and the `turns/` and `sessions/` files a running older release still writes - in the cache and its sandbox fallback, keeping the current session's (`$TEZGAH_SESSION`) ledger, stamp and marks whatever their age (`sweep_state`) |
|
|
374
|
+
| `tezgah-doctor --import-evidence` | imports every old `evidence/*.jsonl` file into the cache's `tezgah.db` now, in the cache and its sandbox fallback (`bin/tezgah-doctor::import_evidence`); session start runs the same import detached at most once a day (`hooks/tezgah_store.py::import_later`) |
|
|
374
375
|
| `tezgah-doctor --coverage` | every tracked file the codegraph index does not hold, in two classes — a supported extension the index is missing, and a shebang-only script with no `.py` twin — with the file counts it read, so an empty result cannot read as "everything is covered" |
|
|
375
376
|
| `tezgah-doctor --stack` | per host: the context files in load order, the skill dirs and which copy wins, the hook rows per event with their owner, the MCP servers, and every conflict; exits 1 on one ([hosts](hosts.md#precedence-what-each-host-loads-and-in-which-order), `hooks/tezgah_stack.py::report`) |
|
|
376
377
|
| `tezgah-doctor --prune-sessions DAYS` | deletes sessions idle longer than DAYS through `opencode session delete`, then vacuums; skipped when opencode is running or its CLI is missing (`bin/tezgah-doctor::prune_sessions`, `bin/tezgah-doctor::main`) |
|
|
@@ -398,7 +399,7 @@ writes only into a repository that already has a `.tezgah/` directory. A
|
|
|
398
399
|
(`hooks/tezgah_context.py::repo_marks`). A workspace of repo-provided data gets
|
|
399
400
|
nothing.
|
|
400
401
|
|
|
401
|
-
Rows go to `<repo>/.tezgah/taste/
|
|
402
|
+
Rows go to the `signals` table of `<repo>/.tezgah/taste/taste.db`
|
|
402
403
|
(`_write`, `hooks/tezgah_taste.py::_write`). A user turn adds a `prompt` row,
|
|
403
404
|
redacted and cut to 2000 characters (`hooks/tezgah_taste.py::note_prompt`).
|
|
404
405
|
A landed write adds an `edit` row with its redacted old and new text, cut to
|
|
@@ -412,7 +413,7 @@ ledger row. A rollback therefore never reads it as a pre-state (`capture_after`,
|
|
|
412
413
|
`hooks/tezgah_snapshot.py::capture_after`). A file outside the repository gets no
|
|
413
414
|
after blob.
|
|
414
415
|
opencode reaches the same call through a process (`noteTaste`,
|
|
415
|
-
`hosts/opencode/plugins/tezgah.js:
|
|
416
|
+
`hosts/opencode/plugins/tezgah.js:2413-2423`).
|
|
416
417
|
|
|
417
418
|
```sh
|
|
418
419
|
touch ~/.config/tezgah/taste-on # arm; rm it to disarm
|
|
@@ -438,13 +439,21 @@ write's pre-state, or with the file on disk (`user_diff`,
|
|
|
438
439
|
signal (`decide`, `bin/tezgah-taste::decide`, [judge](judge.md)). The decision
|
|
439
440
|
names preference, defect or none. It picks one of eleven fixed categories and a
|
|
440
441
|
scope. It also says how the signal relates to each learning held: supports,
|
|
441
|
-
contradicts, narrows or unrelated. When another provider answers, `learn`
|
|
442
|
-
the decision
|
|
443
|
-
goes to `defects
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
(`hooks/
|
|
442
|
+
contradicts, narrows or unrelated. When another provider answers, `learn` records
|
|
443
|
+
the decision in the `decisions` table as `unverified` and never applies it. A
|
|
444
|
+
defect goes to the `defects` table and never into the ledger.
|
|
445
|
+
|
|
446
|
+
Armed taste also learns by itself. At session start, `learn` starts in the
|
|
447
|
+
background when new signals arrived since the last start. It starts at most
|
|
448
|
+
once an hour per repository (`learn_later`, `hooks/tezgah_taste.py::learn_later`). It runs
|
|
449
|
+
with `--no-fallback` and only when a TypeSafe key resolves, so it never pays a
|
|
450
|
+
generative provider for a decision it could not apply. It does not run on
|
|
451
|
+
Windows, which has no ledger lock yet. The stamp is a `taste_learn` row of the
|
|
452
|
+
cache's `tezgah.db`. The log lives under the cache dir, in `taste-learn/`.
|
|
453
|
+
|
|
454
|
+
The repository's learnings live in the same `taste.db`. User-scope learnings live in
|
|
455
|
+
`~/.config/tezgah/taste/taste.db` (`hooks/tezgah_taste_ledger.py`). Confidence is
|
|
456
|
+
(r+1)/(r+s+2) over supporting
|
|
448
457
|
and contradicting evidence. Each piece counts at its decision probability, and
|
|
449
458
|
the ledger forgets it per day at its scope's rate (`confidence`,
|
|
450
459
|
`hooks/tezgah_taste_ledger.py::confidence`). A learning turns active in its
|
|
@@ -457,8 +466,34 @@ ledgers. `learn`, `accept`, `reject` and `edit` rewrite them one at a time on
|
|
|
457
466
|
this machine, under a lock on `~/.config/tezgah/taste/ledger.lock` (`locked`,
|
|
458
467
|
`hooks/tezgah_taste_ledger.py::locked`). The lock spans every repository,
|
|
459
468
|
because each command also rewrites the user ledger. A second one exits 2 and
|
|
460
|
-
names the holder.
|
|
461
|
-
|
|
469
|
+
names the holder.
|
|
470
|
+
|
|
471
|
+
One module owns both databases (`taste`, `hooks/tezgah_store.py::taste`). Each
|
|
472
|
+
runs SQLite in WAL mode with a 5-second busy timeout. The directory is 0700 and
|
|
473
|
+
the database file 0600. A row keeps its JSON fields as before. A hook drops a
|
|
474
|
+
row it cannot write, and the CLI's decision, defect and label rows raise
|
|
475
|
+
instead (`append`, `hooks/tezgah_taste_ledger.py::append`). An open of a
|
|
476
|
+
database imports the files it replaces (`_import`,
|
|
477
|
+
`hooks/tezgah_store.py::_import`). `ledger.json` and `gate.json` import only
|
|
478
|
+
into empty tables. The `.jsonl` row files (`signals`, `decisions`, `defects`,
|
|
479
|
+
`labels`, `injected`) import on every open that finds one, so rows an older
|
|
480
|
+
install wrote later still land. The import holds the old writer's flock on the
|
|
481
|
+
file. The `imported` table records how many bytes of each file went in, in the
|
|
482
|
+
same transaction as the rows. The next open therefore takes only the bytes a
|
|
483
|
+
writer added since, and a torn last line waits for its newline. After the commit
|
|
484
|
+
the import renames a fully read file to `<name>.imported`, or `.imported.N`
|
|
485
|
+
beside an earlier one, and never deletes one. A process killed before that
|
|
486
|
+
rename imports nothing twice. A file that does not read or parse stays in
|
|
487
|
+
place. The import retries it only once its size or mtime changes. The old
|
|
488
|
+
per-session `taste-notes/` files under the cache dir are not carried over: they
|
|
489
|
+
never named their repository. A session open during the upgrade can see one
|
|
490
|
+
write note again.
|
|
491
|
+
|
|
492
|
+
Every command that opens the store exits 2 when the repository's `.tezgah`
|
|
493
|
+
came with the clone (`store_or_exit`, `bin/tezgah-taste::store_or_exit`). It
|
|
494
|
+
then reads, imports and creates nothing there. The refusal also applies to a
|
|
495
|
+
repository whose `.tezgah` is a symlink or whose git index cannot be read. Run
|
|
496
|
+
the command from another checkout to manage the user-scope learnings.
|
|
462
497
|
|
|
463
498
|
Session start injects the active learnings (`block`,
|
|
464
499
|
`hooks/tezgah_taste_ledger.py::block`). A rule needs confidence 0.8 and a
|
|
@@ -467,11 +502,12 @@ labelled sample (`calibration`, `hooks/tezgah_taste_ledger.py::calibration`). A
|
|
|
467
502
|
hint needs 0.6. A learning whose evidence paths are all gone is withheld. A
|
|
468
503
|
write also carries the in-scope learnings it has not yet shown in the session,
|
|
469
504
|
on every host's post-tool channel (`write_note`, `hooks/tezgah_taste.py::write_note`).
|
|
505
|
+
The repository's database keeps the learnings each session has already been shown.
|
|
470
506
|
`gate` compares preference corrections per writing turn before and after the
|
|
471
507
|
first injection, by when each turn happened. Once both arms hold 980 turns and
|
|
472
508
|
the after-rate is not lower
|
|
473
|
-
at one-sided p < 0.1,
|
|
474
|
-
`hooks/tezgah_taste_ledger.py::gate`). `mine` reads omp's top-level session
|
|
509
|
+
at one-sided p < 0.1, injection stops (`gate`,
|
|
510
|
+
`hooks/tezgah_taste_ledger.py::gate`). The database keeps that stop. `mine` reads omp's top-level session
|
|
475
511
|
files and Claude's top-level transcripts whose cwd is under `--root`
|
|
476
512
|
(`cmd_mine`, `bin/tezgah-taste::cmd_mine`). `measure` and `rate` use a fixed
|
|
477
513
|
labelling rule (`RULE`, `bin/tezgah-taste::RULE`).
|
package/docs/skills.md
CHANGED
|
@@ -32,7 +32,7 @@ skill is simply not read.
|
|
|
32
32
|
|
|
33
33
|
opencode's plugin does inject prompt-time text - the builder's per-turn block,
|
|
34
34
|
the conditional rules included
|
|
35
|
-
(`hosts/opencode/plugins/tezgah.js:
|
|
35
|
+
(`hosts/opencode/plugins/tezgah.js:2629-2658`) - but that text names at most one
|
|
36
36
|
skill (the hint), never the roster, so the installer writes one:
|
|
37
37
|
`~/.config/tezgah/opencode-skills.md` always-on and
|
|
38
38
|
`opencode-skills.full.md` on demand (`bin/tezgah-setup::SKILL_ROUTER`, `bin/tezgah-setup::SKILL_ROUTER_FULL`), the first
|
package/docs/status-line.md
CHANGED
|
@@ -96,9 +96,9 @@ measure the surface cannot see, then armed-and-used or armed-not-used.
|
|
|
96
96
|
| `graph` | a code-graph tool call (an `mcp__…codegraph…` tool) | `.no-graph` — a missing binary is `idx`'s report, not this mark's |
|
|
97
97
|
| `orch` | a delegate call (`Task`/`Agent`/`spawn_agent`) or a subagent start | `orchestrate-off` |
|
|
98
98
|
| `judge` | a shell command really ran `tezgah-triage` or `tezgah-docs` | `judge-off`, or no credential: no `TYPESAFE_API_KEY` and no non-empty `~/.config/typesafe/key`, and neither the fallback's `OPENROUTER_API_KEY` nor its `~/.config/openrouter/key` |
|
|
99
|
-
| `gate` | never `on`: shown only as `gate✗`, right after the version, in two cases. One: the prompt hook found at least 3 gated calls in this session's transcript since its first turn: write and shell calls, every one of which the gate answers with a `began` or a `deny` row (Bash/PowerShell/Edit/Write/MultiEdit/NotebookEdit on Claude, `exec_command`/`shell`/`apply_patch` on Codex). Over the same span the ledger holds no row from the gate. A PostToolUse row is no proof: that hook keeps writing while a broken PreToolUse lets every call through. The same prompt carries the line "tezgah gate inactive on this host: ...". Two: a kill switch appeared after the session's first prompt; the prompt hook writes one `disarm` row naming it (`disarmed`, baseline `<cache>/
|
|
100
|
-
| `drift` | never `on`: shown as `drift✗` when a session start found tezgah's own hook entries changed since install (`hooks/tezgah_attest.py::run`). One mark per session and host, `<cache>/
|
|
101
|
-
| `crash` | never `on`: shown as `crash✗` when a hook of this session could not import its core and ran without tezgah (`hooks/tezgah_guard.py::import_failed`, mark `~/.cache/tezgah/
|
|
99
|
+
| `gate` | never `on`: shown only as `gate✗`, right after the version, in two cases. One: the prompt hook found at least 3 gated calls in this session's transcript since its first turn: write and shell calls, every one of which the gate answers with a `began` or a `deny` row (Bash/PowerShell/Edit/Write/MultiEdit/NotebookEdit on Claude, `exec_command`/`shell`/`apply_patch` on Codex). Over the same span the ledger holds no row from the gate. A PostToolUse row is no proof: that hook keeps writing while a broken PreToolUse lets every call through. The same prompt carries the line "tezgah gate inactive on this host: ...". Two: a kill switch appeared after the session's first prompt; the prompt hook writes one `disarm` row naming it (`disarmed`, baseline a `switches` row of `<cache>/tezgah.db`). The mark is a `gate_inactive` row of the same database (`gate_inactive`, audit Phase 1.3) | a row from the gate appears and no switch armed mid-session is still armed |
|
|
100
|
+
| `drift` | never `on`: shown as `drift✗` when a session start found tezgah's own hook entries changed since install (`hooks/tezgah_attest.py::run`). One mark per session and host, a `harness_drift` row of `<cache>/tezgah.db` keyed `<session hash>.<host>`; the session's `attest` row names what drifted | the next clean start of that host in that session |
|
|
101
|
+
| `crash` | never `on`: shown as `crash✗` when a hook of this session could not import its core and ran without tezgah (`hooks/tezgah_guard.py::import_failed`, mark an `import_crash` row of `~/.cache/tezgah/tezgah.db` keyed `<session hash>`). When `tezgah_context` itself is the module that failed, `statusline.py` prints `tezgah ✗ core import failed` instead of the line | never by itself: the mark stays for the session |
|
|
102
102
|
|
|
103
103
|
Read `on` for `consult`/`research`/`judge` as "installed and usable", not "you
|
|
104
104
|
must use it": a missing key or binary reads the same red as a kill switch
|
|
@@ -147,9 +147,9 @@ have written: a mark the literal omits reads `info` there rather than a claim.
|
|
|
147
147
|
|
|
148
148
|
## The used-kinds channel
|
|
149
149
|
|
|
150
|
-
A used measure is one
|
|
151
|
-
`<cache_dir>/sessions/<slug(session_id)>.jsonl`, one `{"kind": …}` per line
|
|
150
|
+
A used measure is one row of the session store, keyed by `slug(session_id)` and kind
|
|
152
151
|
(`record()`, `hooks/tezgah_context.py::record`; `used()` reads it back, `hooks/tezgah_context.py::used`).
|
|
152
|
+
The store is the `used` table of `<cache_dir>/tezgah.db`.
|
|
153
153
|
`cache_dir()` is `~/.cache/tezgah` with a temp fallback for sandboxed hosts
|
|
154
154
|
(`hooks/tezgah_paths.py::CACHE`, `hooks/tezgah_paths.py::fallback_cache`, `hooks/tezgah_paths.py::cache_dir`); a missing kind is not an event, so only
|
|
155
155
|
the five kinds are ever written (`hooks/tezgah_context.py::record`).
|
|
@@ -160,8 +160,8 @@ the command really ran it (34-38,84) and is the one writer that can earn `judge`
|
|
|
160
160
|
(a run of `bin/tezgah-triage` or `bin/tezgah-docs`, `shell_kind`, `hooks/tezgah_context.py::shell_kind`)
|
|
161
161
|
— plus `hooks/projects-auto-init.py::main`
|
|
162
162
|
(`orch` on subagent start), `hosts/codex/hook.py::main`,
|
|
163
|
-
`hosts/cursor/hook.py::dispatch`, hosts/opencode/plugins/tezgah.js:
|
|
164
|
-
hosts/opencode/plugins/tezgah.js:
|
|
163
|
+
`hosts/cursor/hook.py::dispatch`, hosts/opencode/plugins/tezgah.js:2144-2151,
|
|
164
|
+
hosts/opencode/plugins/tezgah.js:2702, `hosts/omp/hook.py::handle`. Both Claude (hooks/hooks.json) and dsh
|
|
165
165
|
(hosts/dsh/hooks.json) wire PostToolUse to that one shared hook, which is how dsh
|
|
166
166
|
gets any used mark at all: with no local transcript, the store is the only channel
|
|
167
167
|
its line has (hooks/projects-posttooluse.py:33-37).
|
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,
|
|
@@ -1055,32 +1056,18 @@ def state_stamp(root):
|
|
|
1055
1056
|
"lessons": _lessons_state(root)}
|
|
1056
1057
|
|
|
1057
1058
|
|
|
1058
|
-
def _stamp_path(session_id):
|
|
1059
|
-
return os.path.join(cache_dir(), "turns", slug(str(session_id)) + ".json")
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
1059
|
def read_stamp(session_id):
|
|
1063
1060
|
"""The stamp this session's previous turn wrote, or None when there is none."""
|
|
1064
1061
|
if not session_id:
|
|
1065
1062
|
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
|
|
1063
|
+
got = tezgah_store.doc("turns", slug(str(session_id)))
|
|
1071
1064
|
return got if isinstance(got, dict) and got.get("root") else None
|
|
1072
1065
|
|
|
1073
1066
|
|
|
1074
1067
|
def write_stamp(session_id, root, stamp):
|
|
1075
1068
|
"""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
|
|
1069
|
+
if session_id:
|
|
1070
|
+
tezgah_store.put_doc("turns", slug(str(session_id)), dict(stamp, root=root))
|
|
1084
1071
|
|
|
1085
1072
|
|
|
1086
1073
|
def forget_seen(session_id):
|
|
@@ -1627,10 +1614,11 @@ POST_TOOL_ROWS = frozenset(k.encode() for k in STEP_KINDS + ("external", "unknow
|
|
|
1627
1614
|
|
|
1628
1615
|
|
|
1629
1616
|
def _ledger_lines(path):
|
|
1617
|
+
"""The ledger's rows at `path` as their JSON bytes, oldest first."""
|
|
1630
1618
|
try:
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
except
|
|
1619
|
+
return [text.encode("utf-8", "replace")
|
|
1620
|
+
for text in tezgah_store.evidence_rows(path)]
|
|
1621
|
+
except tezgah_store.ERRORS:
|
|
1634
1622
|
return []
|
|
1635
1623
|
|
|
1636
1624
|
|
|
@@ -1672,12 +1660,10 @@ def _ledger_since(session_id):
|
|
|
1672
1660
|
return None, False
|
|
1673
1661
|
|
|
1674
1662
|
|
|
1675
|
-
def
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
def _switch_baseline(session_id):
|
|
1680
|
-
return os.path.join(cache_dir(), "switches", slug(str(session_id)) + ".json")
|
|
1663
|
+
def gate_marked(session_id):
|
|
1664
|
+
"""True when the prompt hook left the disarmed-gate mark for this session
|
|
1665
|
+
(`gate_inactive`)."""
|
|
1666
|
+
return tezgah_store.marked("gate_inactive", slug(str(session_id)))
|
|
1681
1667
|
|
|
1682
1668
|
|
|
1683
1669
|
def disarmed(session_id):
|
|
@@ -1694,24 +1680,17 @@ def disarmed(session_id):
|
|
|
1694
1680
|
# the files alone (`armed`, unlatched): a switch the latch ignores is still
|
|
1695
1681
|
# the tamper shape this records
|
|
1696
1682
|
now = sorted(n for n in SWITCHES if armed(n))
|
|
1697
|
-
|
|
1683
|
+
state = tezgah_store.doc("switches", slug(str(session_id)))
|
|
1698
1684
|
try:
|
|
1699
|
-
with open(path, encoding="utf-8") as fh:
|
|
1700
|
-
state = json.load(fh)
|
|
1701
1685
|
start, seen = list(state["start"]), list(state["seen"])
|
|
1702
|
-
except (
|
|
1686
|
+
except (KeyError, TypeError):
|
|
1703
1687
|
start, seen = now, []
|
|
1704
1688
|
moved = [n for n in now if n not in start]
|
|
1705
1689
|
for name in moved:
|
|
1706
1690
|
if name not in seen:
|
|
1707
1691
|
note(session_id, "disarm", name)
|
|
1708
1692
|
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
|
|
1693
|
+
tezgah_store.put_doc("switches", slug(str(session_id)), {"start": start, "seen": seen})
|
|
1715
1694
|
return moved
|
|
1716
1695
|
|
|
1717
1696
|
|
|
@@ -1730,14 +1709,13 @@ def gate_inactive(session_id, payload):
|
|
|
1730
1709
|
calls = 0 if since is None or rows else _transcript_calls(
|
|
1731
1710
|
p.get("transcript_path"), since)
|
|
1732
1711
|
inactive = calls >= GATE_MIN_CALLS
|
|
1733
|
-
|
|
1712
|
+
key = slug(str(session_id))
|
|
1734
1713
|
try:
|
|
1735
1714
|
if inactive or moved:
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
except OSError:
|
|
1715
|
+
tezgah_store.mark("gate_inactive", key)
|
|
1716
|
+
else:
|
|
1717
|
+
tezgah_store.drop("gate_inactive", key)
|
|
1718
|
+
except tezgah_store.ERRORS:
|
|
1741
1719
|
pass
|
|
1742
1720
|
return GATE_INACTIVE % calls if inactive else ""
|
|
1743
1721
|
|
|
@@ -2202,6 +2180,18 @@ def context_for(event, cwd, payload=None, with_core=True):
|
|
|
2202
2180
|
taste = ""
|
|
2203
2181
|
if taste:
|
|
2204
2182
|
parts.append(("taste", taste))
|
|
2183
|
+
# Armed taste learns by itself: a detached `learn` when the signals grew,
|
|
2184
|
+
# at most hourly (tezgah_taste.learn_later); session start only, with the
|
|
2185
|
+
# root `enabled` already resolved above.
|
|
2186
|
+
if taste_root and event == "session_start":
|
|
2187
|
+
try:
|
|
2188
|
+
tezgah_taste.learn_later(taste_root)
|
|
2189
|
+
except Exception:
|
|
2190
|
+
pass
|
|
2191
|
+
# The legacy JSONL ledgers into the evidence database: a detached
|
|
2192
|
+
# bulk import at most daily (tezgah_store.import_later).
|
|
2193
|
+
if event == "session_start":
|
|
2194
|
+
tezgah_store.import_later(cache_dir())
|
|
2205
2195
|
broken = tezgah_research.failing(root) if not off("research-off") else []
|
|
2206
2196
|
if broken:
|
|
2207
2197
|
line_slug, err = broken[0]
|
|
@@ -2400,62 +2390,22 @@ def record(session_id, kind):
|
|
|
2400
2390
|
if kind == "orch":
|
|
2401
2391
|
note(session_id, "orch")
|
|
2402
2392
|
try:
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
with open(os.path.join(d, slug(session_id) + ".jsonl"), "a", encoding="utf-8") as fh:
|
|
2406
|
-
fh.write(json.dumps({"kind": kind}) + "\n")
|
|
2407
|
-
except OSError:
|
|
2393
|
+
tezgah_store.use(slug(session_id), kind)
|
|
2394
|
+
except tezgah_store.ERRORS:
|
|
2408
2395
|
pass
|
|
2409
2396
|
|
|
2410
2397
|
|
|
2411
|
-
# Past this size `used()` compacts the store to one row per kind. Every reader
|
|
2412
|
-
# of the store takes it as a set, so nothing is lost, and the next read is a
|
|
2413
|
-
# few hundred bytes however long the session ran.
|
|
2414
|
-
USED_MAX_BYTES = 65536
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
2398
|
def used(session_id):
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
out = set()
|
|
2421
|
-
path = os.path.join(cache_dir(), "sessions", slug(session_id) + ".jsonl")
|
|
2422
|
-
try:
|
|
2423
|
-
with open(path, encoding="utf-8") as fh:
|
|
2424
|
-
size = os.fstat(fh.fileno()).st_size
|
|
2425
|
-
for line in fh:
|
|
2426
|
-
try:
|
|
2427
|
-
out.add(json.loads(line)["kind"])
|
|
2428
|
-
except (ValueError, KeyError):
|
|
2429
|
-
pass
|
|
2430
|
-
# a row appended since the open changes the size: skip, never lose it
|
|
2431
|
-
if size > USED_MAX_BYTES and os.path.getsize(path) == size:
|
|
2432
|
-
tmp = path + ".tmp"
|
|
2433
|
-
with open(tmp, "w", encoding="utf-8") as fh:
|
|
2434
|
-
fh.writelines(json.dumps({"kind": k}) + "\n"
|
|
2435
|
-
for k in sorted(k for k in out
|
|
2436
|
-
if isinstance(k, str)))
|
|
2437
|
-
os.replace(tmp, path)
|
|
2438
|
-
except OSError:
|
|
2439
|
-
pass
|
|
2440
|
-
return out
|
|
2399
|
+
"""The kinds this session recorded, a set (one `used` row per kind)."""
|
|
2400
|
+
return tezgah_store.used(slug(session_id)) if session_id else set()
|
|
2441
2401
|
|
|
2442
2402
|
|
|
2443
|
-
# How many of the
|
|
2444
|
-
#
|
|
2445
|
-
#
|
|
2446
|
-
# store is its last event.
|
|
2403
|
+
# How many of the sessions that recorded last a fitness report reads, by the
|
|
2404
|
+
# time each last recorded anything, which for a used-kind store is its last
|
|
2405
|
+
# event.
|
|
2447
2406
|
FITNESS_WINDOW = 200
|
|
2448
2407
|
|
|
2449
2408
|
|
|
2450
|
-
def _mtime(path):
|
|
2451
|
-
"""A file's mtime, or 0 when it is gone: the window is a sort key, and a
|
|
2452
|
-
session that vanished mid-report must not crash the report."""
|
|
2453
|
-
try:
|
|
2454
|
-
return os.path.getmtime(path)
|
|
2455
|
-
except OSError:
|
|
2456
|
-
return 0.0
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
2409
|
def skill_fitness(window=FITNESS_WINDOW):
|
|
2460
2410
|
"""Which shipped skills the recorded sessions opened, and which opened none.
|
|
2461
2411
|
|
|
@@ -2466,45 +2416,29 @@ def skill_fitness(window=FITNESS_WINDOW):
|
|
|
2466
2416
|
skill a read opened). This is a measurement, not a gate - it names the
|
|
2467
2417
|
candidates, and retiring one stays the owner's call.
|
|
2468
2418
|
|
|
2469
|
-
The window is the
|
|
2470
|
-
|
|
2419
|
+
The window is the `window` sessions that recorded last. A session counts
|
|
2420
|
+
as having opened a skill when it
|
|
2471
2421
|
recorded that skill's kind: `skill:<name>`, or the mark the status line draws
|
|
2472
2422
|
for the two marked skills, so reads recorded before `SKILL_KIND` existed
|
|
2473
2423
|
still count. Sessions on a host that cannot see a read record neither, and
|
|
2474
2424
|
the report cannot tell that session from one that read nothing - the number
|
|
2475
2425
|
is a floor, not a census.
|
|
2476
2426
|
|
|
2477
|
-
Returns `sessions` and `recorded` (the
|
|
2478
|
-
`skills` (the opened ones ranked by session count, each with the
|
|
2479
|
-
line draws for it, or None), and `never` (the shipped skills no
|
|
2480
|
-
the window opened)."""
|
|
2427
|
+
Returns `sessions` and `recorded` (the sessions read and the sessions
|
|
2428
|
+
recorded), `skills` (the opened ones ranked by session count, each with the
|
|
2429
|
+
mark the line draws for it, or None), and `never` (the shipped skills no
|
|
2430
|
+
session in the window opened)."""
|
|
2481
2431
|
catalog = shipped_skills()
|
|
2482
|
-
|
|
2483
|
-
try:
|
|
2484
|
-
files = sorted((os.path.join(d, n) for n in os.listdir(d)
|
|
2485
|
-
if n.endswith(".jsonl")),
|
|
2486
|
-
key=_mtime, reverse=True)
|
|
2487
|
-
except OSError:
|
|
2488
|
-
files = []
|
|
2432
|
+
window_kinds, recorded = tezgah_store.used_sessions(window)
|
|
2489
2433
|
opened = {}
|
|
2490
|
-
for
|
|
2491
|
-
kinds = set()
|
|
2492
|
-
try:
|
|
2493
|
-
with open(path, encoding="utf-8") as fh:
|
|
2494
|
-
for line in fh:
|
|
2495
|
-
try:
|
|
2496
|
-
kinds.add(json.loads(line)["kind"])
|
|
2497
|
-
except (ValueError, KeyError, TypeError):
|
|
2498
|
-
pass # a torn line, the way `used` reads one
|
|
2499
|
-
except OSError:
|
|
2500
|
-
continue
|
|
2434
|
+
for kinds in window_kinds:
|
|
2501
2435
|
for name in catalog:
|
|
2502
2436
|
mark = SKILL_MARKS.get(name)
|
|
2503
2437
|
if (SKILL_KIND + name) in kinds or (mark and mark in kinds):
|
|
2504
2438
|
opened[name] = opened.get(name, 0) + 1
|
|
2505
2439
|
return {
|
|
2506
|
-
"sessions": len(
|
|
2507
|
-
"recorded":
|
|
2440
|
+
"sessions": len(window_kinds),
|
|
2441
|
+
"recorded": recorded,
|
|
2508
2442
|
"skills": [{"name": name, "mark": SKILL_MARKS.get(name),
|
|
2509
2443
|
"sessions": count}
|
|
2510
2444
|
for name, count in sorted(opened.items(),
|
|
@@ -2793,7 +2727,7 @@ def health_segments(cwd, session_id=None, used_override=None, idx_override=None,
|
|
|
2793
2727
|
segs = with_update_notice(version_segment())
|
|
2794
2728
|
# Shown only when the prompt hook found the gate disarmed (gate_inactive):
|
|
2795
2729
|
# first, because it says every other mark is not being enforced.
|
|
2796
|
-
if session_id and
|
|
2730
|
+
if session_id and gate_marked(session_id):
|
|
2797
2731
|
segs.append({"key": "gate", "state": "off", "glyph": GLYPHS["off"],
|
|
2798
2732
|
"text": "gate", "group": 0})
|
|
2799
2733
|
# Shown when this session's start found tezgah's own hook entries changed
|
|
@@ -2811,7 +2745,7 @@ def health_segments(cwd, session_id=None, used_override=None, idx_override=None,
|
|
|
2811
2745
|
"text": "drift", "group": 0})
|
|
2812
2746
|
# A hook of this session could not import its core (tezgah_guard.
|
|
2813
2747
|
# import_failed): it failed open, so what it guards did not run.
|
|
2814
|
-
if session_id and
|
|
2748
|
+
if session_id and tezgah_store.doc("import_crash", import_crash_key(session_id)) is not None:
|
|
2815
2749
|
segs.append({"key": "crash", "state": "off", "glyph": GLYPHS["off"],
|
|
2816
2750
|
"text": "crash", "group": 0})
|
|
2817
2751
|
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:
|