@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.
@@ -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 `~/.cache/tezgah/update.json`.
206
- When that file is a day old, the redraw stamps it and starts one detached
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:2633-2637`). Until 2026-10-02 it
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`); and deletes tezgah's own hook state not modified for `--retention-days` (default 30) - the ledgers under `evidence/`, `turns/`, `sessions/`, `classify.log`, `context-drops.log` and `debug.log`, in the cache and its sandbox fallback - keeping the current session's (`$TEZGAH_SESSION`) files whatever their age (`sweep_state`) |
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/signals.jsonl` through the ledger's own writer
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:2380-2390`).
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` writes
442
- the decision to `decisions.jsonl` as `unverified` and never applies it. A defect
443
- goes to `defects.jsonl` and never into the ledger.
444
-
445
- The ledger is `<repo>/.tezgah/taste/ledger.json`, plus
446
- `~/.config/tezgah/taste/ledger.json` for user-scope learnings
447
- (`hooks/tezgah_taste_ledger.py`). Confidence is (r+1)/(r+s+2) over supporting
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. Every taste row goes through the evidence ledger's locked
461
- writer (`append`, `hooks/tezgah_taste_ledger.py::append`).
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, it writes `gate.json` and injection stops (`gate`,
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:2596-2625`) - but that text names at most one
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
@@ -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>/switches/<session>.json`). Mark file `<cache>/gate-inactive/<session>` (`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, `<cache>/harness-drift/<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 `~/.cache/tezgah/import-crash/<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 |
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 append to the session store,
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:2123-2130,
164
- hosts/opencode/plugins/tezgah.js:2669, `hosts/omp/hook.py::handle`. Both Claude (hooks/hooks.json) and dsh
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).
@@ -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 drift_mark(session_id, host):
198
- """The mark one host's drifted session start leaves; its text is the drift
199
- list. Keyed by session AND host: two hosts can share a session id (a
200
- `tezgah-context attest` run by hand, a host whose ids collide), and a clean
201
- start on one must not clear the other's mark."""
202
- return os.path.join(tp.cache_dir(), "harness-drift", "%s.%s" % (
203
- hashlib.sha256(str(session_id).encode()).hexdigest()[:16], host))
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
- try:
213
- with open(drift_mark(session_id, host), encoding="utf-8") as fh:
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
- mark = drift_mark(session_id, host)
246
- try:
247
- if found:
248
- os.makedirs(os.path.dirname(mark), exist_ok=True)
249
- with open(mark, "w", encoding="utf-8") as fh:
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
@@ -17,7 +17,8 @@ import time
17
17
  import tezgah_embed
18
18
  import tezgah_orca
19
19
  import tezgah_research
20
- from tezgah_guard import import_crash_mark
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
- try:
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 not session_id:
1077
- return
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
- with open(path, "rb") as fh:
1632
- return fh.read().split(b"\n")
1633
- except OSError:
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 _gate_mark(session_id):
1676
- return os.path.join(cache_dir(), "gate-inactive", slug(str(session_id)))
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
- path = _switch_baseline(session_id)
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 (OSError, ValueError, KeyError, TypeError):
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
- try:
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
- mark = _gate_mark(session_id)
1712
+ key = slug(str(session_id))
1734
1713
  try:
1735
1714
  if inactive or moved:
1736
- os.makedirs(os.path.dirname(mark), exist_ok=True)
1737
- open(mark, "w", encoding="utf-8").close()
1738
- elif os.path.exists(mark):
1739
- os.remove(mark)
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
- d = os.path.join(cache_dir(), "sessions")
2404
- os.makedirs(d, exist_ok=True)
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
- if not session_id:
2419
- return set()
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 newest recorded session files a fitness report reads. A row is
2444
- # `{"kind": kind}` and carries no timestamp, so the window can only be files by
2445
- # mtime - the moment the session last recorded anything, which for a used-kind
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 newest `window` session files by mtime, because the rows
2470
- carry no timestamp. A session counts as having opened a skill when it
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 files read and the files present),
2478
- `skills` (the opened ones ranked by session count, each with the mark the
2479
- line draws for it, or None), and `never` (the shipped skills no session in
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
- d = os.path.join(cache_dir(), "sessions")
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 path in files[:window]:
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(files[:window]),
2507
- "recorded": len(files),
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 os.path.exists(_gate_mark(session_id)):
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 os.path.exists(import_crash_mark(session_id)):
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:
@@ -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 nudge_mark(session_id):
591
- """The once-per-session mark: sha1(id)[:16], the key opencode's oncePerSession writes."""
592
- return os.path.join(cache_dir(), "nudged", hashlib.sha1(
593
- (session_id or "nosession").encode("utf-8")).hexdigest()[:16])
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 gets the
600
- one-shot nudge instead of the write failing open."""
601
- mark = nudge_mark(session_id)
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
- os.makedirs(os.path.dirname(mark), exist_ok=True)
606
- open(mark, "w", encoding="utf-8").close() # consume BEFORE denying: later greps pass
607
- return True
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 evidence ledger, the session
1871
- # store, the switch baseline (tezgah_context.disarmed), the gate mark, and the
1872
- # marks the status line reads for a drifted hook entry
1873
- # (tezgah_attest.drift_mark) and a core that failed to import
1874
- # (tezgah_guard.import_crash_mark).
1875
- CONTROL_CACHE = ("evidence", "sessions", "switches", "gate-inactive",
1876
- "harness-drift", "import-crash", "workspace-index.json")
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 os.path.exists(nudge_mark(sid)))
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: