@r1z4x/tezgah 1.3.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/docs/evidence.md CHANGED
@@ -7,9 +7,11 @@ undone from. Read it before changing a rule that records or reads evidence, or w
7
7
 
8
8
  ## The ledger
9
9
 
10
- One append-only JSONL file per session at `<cache>/evidence/<slug>.jsonl`; the stem is a readable
11
- prefix plus a hash of the session id, so a filename can never be turned back into one
12
- (`hooks/tezgah_integrity.py::_slug`). Every writer goes through `note_path` (`hooks/tezgah_integrity.py::note_path`): `note()`
10
+ One append-only ledger per session, stored as the `evidence` rows of `<cache>/tezgah.db`
11
+ (`hooks/tezgah_store.py::EVIDENCE_SCHEMA`). A row keeps the JSON text its writer produced, with the
12
+ session, `kind` and `ts` in columns beside it. A caller names a ledger by its old file path,
13
+ `<cache>/evidence/<slug>.jsonl`. The stem is the session key: a readable prefix plus a hash of the
14
+ session id, which nothing can turn back into the id (`hooks/tezgah_integrity.py::_slug`). Every writer goes through `note_path` (`hooks/tezgah_integrity.py::note_path`): `note()`
13
15
  derives the path from the session id (`hooks/tezgah_integrity.py::note`). The row is built in exactly one place (`hooks/tezgah_integrity.py::note_path`), and its shape is
14
16
  `{"kind": …, "ts": …, "v": …, "detail": …}` plus whatever `LEDGER_FIELDS` keys the writer knew — for a check
15
17
  the host reported passing, `{"kind": "verify_ok", "ts": 1758000000, "v": 3, "detail": "pytest -q",
@@ -50,17 +52,47 @@ plugin, whose row carries the same field under the same empty-means-absent rule
50
52
  (`hosts/opencode/plugins/tezgah.js:1230-1241`) — so no live host leaves it out. A row written *before*
51
53
  the field landed names its tool only where the name survived in `detail` — an `unknown` row and an
52
54
  MCP `external` row — so the one cause of an under-count is the corpus's age, and it shrinks as
53
- sessions run. The append is one locked line, an exclusive `flock` with a 1 s bound falling
54
- back to an unlocked write (`hooks/tezgah_integrity.py::_append`), and is best effort: a write failure is never the caller's.
55
- A ledger is created `0600` in a `0700` directory, because its rows name the paths and commands of
56
- the user's work (audit L-6 found them `0644`). Every reader skips a line that ends in a newline and
57
- does not parse, in its own ledger or another's (`_parse` `hooks/tezgah_integrity.py::_parse`). The
58
- reader used to raise there, and the guard then failed the Stop rule open for the turn, so corrupting
59
- a `verify_fail` row let a claim through. Now the reader writes one `ledger_damage` row per damaged
60
- line into that ledger, keyed on a digest of the line (`_note_damage`
55
+ sessions run. The append is one INSERT in autocommit (`hooks/tezgah_integrity.py::_append`,
56
+ `hooks/tezgah_store.py::append_evidence`), so a row is whole or absent and two writers queue on the
57
+ database's lock. A row the database refuses (busy past its timeout, a damaged file) goes to the
58
+ session's old JSONL file instead, marked `unlocked`, and the next import takes it. It is best
59
+ effort: a write failure is never the caller's. The store creates the
60
+ database `0600` in a `0700` directory. Its rows name the paths and commands of the user's work, and
61
+ audit L-6 found the old files `0644`.
62
+
63
+ **The JSONL files it replaced.** Every read or write of a session first imports its old
64
+ `<cache>/evidence/<slug>.jsonl` file (`hooks/tezgah_store.py::_sync`). The import costs one stat
65
+ when nothing is new. It takes the database's write lock first, then the file's flock if it is free.
66
+ It takes only the complete lines past the bytes the `imported` table already counts. It never
67
+ renames the file aside. An opencode process started before the store may still run the old plugin,
68
+ which appends to it, and a refused row lands there. Session start launches the bulk import
69
+ of every other file, detached and at most once a day
70
+ (`hooks/tezgah_store.py::import_later`). `python3 hooks/tezgah_store.py import-evidence` and
71
+ `tezgah-doctor --import-evidence` run it on demand. A reader that spans every session imports first
72
+ (`hooks/tezgah_store.py::sessions`).
73
+
74
+ **opencode's plugin** opens the same database through node:sqlite. It uses the store's busy
75
+ timeout, WAL switch and schema (`evidenceDb` in `hosts/opencode/plugins/tezgah.js`), so either side
76
+ may create it. `tests/test_opencode_plugin.py::OpenCodePlugin.test_the_plugin_creates_the_store_python_creates`
77
+ holds the two schemas equal. Before a read or write of a session it asks the store to import that
78
+ session's old file. It asks again only when the file's size or mtime changed (`importLegacy`). A
79
+ row the store refuses goes to that file marked `unlocked`, as the Python writer's does
80
+ (`legacyAppend`). A runtime without node:sqlite, which is node before 22.5, runs
81
+ `python3 hooks/tezgah_store.py evidence append|tail|first|kind|import`
82
+ (`hooks/tezgah_store.py::_evidence_cli`) for each read or write instead. `TEZGAH_OPENCODE_NO_SQLITE=1`
83
+ forces that path. Two limits are known. node:sqlite is synchronous, so a busy database can block
84
+ the host's event loop for up to the 5-second busy timeout. Second, the plugin's deadline can kill a
85
+ CLI append after the CLI committed the row. The plugin then writes the row to the old file too,
86
+ and the ledger holds it twice.
87
+
88
+ Every reader skips a line that ends in a newline and does not parse (`_parse`
89
+ `hooks/tezgah_integrity.py::_parse`). Such a line reaches the database only through an imported file,
90
+ which stores it as written. The reader used to raise there, and the guard then failed the Stop rule
91
+ open for the turn, so corrupting a `verify_fail` row let a claim through. Now the reader writes one
92
+ `ledger_damage` row per damaged line, keyed on a digest of the line (`_note_damage`
61
93
  `hooks/tezgah_integrity.py::_note_damage`). The Stop rule refuses a done-claim in a turn that holds one
62
- as "evidence tampered". A tail with no newline is a fragment, not damage: `_append` cuts it before
63
- its own write, so an honest writer never leaves one. Any other read error in another session's
94
+ as "evidence tampered". A tail with no newline is a fragment, not damage: the import waits for its
95
+ newline, and the file append cuts it before its own write. Any other read error in another session's
64
96
  ledger still costs only that ledger's rows (`_foreign_rows`; audit M-7).
65
97
 
66
98
  What never reaches the ledger: tool result bodies (only `out_bytes`, a size,
@@ -78,7 +110,7 @@ a write tool is `edit`, a shell call is `verify` when its command matches the ch
78
110
  | `turn` | `hooks/tezgah_integrity.py::note_turn`, from the prompt path | `hooks/tezgah_integrity.py::_turn_start`, scoping every turn rule; `hooks/tezgah_integrity.py::_claim_key` |
79
111
  | `run`, `edit`, `verify`, `verify_ok`, `verify_fail`, `interrupted` | `hooks/tezgah_integrity.py::note_tool`, the call's own name in `tool`, on opencode the plugin writes these same kinds and the same field (`hosts/opencode/plugins/tezgah.js:1192-1270`) | the Stop rule's `worked` set `hooks/tezgah_integrity.py::WORK_KINDS`; `counters.steps` `hooks/tezgah_integrity.py::_counts`; `last_verify`/`partial_state`; the `--trend` tool histogram |
80
112
  | `external`, `unknown` | `hooks/tezgah_integrity.py::note_tool` | the taint notice, via `source`; the `--trend` tool histogram; nothing counts them as work |
81
- | `began` | the gate's allow path for a write or shell call `hooks/tezgah_gate.py::decision`; on opencode the plugin's before-hook when the core was not asked (`hosts/opencode/plugins/tezgah.js:2516-2532`) | `hooks/tezgah_integrity.py::unanswered`, behind a claim in the Stop rule and in `counters.unanswered` |
113
+ | `began` | the gate's allow path for a write or shell call `hooks/tezgah_gate.py::decision`; on opencode the plugin's before-hook when the core was not asked (`hosts/opencode/plugins/tezgah.js:2549-2565`) | `hooks/tezgah_integrity.py::unanswered`, behind a claim in the Stop rule and in `counters.unanswered` |
82
114
  | `claim` | `hooks/tezgah_integrity.py::stop_reason`, only for a reply in the claim vocabulary - a completion or verification word (`DONE`/`VERIFIED`), blocked or allowed, or a refused claim about an external system's state (`_external_claim`) | `hooks/tezgah_integrity.py::counters` |
83
115
  | `refusal` | `stop_reason`, for a blocked reply that claimed nothing - a work-only or a shape refusal - with the same `blocked: <class>` detail (row version 3) | `counters.refusals`, and `shape_blocked` for a shape class; never a claim; `NOT_TOOL_HOOK` lists it |
84
116
  | `after_block` | `hooks/tezgah_integrity.py::stop_reason`, on the reply after this rule's own block (`stop_hook_active` in a turn holding a `blocked:` claim or refusal row) on Claude, Codex and omp; `detail` is `would block: <class>`, `ok` or `no claim` | nothing yet: a record for the after-block observation window, not a claim and not a second `shape` row, so `counters` counts it neither as a claim nor as a reply; `NOT_TOOL_HOOK` lists it |
@@ -92,7 +124,7 @@ a write tool is `edit`, a shell call is `verify` when its command matches the ch
92
124
  | `lesson_hit` | nothing yet: reserved for plan 061 Phase B, a compiled lesson check that matched a call in its observe stage, with the lesson's `key` and the call's `id` | nothing yet |
93
125
  | `ledger_damage` | `hooks/tezgah_integrity.py::_note_damage`, from `_parse`, once per damaged line, with its 12-hex `key` | the Stop rule's first evidence class, "evidence tampered" (`hooks/tezgah_integrity.py::_stop_block`) |
94
126
  | `disarm` | `hooks/tezgah_context.py::disarmed`, on the prompt a switch first appears on after the session's first prompt; `detail` is the switch name. Also the gate (`hooks/tezgah_gate.py::decision`), for a call it lets through that changes a `CONTROL_EVIDENCE` target - an open plan's delete or move, a forced `git add` of a `.tezgah/` path - with the call's `id` and `detail` `control: <what>` | the status line's `gate` mark ([status-line.md](status-line.md)) for the switch rows, set by the same check that writes them; the gate's rows are read by a person or a replay; no counter |
95
- | `attest` | `hooks/tezgah_attest.py::run`, once per session start: Claude and dsh (`hooks/projects-auto-init.py::main`), Codex, Cursor and omp from their session-start events, opencode at the first message through `oncePerSession` (`bin/tezgah-context::main`). `detail` is `ok`, `drifted: <entries>` (an entry added, removed or changed since install, a hook file any user can write, a stale omp bridge, a changed entry script on a release install) or `unverified: <why>`; `host` names the host, `switches` the kill switches present. The module is imported only inside that call, so a broken one costs the row and never a gate | the `drift` status mark and the `harness` field of the session's later `claim` rows, read from one mark file per session and host (`hooks/tezgah_attest.py::drift_mark`, so one host's clean start cannot clear another's) - an annotation, never a Stop block; `NOT_TOOL_HOOK` lists it, so a session start after a compaction does not read as a gate that ran |
127
+ | `attest` | `hooks/tezgah_attest.py::run`, once per session start: Claude and dsh (`hooks/projects-auto-init.py::main`), Codex, Cursor and omp from their session-start events, opencode at the first message through `oncePerSession` (`bin/tezgah-context::main`). `detail` is `ok`, `drifted: <entries>` (an entry added, removed or changed since install, a hook file any user can write, a stale omp bridge, a changed entry script on a release install) or `unverified: <why>`; `host` names the host, `switches` the kill switches present. The module is imported only inside that call, so a broken one costs the row and never a gate | the `drift` status mark and the `harness` field of the session's later `claim` rows, read from one `harness_drift` row of `<cache>/tezgah.db` per session and host (`hooks/tezgah_attest.py::drift_key`, so one host's clean start cannot clear another's) - an annotation, never a Stop block; `NOT_TOOL_HOOK` lists it, so a session start after a compaction does not read as a gate that ran |
96
128
  | `crash` | `hooks/tezgah_guard.py::safe` for a core call that raised, and `hooks/tezgah_guard.py::import_failed` for an entry point whose core imports raised (`detail` `import: <class>: <message>`). An import failure also writes one stderr line and the status line's `crash` mark, always; when `tezgah_integrity` or `tezgah_paths` is the module that failed, the row is the part that cannot be written. Hooks fail open with exit 0; `tezgah-gate check` exits 3 | `NOT_TOOL_HOOK` lists it; no counter |
97
129
 
98
130
  **`compact` is what a compaction kept, from the record.** When the host hands the PostCompact
@@ -148,8 +180,8 @@ or its call's rows went missing. The reader marks it in memory and never writes
148
180
  `scratch_evidence` and `tezgah-route --report` all read it as a check nobody saw pass. The Stop rule
149
181
  refuses a done-claim in its turn as "evidence tampered", and `counters.orphans` counts it. A false
150
182
  block refuses an honest turn, so the reader marks nothing in five cases. These are a ledger with no
151
- `began` row before the pass, a turn with a `crash` row, and `pretooluse-off`. The others are an
152
- append that took the unlocked fallback (its row carries `unlocked`) and the sandbox fallback cache.
183
+ `began` row before the pass, a turn with a `crash` row, and `pretooluse-off`. The others are a row
184
+ a JSONL append wrote without the lock (it carries `unlocked`) and the sandbox fallback cache.
153
185
  A row with no outcome, such as Cursor's afterShellExecution `verify`, does not take a pass's `began`.
154
186
  `turn_rows` seeds the waiting `began` rows from the 200 rows before the turn. So a call that straddles a
155
187
  prompt pairs there as it does in `events`. A forged `began` row plus its pass stays invisible:
@@ -423,8 +455,9 @@ is. Every denial is itself a `deny` row.
423
455
 
424
456
  ## The session store for the status marks
425
457
 
426
- `<cache>/sessions/<slug>.jsonl`, written by `record()` (`hooks/tezgah_context.py::record`) and read
427
- by `used()` (`hooks/tezgah_context.py::used`). A row is exactly `{"kind": kind}` — no timestamp, no outcome, no session —
458
+ The `used` rows of `<cache>/tezgah.db`, written by `record()` (`hooks/tezgah_context.py::record`) and read
459
+ by `used()` (`hooks/tezgah_context.py::used`). A row is the session's slug, one kind and the time it was last
460
+ recorded (`hooks/tezgah_store.py::use`) — no outcome, one row per session and kind —
428
461
  and the kinds are the used-tool marks [status-line.md](status-line.md) lights up (`graph`, `consult`,
429
462
  `research`, `judge`), plus one kind per shipped skill a read opened. It is separate from the
430
463
  [ledger](glossary.md#ledger) because it is display state, not
@@ -449,7 +482,7 @@ line never asked. Three surfaces recognise a read, and they have to agree on the
449
482
  bridge calls (`hosts/omp/hook.py::classify`), omp's own filter, which decides whether python hears about the
450
483
  read at all (`hosts/omp/tezgah-hook.ts.in:225-262`, its catalogue the checkout `@HOOK@` names), and
451
484
  opencode's plugin, which classifies in process because that host has no Python hook
452
- (`hosts/opencode/plugins/tezgah.js:2141-2198`, its catalogue the host's own installed skill dir
485
+ (`hosts/opencode/plugins/tezgah.js:2162-2219`, its catalogue the host's own installed skill dir
453
486
  (`dirname(CONFIG)/opencode/skills`), which `tezgah-setup` fills with every shipped skill).
454
487
 
455
488
  Claude loads a skill through its `Skill` tool, never a read, so a Claude load used to leave no row.
@@ -463,14 +496,15 @@ proves the hook side only.
463
496
  recorded sessions opened it, and names the ones none did: a skill is a dependency that has to keep
464
497
  earning the context lines it costs, and accretion is invisible from reading the skill itself. It is a
465
498
  measurement, not a gate - it names the candidates for retirement, and deleting one stays the owner's
466
- call. Three ceilings are honest parts of the number, not bugs: the window is the newest
467
- `FITNESS_WINDOW = 200` session files by mtime, because a row carries no timestamp; a session counts
499
+ call. Three ceilings are honest parts of the number, not bugs: the window is the
500
+ `FITNESS_WINDOW = 200` sessions that recorded last, by the time each last recorded a kind
501
+ (`hooks/tezgah_store.py::used_sessions`), which says when the session last did anything this store sees; a session counts
468
502
  as having opened a skill when it recorded that skill's kind, so reads recorded before `skill:` existed
469
503
  still count through the two marks; and only the hosts that classify a read in process record one
470
504
  here, so the count is a floor, not a census. Which host records what, since this store is the
471
505
  report's only source: **omp** (the extension's filter lets the read through,
472
506
  `hosts/omp/tezgah-hook.ts.in:255-262`, and the hook classifies it, `hosts/omp/hook.py::classify`) and
473
- **opencode** (the plugin classifies it, `hosts/opencode/plugins/tezgah.js:2183-2198`) record the mark
507
+ **opencode** (the plugin classifies it, `hosts/opencode/plugins/tezgah.js:2204-2219`) record the mark
474
508
  or `skill:<name>` for every shipped skill, in either form; **Claude** sees a read, but only in its
475
509
  transcript, which feeds the status line and writes no row here (`statusline.py::claude_used`), so a Claude
476
510
  session reads in this report as one that opened nothing - marks included; **codex**, **cursor** and
@@ -488,8 +522,9 @@ deliberately not here.
488
522
  ## Snapshots and rollback
489
523
 
490
524
  Before a write is allowed, `capture` copies the current bytes of every file the call names into
491
- `<cache>/snapshots/<id>/file`, `meta.json` beside it (`hooks/tezgah_snapshot.py::_store`, `:75-80`,
492
- `hooks/tezgah_snapshot.py::_capture_one`), and appends one `snapshot` row whose `detail` is the resolved path, `id` the 12-hex
525
+ `<cache>/snapshots/<id>/file` (`hooks/tezgah_snapshot.py::_store`, `:74-79`,
526
+ `hooks/tezgah_snapshot.py::_capture_one`), indexes it with its manifest as a `snapshots` row of
527
+ `<cache>/tezgah.db` (`hooks/tezgah_store.py::add_snapshot`), and appends one `snapshot` row whose `detail` is the resolved path, `id` the 12-hex
493
528
  snapshot id, `hash` the sha256 and `out_bytes` the size (`hooks/tezgah_snapshot.py::_capture_one`). The row is written last, so it
494
529
  never names a copy that is not on disk. A credential file (`SECRET_FILE`: `.env*`, `*.env`, key and
495
530
  certificate files, `id_rsa`-style keys, `credentials.json`, `.netrc`, `.npmrc`, `.pypirc`, `.pgpass`,
@@ -499,11 +534,11 @@ are written `0600` in `0700` directories. Every host calls `capture` in process
499
534
  JavaScript plugin reaches it through `bin/tezgah-capture` (`bin/tezgah-capture:1-20`).
500
535
  The core captures a call put to `tezgah-gate decide` itself, so the plugin skips it.
501
536
 
502
- The store is capped twice (`hooks/tezgah_snapshot.py:34-53`): `CAP = 200` snapshots, oldest evicted by directory mtime when a
503
- capture crosses it (`hooks/tezgah_snapshot.py::CAP`, `hooks/tezgah_snapshot.py::_evict`), and `MAX_BYTES = 2 MiB` per file, above which
537
+ The store is capped twice (`hooks/tezgah_snapshot.py:35-56`): `CAP = 200` snapshots, oldest evicted in capture order when a
538
+ capture crosses it (`hooks/tezgah_snapshot.py::CAP`, `hooks/tezgah_store.py::add_snapshot`), and `MAX_BYTES = 2 MiB` per file, above which
504
539
  **nothing is captured at all** — an over-large file is skipped because `capture` runs synchronously in
505
540
  the gate on the path of the very write it protects, so copying gigabytes would stall that call, and a
506
- refused capture writes no row and so claims no copy (`hooks/tezgah_snapshot.py:46-53`, `hooks/tezgah_snapshot.py::_capture_one`).
541
+ refused capture writes no row and so claims no copy (`hooks/tezgah_snapshot.py:49-56`, `hooks/tezgah_snapshot.py::_capture_one`).
507
542
 
508
543
  `restore` (`hooks/tezgah_snapshot.py::restore`) has exactly one caller, `bin/tezgah-rollback`, which a user runs:
509
544
  `tezgah-rollback <snapshot-id> [--force]` (`bin/tezgah-rollback::main`). It refuses an unknown or
@@ -586,8 +621,8 @@ empty stand-in for the result body its bridge never sends, so a subagent's repor
586
621
  cannot import the core in process and mirrors the control in JavaScript instead — the channel on the
587
622
  call's own row, the label and the taint notice in front of the result the hook is handed, including
588
623
  the `external` row an MCP answer or a fetched page earns (`untrustedSource`
589
- `hosts/opencode/plugins/tezgah.js:1898`, `labelResult` `:1972`, with the tier's argv reader
590
- `tierRead` at `:1881`). The two halves are pinned against each other over a shared corpus, so neither
624
+ `hosts/opencode/plugins/tezgah.js:1919`, `labelResult` `:1993`, with the tier's argv reader
625
+ `tierRead` at `:1902`). The two halves are pinned against each other over a shared corpus, so neither
591
626
  can move without failing the other's test (`tests/test_opencode_plugin.py::OpenCodePlugin.test_the_classifier_agrees_with_the_python_half_on_a_shared_corpus`). One difference is deliberate:
592
627
  opencode's plugin writes that `external` row itself and carries the channel alone, since the UI rule
593
628
  `_screen_read` belongs to the Python half and does not run there.
@@ -693,7 +728,8 @@ the corpus ran under).
693
728
  No counter above says whether a refusal was right. The ledger stores the verdict, never the truth.
694
729
  `tezgah-gate replay` (`hooks/tezgah_replay.py`) builds the instrument that can. It is a report and
695
730
  changes no gate behaviour. Every file it writes stays under `~/.cache/tezgah/replay/<run>/`, owner
696
- only, and never leaves the machine. Every figure it prints names its cutoff.
731
+ only, and never leaves the machine. The run's summary is its row of the cache's `tezgah.db`
732
+ (`hooks/tezgah_store.py::add_replay`), and the newest row is the latest run. Every figure it prints names its cutoff.
697
733
 
698
734
  - **Corpus.** The run reads every ledger under the cache and writes none. It drops a whole ledger
699
735
  when `fixture_ledger` calls it a fixture. It also drops one when the session id behind the file
package/docs/gate.md CHANGED
@@ -34,7 +34,7 @@ writes stays under `~/.cache/tezgah/replay`. The corpus, the exclusions and the
34
34
  |---|---|---|
35
35
  | claude, dsh | `hooks/projects-pretooluse.py::main` (`hosts/dsh/hooks.json:13-14`) | `hookSpecificOutput.permissionDecision: "deny"` |
36
36
  | codex, cursor, omp | `hosts/codex/hook.py::main`, `hosts/cursor/hook.py::dispatch`, `hosts/omp/hook.py::handle` | that host's own envelope — [hosts.md](hosts.md) |
37
- | opencode | `hosts/opencode/plugins/tezgah.js:2373` | `new Error(deny)` thrown at `hosts/opencode/plugins/tezgah.js:2534` |
37
+ | opencode | `hosts/opencode/plugins/tezgah.js:2406` | `new Error(deny)` thrown at `hosts/opencode/plugins/tezgah.js:2567` |
38
38
 
39
39
  Every refusal is also recorded before it is returned: `_deny` appends a ledger row `deny` whose `detail` is `"<rule>: <reason, first 80 chars>"`, plus any
40
40
  `extra` (`hooks/tezgah_gate.py::_deny`). That row, not the reason wording, is where "why was this denied" is answered, and `<rule>` is the name used below.
@@ -78,7 +78,7 @@ it first after the root check (`decision` `hooks/tezgah_gate.py::decision`). The
78
78
  |---|---|---|
79
79
  | kill switches and opt-in markers | every name in `SWITCHES` (`hooks/tezgah_paths.py::SWITCHES`) under `~/.config/tezgah` or `~/.claude` | any write |
80
80
  | tezgah's configuration | all of `~/.config/tezgah` (`config.json`, `bin/`, `ponytail.level`, the `hook:<host>:...` rows of `contract.sha256`, ...) | any write |
81
- | evidence and session state | the cache's `evidence/`, `sessions/`, `switches/`, `gate-inactive/`, `harness-drift/`, `import-crash/` and `workspace-index.json`, in the global cache and the temp fallback (`hooks/tezgah_gate.py::CONTROL_CACHE`) | any write |
81
+ | evidence and session state | the cache's `tezgah.db` (with its `-wal` and `-shm` files), `evidence/`, the `sessions/`, `switches/`, `gate-inactive/`, `harness-drift/` and `import-crash/` directories that database imports once, and `workspace-index.json`, in the global cache and the temp fallback (`hooks/tezgah_gate.py::CONTROL_CACHE`) | any write |
82
82
  | hook wiring | the opencode plugin file, the omp hook, the Claude plugin copy, the install prefix, and the tree the hooks run from unless the session works inside it (`hooks/tezgah_gate.py::_registration`, `hooks/tezgah_gate.py::_install_trees`) | any write |
83
83
  | shared registration files | `.claude/settings.json` and `settings.local.json`, `~/.claude/plugins/installed_plugins.json` and `known_marketplaces.json`, Codex's and Cursor's `hooks.json`, Codex's `config.toml` | a shell write; a write tool only when it changes an entry that is tezgah's - `statusLine`, `attribution`, `disableAllHooks`, a key or item naming tezgah (the user's own hook beside tezgah's in one matcher group stays theirs), or any `[hooks...]` line of `config.toml`, where the trust entries live (`hooks/tezgah_gate.py::_shared_change`) |
84
84
  | repository marks and hooks | a `REPO_MARKS` name under a root, the session checkout's `.git/hooks` (a `.sample` file and a clone elsewhere pass), `git config core.hooksPath` set or unset there or `--global` | any write |
@@ -89,7 +89,7 @@ it first after the root check (`decision` `hooks/tezgah_gate.py::decision`). The
89
89
  | the hook entries | `projects-auto-init`, `projects-pretooluse`, `projects-posttooluse`, `projects-stop`, `hook.py` under `hosts/codex`, `hosts/cursor` or `hosts/omp`, and the `tezgah-codex-hook`/`tezgah-cursor-hook` launchers (`hooks/tezgah_gate.py::HOOK_ENTRIES`, `hooks/tezgah_gate.py::_host_hook`): fed a forged payload, one writes genuine rows, the switch latch's `authorized` row among them | always |
90
90
 
91
91
  The rule reads the shell word by word, one simple command at a time (`shell_control` `hooks/tezgah_gate.py::shell_control`). It reads each redirect target.
92
- It reads the arguments of `touch`, `rm`, `rmdir`, `unlink`, `mv`, `chmod`, `mkdir`, `tee`, `truncate`, the copiers' destination and `sed -i`/`perl -i`.
92
+ It reads the arguments of `touch`, `rm`, `rmdir`, `unlink`, `mv`, `chmod`, `mkdir`, `tee`, `truncate`, `sqlite3`, the copiers' destination and `sed -i`/`perl -i`.
93
93
  It reads the value of `cp -t`, `curl -o`, `wget -O`, `tar -C` on extraction and `unzip -d` (`OPTION_TARGETS`). It expands `~`, `$HOME` and the XDG
94
94
  variables first. It follows a `cd` within the line and reads past `if`/`then`/`do`/`!`/`{`. It opens `eval` and `bash -c`, and any flag cluster holding
95
95
  `c` is `-c`. A path counts as written and with its last link resolved. A quoted word is a word, so a grep or a commit message that names a switch passes. `SHELL_WRITE` stays as it was: it feeds the
@@ -130,7 +130,7 @@ Passes: a line that opens with `set -o pipefail` (`hooks/tezgah_integrity.py::PI
130
130
  (`verify_ok`/`verify_fail`, [evidence.md](evidence.md)); a redirect to a file; a pipe of anything that is not a check (`git log | head`). The
131
131
  remedy is one sentence, `hooks/tezgah_integrity.py::PIPED_REMEDY`, which the always-on core and the contract skill quote too. Told: keep the check last with its output in a file, then read the file in a separate call, or open the line with `set -o pipefail;`. A `; tail` on the same line hands the status to `tail`. The refusal names the check as it was typed. Standing, under `verify-off`
132
132
  (`hooks/tezgah_gate.py::decision`). opencode asks the core for this rule on any shell line that pipes a check
133
- (`hosts/opencode/plugins/tezgah.js:2459-2463`) and records such a line as `verify` when a pipe or a later command owns its status (`statusHidden`, `hosts/opencode/plugins/tezgah.js:445`).
133
+ (`hosts/opencode/plugins/tezgah.js:2492-2496`) and records such a line as `verify` when a pipe or a later command owns its status (`statusHidden`, `hosts/opencode/plugins/tezgah.js:445`).
134
134
 
135
135
  ### Attribution — an AI/model credit on its way into an artifact
136
136
 
@@ -144,9 +144,9 @@ a `git`/`gh` write for it to be this rule's. An MCP `write` or `publish` call me
144
144
 
145
145
  ### Language — an identifier or message that is not English
146
146
 
147
- Trigger, shell: a command that would create an identifier - `git checkout -b`/`git switch -c`, `git branch` with a new name, a commit subject (`git commit -m`/`--message`/`-F`), or a `gh pr|issue create --title` (the language section `hooks/tezgah_gate.py:352-465`, `hooks/tezgah_gate.py::created_texts`; the text is read from the raw command, because `mask()` blanks quoted strings and a commit subject is one) - whose name or subject is **not English**: a letter outside ASCII anywhere (`hooks/tezgah_lang.py::non_english` - a predicate rather than one language's letter list, so Russian, Greek, Arabic, Chinese and accented Latin hit it exactly as Turkish does) or an ASCII-folded Turkish word from the curated list (`hooks/tezgah_lang.py::ENGLISH`, wi…
147
+ Trigger, shell: a command that would create an identifier - `git checkout -b`/`git switch -c`, `git branch` with a new name, a commit subject (`git commit -m`/`--message`/`-F`), or a `gh pr|issue create --title` (the language section `hooks/tezgah_gate.py:354-467`, `hooks/tezgah_gate.py::created_texts`; the text is read from the raw command, because `mask()` blanks quoted strings and a commit subject is one) - whose name or subject is **not English**: a letter outside ASCII anywhere (`hooks/tezgah_lang.py::non_english` - a predicate rather than one language's letter list, so Russian, Greek, Arabic, Chinese and accented Latin hit it exactly as Turkish does) or an ASCII-folded Turkish word from the curated list (`hooks/tezgah_lang.py::ENGLISH`, wi…
148
148
 
149
- The rule is a **heuristic** and its own text says so: a word list is not a language detector, so it cannot tell a proper noun or a product name in another language from a word that ought to be English, and a listed word may be the user's own term. A command that only names the words - `echo`, `grep`, `git log --grep` - creates nothing and passes. An identifier outlives the session that typed it, which is the reason the rule exists at all: `plan/004-admin-durum-onarimi` is in a public history from the moment it exists. On **opencode** the rule is not ported: the plugin asks the core for it through `bin/tezgah-gate check`, one spawn for a command its pre-test reads as identifier-creating (`IDENT_CMD` `hosts/opencode/plugins/tezgah.js:585`, the call site `hosts/opencode/plugins/tezgah.js:2459-2463`).
149
+ The rule is a **heuristic** and its own text says so: a word list is not a language detector, so it cannot tell a proper noun or a product name in another language from a word that ought to be English, and a listed word may be the user's own term. A command that only names the words - `echo`, `grep`, `git log --grep` - creates nothing and passes. An identifier outlives the session that typed it, which is the reason the rule exists at all: `plan/004-admin-durum-onarimi` is in a public history from the moment it exists. On **opencode** the rule is not ported: the plugin asks the core for it through `bin/tezgah-gate check`, one spawn for a command its pre-test reads as identifier-creating (`IDENT_CMD` `hosts/opencode/plugins/tezgah.js:585`, the call site `hosts/opencode/plugins/tezgah.js:2492-2496`).
150
150
 
151
151
  Off: `lang-off`, the rule's own switch (`hooks/tezgah_gate.py::decision`), and `pretooluse-off` drops it with the whole gate.
152
152
 
@@ -166,7 +166,7 @@ Trigger: a write tool, or a shell command that writes a file, while an active ta
166
166
  against `hooks/tezgah_gate.py::SHELL_WRITE`); the allowlist refuses a target outside it (`hooks/tezgah_gate.py::TASK_SCOPE_DENY`) — a write tool's path, and a shell command's redirect or `tee` target (`hooks/tezgah_gate.py::write_paths`); and the record itself is refused twice over — a write whose target IS it, by a write tool or a shell redirect/`tee`
167
167
  (`hooks/tezgah_gate.py::TASK_RECORD_DENY`) and a shell command that would change it through the CLI, named by its basename with or without `.py` (`hooks/tezgah_gate.py::TASK_UNLOCK_DENY`, matched on the masked text by `hooks/tezgah_gate.py::TASK_CHANGE`). Told:
168
168
  none of them names the command that lifts it, on purpose — with the unlock printed, an armed arm removed or disabled the gate in 25 of 25 runs and obeyed it in none
169
- (`hooks/tezgah_gate.py:883-887`) — so each names the phase or the allowlist as the user's to change. Standing: only the record changing lifts it.
169
+ (`hooks/tezgah_gate.py:882-886`) — so each names the phase or the allowlist as the user's to change. Standing: only the record changing lifts it.
170
170
  Scratch meets no allowlist: a temp file or a device, such as `pytest > /tmp/check.log` (`hooks/tezgah_gate.py::task_reason`). Build output inside the repo stays held. Off: `task-off`
171
171
  (`hooks/tezgah_gate.py::decision`). It fails open wherever the question cannot be answered: no active task, no readable record; the record is written by `bin/tezgah-task` and
172
172
  never by the agent, so this rule refuses on a decision the user made rather than one the gate inferred.
@@ -253,7 +253,7 @@ The search is the pattern of a `grep`, `search` or `ast_grep` tool, a term of om
253
253
  omp's bridge sends `find`, `search` and `ast_grep` to the gate, because omp's own prompt sends code discovery to `find` first (`hosts/omp/tezgah-hook.ts.in`).
254
254
  Told: the host's graph tool (`hooks/tezgah_gate.py::graph_tool`) and the runnable command built from the denied search (`codegraph explore <X>`, `codegraph callers <X>`, `codegraph impact <X>`).
255
255
  An omp subagent uses this CLI, because omp's MCP device refuses concurrent writes (`hooks/tezgah_gate.py::nudge_reason`).
256
- Once-only: the mark in `cache_dir()/nudged/<sha1(session)[:16]>` is written *before* the refusal, so re-issuing the search passes (`hooks/tezgah_gate.py::first_nudge`). opencode's `oncePerSession` writes the same name (`hooks/tezgah_gate.py::nudge_mark`).
256
+ Once-only: the mark is a `nudged` row of the cache's `tezgah.db`, keyed `sha1(session)[:16]`. It is written *before* the refusal, so re-issuing the search passes (`hooks/tezgah_gate.py::first_nudge`). opencode's `oncePerSession` writes the same key (`hooks/tezgah_gate.py::nudge_key`).
257
257
  After that, every later symbol search passes with one line naming the graph tool, while the index stamp equals HEAD (`hooks/tezgah_gate.py::graph_advice`).
258
258
  Claude reads the line as PreToolUse `additionalContext` (`hooks/projects-pretooluse.py`), and omp reads it with the call's `tool_result` (`hosts/omp/tezgah-hook.ts.in`).
259
259
  Codex and Cursor read it with their PostToolUse result (`hosts/codex/hook.py`, `hosts/cursor/hook.py`).
@@ -275,7 +275,7 @@ Read this before filing a security-ish issue; each is a decision, not an oversig
275
275
  outbound effect carrying only the taint notice, with no refusal in between. The notice itself stays (`hooks/tezgah_untrusted.py`).
276
276
  - A write inside the root after a fetch: left to the taint notice that `hooks/tezgah_untrusted.py` rides the first effect with, because it is recoverable from
277
277
  the snapshot.
278
- - `grep -A 3 foo`: a flag with its own value shifts the token, so the search passes unnudged (`hooks/tezgah_gate.py:163-164`).
278
+ - `grep -A 3 foo`: a flag with its own value shifts the token, so the search passes unnudged (`hooks/tezgah_gate.py:165-166`).
279
279
  - A foreign `apply_patch` write: the PostToolUse writer records no path for that row (`hooks/tezgah_gate.py::write_paths`); and the guard skips a row an older writer left with no
280
280
  `target`, because that row names no file (`hooks/tezgah_integrity.py::writers_elsewhere`).
281
281
  - Which rule the user meant when a turn drifts: "a guess about intent wearing a check's clothes" (`hooks/tezgah_gate.py::drift_reason`).
@@ -320,17 +320,17 @@ widen a list a regex already covers.
320
320
  ## The mirror: the opencode plugin
321
321
 
322
322
  opencode cannot run Python hooks, so `hosts/opencode/plugins/tezgah.js` is an independent JavaScript re-implementation of the same rules, dispatched from
323
- `tool.execute.before` (hosts/opencode/plugins/tezgah.js:2373, exported `hosts/opencode/plugins/tezgah.js:2321`) in the gate's own order (`hosts/opencode/plugins/tezgah.js:2373-2535`); its shortcut constants restate the same regexes (`NEUTER` `hosts/opencode/plugins/tezgah.js:270`,
323
+ `tool.execute.before` (hosts/opencode/plugins/tezgah.js:2406, exported `hosts/opencode/plugins/tezgah.js:2342`) in the gate's own order (`hosts/opencode/plugins/tezgah.js:2406-2568`); its shortcut constants restate the same regexes (`NEUTER` `hosts/opencode/plugins/tezgah.js:270`,
324
324
  `SKIP_ENV` hosts/opencode/plugins/tezgah.js:271, `NO_VERIFY` hosts/opencode/plugins/tezgah.js:272, `GITISH` hosts/opencode/plugins/tezgah.js:273, `SKIP_TEST` hosts/opencode/plugins/tezgah.js:515). What keeps the two halves honest is a test, not a shared module:
325
325
  `tests/test_opencode_plugin.py` drives the plugin's hooks through a node harness with a throwaway HOME, and imports the Python `tezgah_integrity.call_id` so a
326
326
  separator or canonical-form drift in the action id fails there instead of silently in a session (`tests/test_opencode_plugin.py:1-7`, `tests/test_opencode_plugin.py:24-27`, `tests/test_opencode_plugin.py::OpenCodePlugin.test_the_id_is_the_hash_the_python_writer_computes`).
327
327
 
328
328
  The shell rule kinds mirrored last are each pinned in both suites: the shell's write body (`SHELL_WRITE` `hosts/opencode/plugins/tezgah.js:979`,
329
329
  `heredocBodies` `hosts/opencode/plugins/tezgah.js:985`, `shellWriteBody` `hosts/opencode/plugins/tezgah.js:1018`, the shortcut and attribution twins at
330
- `hosts/opencode/plugins/tezgah.js:2425-2429` and `hosts/opencode/plugins/tezgah.js:1626`, the credential twin at `hosts/opencode/plugins/tezgah.js:1586-1590`) and the
331
- ordering obligation (`COMMIT_CMD` `hosts/opencode/plugins/tezgah.js:1515`, `ORDER_DENY`
332
- `hosts/opencode/plugins/tezgah.js:1517`, `lastVerify` `hosts/opencode/plugins/tezgah.js:1527`, `orderReason` `hosts/opencode/plugins/tezgah.js:1552`, dispatched at
333
- `hosts/opencode/plugins/tezgah.js:2474-2477`). `lastVerify` folds the tail exactly as `_last_verify` does, minus the `passing_check` narrowing - this rule reads only
330
+ `hosts/opencode/plugins/tezgah.js:2458-2462` and `hosts/opencode/plugins/tezgah.js:1647`, the credential twin at `hosts/opencode/plugins/tezgah.js:1607-1611`) and the
331
+ ordering obligation (`COMMIT_CMD` `hosts/opencode/plugins/tezgah.js:1535`, `ORDER_DENY`
332
+ `hosts/opencode/plugins/tezgah.js:1537`, `lastVerify` `hosts/opencode/plugins/tezgah.js:1548`, `orderReason` `hosts/opencode/plugins/tezgah.js:1573`, dispatched at
333
+ `hosts/opencode/plugins/tezgah.js:2507-2510`). `lastVerify` folds the tail exactly as `_last_verify` does, minus the `passing_check` narrowing - this rule reads only
334
334
  `fail`, and a row that narrowing would refuse reads `ok`/`ran` on both sides, so the outcome is identical.
335
335
 
336
336
  The control rule has no JavaScript copy. The plugin's `CONTROL_CMD` pre-test picks the shell lines that could touch the control plane. The plugin puts each
@@ -357,16 +357,16 @@ rule's triggers are the classes `_stop_block` returns, the four `_shape_block` a
357
357
  | `shortcut` | E7c: with the write tools refused, 3 of 25 armed runs wrote a test skip through a heredoc | an internal benchmark (block E7, not published) | `tests/test_gate.py::test_a_shell_write_that_disables_a_test_is_denied` |
358
358
  | `piped` | a real omp session: 13 checks ran piped and 3 of 6 completion claims were blocked | the Piped section above | `tests/test_gate.py::test_a_piped_check_is_refused_and_pipefail_passes` |
359
359
  | `attribution` | E7c: the same 3 of 25 runs, a credit written through a heredoc | an internal benchmark (block E7, not published) | `tests/test_gate.py::test_attribution_denies_a_credit_written_by_the_shell` |
360
- | `lang` | a plan branch was created as `plan/004-admin-durum-onarimi`, and its slug is in the public history for good | `hooks/tezgah_gate.py:352-361` | `tests/test_gate.py::test_a_turkish_branch_name_is_refused` |
361
- | `race` | two sessions on one file - a parent and its subagent, or two worktrees - landing on a stale read | `hooks/tezgah_gate.py:752-759` | `tests/test_gate.py::test_a_write_to_a_file_another_session_wrote_is_refused` |
360
+ | `lang` | a plan branch was created as `plan/004-admin-durum-onarimi`, and its slug is in the public history for good | `hooks/tezgah_gate.py:354-363` | `tests/test_gate.py::test_a_turkish_branch_name_is_refused` |
361
+ | `race` | two sessions on one file - a parent and its subagent, or two worktrees - landing on a stale read | `hooks/tezgah_gate.py:751-758` | `tests/test_gate.py::test_a_write_to_a_file_another_session_wrote_is_refused` |
362
362
  | `task` | E7: with the unlocking command printed, an armed arm removed or disabled the gate in 25 of 25 runs; E7b: the heredoc redirect in 3 of 25 | the Task section above (the checkpoint refusal) | `tests/test_gate.py::test_a_reading_phase_refuses_a_write_inside_the_allowlist` |
363
363
  | `secret` | E7c: the same 3 of 25 runs, a key written through a heredoc | an internal benchmark (block E7, not published) | `tests/test_gate.py::test_a_credential_written_by_a_heredoc_is_denied` |
364
- | `workspace` | sessions wrote root `plans/`, `research/` and `analysis/` (observed across several unrelated projects on one machine) | `hooks/tezgah_gate.py:1111-1115` | `tests/test_gate.py::test_untracked_root_kinds_are_refused_for_tools_and_redirects` |
365
- | `plan` | a turn wrote four product files - a hook, its tests, its docs and the changelog - straight onto `main` | `hooks/tezgah_gate.py:1391-1399` | `tests/test_gate.py::test_the_third_product_write_on_main_is_refused` |
364
+ | `workspace` | sessions wrote root `plans/`, `research/` and `analysis/` (observed across several unrelated projects on one machine) | `hooks/tezgah_gate.py:1110-1114` | `tests/test_gate.py::test_untracked_root_kinds_are_refused_for_tools_and_redirects` |
365
+ | `plan` | a turn wrote four product files - a hook, its tests, its docs and the changelog - straight onto `main` | `hooks/tezgah_gate.py:1390-1398` | `tests/test_gate.py::test_the_third_product_write_on_main_is_refused` |
366
366
  | `order` | FAVA: the shape found in 90% of real agent-instruction projects ("do not commit before running the tests") | the Ordering section above | `tests/test_gate.py::test_a_commit_is_refused_while_the_newest_check_failed` |
367
367
  | `loop` | designed | the turn's own repeated failure; no incident recorded | `tests/test_gate.py::test_an_identical_failed_call_is_denied_after_the_ceiling` |
368
368
  | `retry` | designed | the session ceiling over the same guard; no incident recorded | `tests/test_gate.py::test_a_fourth_identical_call_is_refused_whatever_the_outcome` |
369
- | `drift` | an internal plan's flip rule on the 2026-09-20 move to a result-channel notice: at 27 sessions the blocked-claim rate in turns under 25 work rows read 112/140 = 0.8000, against 59/142 = 0.4155 before | `hooks/tezgah_gate.py:1284-1296` | `tests/test_gate.py::test_a_long_turn_restates_the_constraints_before_a_write` |
369
+ | `drift` | an internal plan's flip rule on the 2026-09-20 move to a result-channel notice: at 27 sessions the blocked-claim rate in turns under 25 work rows read 112/140 = 0.8000, against 59/142 = 0.4155 before | `hooks/tezgah_gate.py:1283-1295` | `tests/test_gate.py::test_a_long_turn_restates_the_constraints_before_a_write` |
370
370
  | `evidence tampered` | a terminated garbage line made `_parse` raise, and the guard then failed the Stop rule open for the turn; corrupting a `verify_fail` row was an allow route. A `verify_ok` appended with `python3 -c` licensed a claim (security-03), so a pass with no `began` row is an orphan (`hooks/tezgah_integrity.py::_pair`) | `hooks/tezgah_integrity.py::_parse` | `tests/test_integrity.py::test_a_damaged_ledger_blocks_a_done_claim_as_evidence_tampered` |
371
371
  | `check failed` | designed | the newest check failing is its own class; no incident recorded | `tests/test_integrity.py::test_failed_check_blocks` |
372
372
  | `partial failure` | designed | a failure the turn never resolved; no incident recorded | `tests/test_integrity.py::test_an_unresolved_failure_blocks_even_after_an_earlier_pass` |
@@ -400,10 +400,10 @@ rule's triggers are the classes `_stop_block` returns, the four `_shape_block` a
400
400
 
401
401
  ## Source of truth
402
402
 
403
- - `hooks/tezgah_gate.py` — `hooks/tezgah_gate.py::decision`; every rule constant and refusal text `hooks/tezgah_gate.py:162-2157`; `attribution`/`attribution_edit` `hooks/tezgah_gate.py::attribution`, `hooks/tezgah_gate.py::attribution_edit`; `explored`
403
+ - `hooks/tezgah_gate.py` — `hooks/tezgah_gate.py::decision`; every rule constant and refusal text `hooks/tezgah_gate.py:164-2158`; `attribution`/`attribution_edit` `hooks/tezgah_gate.py::attribution`, `hooks/tezgah_gate.py::attribution_edit`; `explored`
404
404
  `hooks/tezgah_gate.py::explored`; `hooks/tezgah_gate.py::searched_symbol`, `hooks/tezgah_gate.py::index_slug`, `hooks/tezgah_gate.py::first_nudge`, `hooks/tezgah_gate.py::nudge_reason`, `hooks/tezgah_gate.py::graph_advice`; `hooks/tezgah_gate.py::LOOP_ATTEMPTS`, `hooks/tezgah_gate.py::loop_reason`,
405
405
  `hooks/tezgah_gate.py::RETRY_CEILING`, `hooks/tezgah_gate.py::retry_reason`; `hooks/tezgah_gate.py::secret_command`, `hooks/tezgah_gate.py::race_reason`,
406
- `hooks/tezgah_gate.py::drift_reason`, `hooks/tezgah_gate.py::effectful`, `hooks/tezgah_gate.py::_deny`; the plan rule's own section `hooks/tezgah_gate.py:1391-1588` — `hooks/tezgah_gate.py::plan_reason`, `hooks/tezgah_gate.py::_branch`, `hooks/tezgah_gate.py::_checkout`, `hooks/tezgah_gate.py::_product`, `hooks/tezgah_gate.py::PLAN_DENY`; `hooks/tezgah_gate.py::write_paths`, `hooks/tezgah_gate.py::shell_target`, `hooks/tezgah_gate.py::SHELL_AS_WRITE`; the `capture` call `hooks/tezgah_gate.py::decision` (a write tool's target, and a shell write's)
406
+ `hooks/tezgah_gate.py::drift_reason`, `hooks/tezgah_gate.py::effectful`, `hooks/tezgah_gate.py::_deny`; the plan rule's own section `hooks/tezgah_gate.py:1390-1587` — `hooks/tezgah_gate.py::plan_reason`, `hooks/tezgah_gate.py::_branch`, `hooks/tezgah_gate.py::_checkout`, `hooks/tezgah_gate.py::_product`, `hooks/tezgah_gate.py::PLAN_DENY`; `hooks/tezgah_gate.py::write_paths`, `hooks/tezgah_gate.py::shell_target`, `hooks/tezgah_gate.py::SHELL_AS_WRITE`; the `capture` call `hooks/tezgah_gate.py::decision` (a write tool's target, and a shell write's)
407
407
  - `hooks/projects-pretooluse.py` — the Claude and dsh envelope; `hooks/tezgah_paths.py` — `off`, `root_for`, `cache_dir`
408
408
  - `hooks/tezgah_integrity.py` — `hooks/tezgah_integrity.py::WRITE_TOOLS`, `hooks/tezgah_integrity.py::BASH_TOOLS`; `NEUTER`/`SKIP_ENV`/`NO_VERIFY`/`GITISH` `hooks/tezgah_integrity.py::NEUTER`, `hooks/tezgah_integrity.py::SKIP_ENV`, `hooks/tezgah_integrity.py::NO_VERIFY`, `hooks/tezgah_integrity.py::GITISH`; `HOOKS_KEY`/`GIT_VALUE_OPTS`/`CONFIG_READS`/`ROUGH_WORDS` `hooks/tezgah_integrity.py::HOOKS_KEY`, `hooks/tezgah_integrity.py::GIT_VALUE_OPTS`, `hooks/tezgah_integrity.py::CONFIG_READS`, `hooks/tezgah_integrity.py::ROUGH_WORDS`; `hooks/tezgah_integrity.py::SKIP_TEST`; `TEST_PATH`
409
409
  `hooks/tezgah_integrity.py::TEST_PATH`; `hooks/tezgah_integrity.py::call_id`; `hooks/tezgah_integrity.py::_path`, `hooks/tezgah_integrity.py::note`, `hooks/tezgah_integrity.py::events`, `hooks/tezgah_integrity.py::writers_elsewhere`, `hooks/tezgah_integrity.py::mask`, `hooks/tezgah_integrity.py::shortcut_command`,
package/docs/glossary.md CHANGED
@@ -22,7 +22,7 @@ The per-session check of tezgah's own hook entries against the install record. `
22
22
  The byte ceiling one injected event may spend: `CONTEXT_BUDGET` per event with `DEFAULT_BUDGET` behind it (`hooks/tezgah_context.py::CONTEXT_BUDGET`), enforced by `budgeted()` (`hooks/tezgah_context.py::budgeted`), which gives up blocks lowest value first along `DROP_ORDER` (`hooks/tezgah_context.py::DROP_ORDER`) - the skill hint among the first, the task phase, the delta and the pointer last - shrinking a block in `SHRINK` (the per-turn relevant lessons, to their first lesson) before it drops it (`hooks/tezgah_context.py::SHRINK`). The core, the reminder, an armed paragraph and the pinned user constraints sit outside `DROP_ORDER`. An event can therefore stay over its budget, and its note says so. The ceiling counts bytes, never tokens, because the module has no tokenizer. Not the [metadata band](#metadata-band), which is the installer's measurement of the same text rather than a runtime bound.
23
23
 
24
24
  ### capture
25
- The pre-write copy the gate takes of every file a write is about to change, `capture()` (`hooks/tezgah_snapshot.py::capture`), called on the gate's allow path (`hooks/tezgah_gate.py::decision`) and capped at `CAP` snapshots and `MAX_BYTES` per file (`hooks/tezgah_snapshot.py:52-53`); a file too large or unreadable is left uncaptured and writes no row, so nothing claims a copy that is not there. Not a transaction: the copy is bytes on disk, not a rollback that runs itself.
25
+ The pre-write copy the gate takes of every file a write is about to change, `capture()` (`hooks/tezgah_snapshot.py::capture`), called on the gate's allow path (`hooks/tezgah_gate.py::decision`) and capped at `CAP` snapshots and `MAX_BYTES` per file (`hooks/tezgah_snapshot.py:55-56`); a file too large or unreadable is left uncaptured and writes no row, so nothing claims a copy that is not there. Not a transaction: the copy is bytes on disk, not a rollback that runs itself.
26
26
 
27
27
  ### claim
28
28
  A ledger [row](#row) recording the [Stop rule](#stop-rule)'s verdict on one reply, `detail` being `ok` or `blocked: <reason class>`, written by `stop_reason()` (`hooks/tezgah_integrity.py::stop_reason`, row at `hooks/tezgah_integrity.py::stop_reason`). Not a deny: nothing ran, the turn simply may not end.
@@ -47,7 +47,7 @@ What one host can do with one rule surface: the gate, the Stop rule or a subagen
47
47
 
48
48
  <a id="ledger"></a>
49
49
  ### evidence ledger
50
- One JSONL file per session recording what ran, `<cache>/evidence/<slug>.jsonl` (`hooks/tezgah_integrity.py::_path`), appended by `note()` (`hooks/tezgah_integrity.py::note`) and read by `events()` (`hooks/tezgah_integrity.py::events`), with its contract in the module docstring (`hooks/tezgah_integrity.py:2-24`) and every reader failing open. Not the [session store](#session-store), which holds only used-tool marks.
50
+ One session's record of what ran: the `evidence` rows of `<cache>/tezgah.db` for that session, keyed by the path its JSONL file had, `<cache>/evidence/<slug>.jsonl` (`hooks/tezgah_integrity.py::_path`, `hooks/tezgah_store.py::EVIDENCE_SCHEMA`), appended by `note()` (`hooks/tezgah_integrity.py::note`) and read by `events()` (`hooks/tezgah_integrity.py::events`), with its contract in the module docstring (`hooks/tezgah_integrity.py:2-24`) and every reader failing open. A read or write of the session first imports a JSONL file still at that path. Not the [session store](#session-store), which holds only used-tool marks.
51
51
 
52
52
  ### false completion
53
53
  The count of [claim](#claim) rows a stop refused, `false_completion` in `counters()`, read against `claims` so the rate is meaningful (`hooks/tezgah_integrity.py::_counts`, `hooks/tezgah_integrity.py::drift_series`). Not the count of [denies](#deny): a deny is the gate refusing a call, a false completion is the Stop rule refusing the end of a turn.
@@ -125,7 +125,7 @@ A directory tezgah is armed over, from `TEZGAH_ROOTS`, the config file or `~/Pro
125
125
  The edge of a root: outside every root the gate returns nothing (`hooks/tezgah_gate.py::decision`), the context builder returns nothing (`hooks/tezgah_context.py::context_for`) and the per-repo extras are omitted from the status line (`hooks/tezgah_context.py::health_segments`). Not a repository boundary.
126
126
 
127
127
  ### row
128
- One line of an [evidence ledger](#evidence-ledger): `{"kind","ts","detail"}` plus whatever the writer knew of `LEDGER_FIELDS` (`id`, `exit`, `out_bytes`, `fail_class`, `workspace`, `source`, `hash`, `changed`, `tool`), written by `note_path()` (`hooks/tezgah_integrity.py::note_path`, `hooks/tezgah_integrity.py::_stored_text`) with `detail` redacted and cut to `DETAIL_MAX` (`hooks/tezgah_integrity.py::DETAIL_MAX`). A missing key means the writer did not know it, never null. `tool` is the call's own name where the host reports one, which is what makes a firing histogram possible: `classify` folds the name into `run`/`edit`, so a row that predates the field carries none.
128
+ One entry of an [evidence ledger](#evidence-ledger), stored as its JSON text: `{"kind","ts","detail"}` plus whatever the writer knew of `LEDGER_FIELDS` (`id`, `exit`, `out_bytes`, `fail_class`, `workspace`, `source`, `hash`, `changed`, `tool`), written by `note_path()` (`hooks/tezgah_integrity.py::note_path`, `hooks/tezgah_integrity.py::_stored_text`) with `detail` redacted and cut to `DETAIL_MAX` (`hooks/tezgah_integrity.py::DETAIL_MAX`). A missing key means the writer did not know it, never null. `tool` is the call's own name where the host reports one, which is what makes a firing histogram possible: `classify` folds the name into `run`/`edit`, so a row that predates the field carries none.
129
129
 
130
130
  ### rule
131
131
  One bold-labelled paragraph of the contract, the unit a [kill switch](#kill-switch) drops and a test pins: `CORE_RULES` pairs each key with the label it must start with (`hooks/tezgah_context.py::CORE_RULES`) and `core_split()` drops exactly the paragraph whose label matches (`hooks/tezgah_context.py::core_split`). Not a [skill](#skill), which the model chooses to read.
@@ -134,7 +134,7 @@ One bold-labelled paragraph of the contract, the unit a [kill switch](#kill-swit
134
134
  One host conversation, identified by the id `session_of()` reads from a payload under whichever name the host uses (`hooks/tezgah_context.py::session_of`), keying the [ledger](#evidence-ledger) filename (`hooks/tezgah_integrity.py::_slug`), the [session store](#session-store) and the per-turn markers (`note_turn()`, `hooks/tezgah_integrity.py::note_turn`). Not a [turn](#turn), of which one session has many.
135
135
 
136
136
  ### session store
137
- The used-tool marks one session wrote, `<cache>/sessions/<slug>.jsonl`, appended by `record()` (`hooks/tezgah_context.py::record`) and read by `used()` (`hooks/tezgah_context.py::used`) to feed the "used" half of the status marks, with a missing kind recorded nowhere at all. Not the [evidence ledger](#evidence-ledger).
137
+ The used-tool marks one session wrote, the `used` rows of `<cache>/tezgah.db`, one per kind, written by `record()` (`hooks/tezgah_context.py::record`) and read by `used()` (`hooks/tezgah_context.py::used`) to feed the "used" half of the status marks, with a missing kind recorded nowhere at all. Not the [evidence ledger](#evidence-ledger).
138
138
 
139
139
  ### skill
140
140
  `skills/<name>/SKILL.md` - frontmatter with `name` and `description`, then instructions the model reads by choice; [skills](skills.md) owns the layer. Not a [rule](#rule), which is injected, and not a [slash command](#slash-command).
package/docs/hosts.md CHANGED
@@ -20,7 +20,7 @@ surface can honestly report this session; the two skill-read marks (`pony`,
20
20
  | claude | SessionStart, SubagentStart, PostCompact, UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, Stop, SubagentStop (record-only, `subagent_end`) - `hooks/hooks.json:2-32` | the managed block in `~/.claude/CLAUDE.md` (`CLAUDE_RULES`), Claude's global memory file; the SessionStart/PostCompact hook rows declare `TEZGAH_CORE_IN_FILE` (`hooks/hooks.json:4,10`) and the hook drops the core when the block is there, so the contract is paid for once (`hooks/projects-auto-init.py::core_is_in_a_file`; the static `output-styles/tezgah.md` mirror (`output-styles/tezgah.md:1`) is the hookless build's copy) | UserPromptSubmit -> `additionalContext` (`hooks/hooks.json:12-14`, `hooks/projects-auto-init.py::main`) | `statusLine` command in `~/.claude/settings.json` (`bin/tezgah-setup::wire_claude_statusline`), ANSI, forwards Orca first (`statusline.py:47-55`) | all seven: the transcript parse covers the two skill reads, `graph`, `orch` and the three shell kinds - `consult`, `research` and the judgement callers (`statusline.py::claude_used`, `observable=None` at `statusline.py::observable`) |
21
21
  | codex | SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, SubagentStart, PostCompact, Stop (`bin/tezgah-setup::CODEX_HOOK_EVENTS`; named in `hosts/codex/hook.py:6-7`) | hook: the normalized events inject `context_for` (`hosts/codex/hook.py::main`) | UserPromptSubmit -> `additionalContext` (`hosts/codex/hook.py::main`) | no custom footer item (`tui.status_line` is a closed enum) - the line rides `systemMessage`, plain, at SessionStart and Stop (`hosts/codex/hook.py:10-12`, `hosts/codex/hook.py::main`) | five tool-use (`TOOL_USE_MEASURES` at `hosts/codex/hook.py::main`) |
22
22
  | cursor | 13 events: sessionStart, preToolUse, postToolUse, postToolUseFailure, subagentStart, subagentStop, beforeSubmitPrompt, stop, beforeMCPExecution, afterShellExecution, afterMCPExecution, afterFileEdit, afterAgentResponse (`bin/tezgah-setup::CURSOR_HOOK_EVENTS`) | hook: sessionStart -> `additional_context` (`hosts/cursor/hook.py::dispatch`) | beforeSubmitPrompt -> `additional_context` plus `{"continue": true}` (`hosts/cursor/hook.py::dispatch`) | `statusLine` in `~/.cursor/cli-config.json` -> `tezgah-statusline --cursor` (`bin/tezgah-setup::install_cursor`), ANSI (`statusline.py:122-124`) | five tool-use (`statusline.py::observable`) |
23
- | opencode | no lifecycle events; plugin hooks: config, permission.ask, tool.execute.before, shell.env, experimental.session.compacting, chat.message, tool.execute.after (`hosts/opencode/plugins/tezgah.js:2328-2681`) - `tool.execute.before` carries the whole gate, `tool.execute.after` the channel on the row, the provenance label on the result and the taint notice on the next effect (`untrustedSource` `hosts/opencode/plugins/tezgah.js:1898`, `labelResult` `:1972`) | static only: generated `instructions` files, because there is no session-start injection point (`hosts/opencode/plugins/tezgah.js:3-6`, `bin/tezgah-setup::opencode_contract_text`) | chat.message pushes a synthetic part (`hosts/opencode/plugins/tezgah.js:2596-2647`) | TUI plugin `tezgah-tui.tsx` declared in `tui.json`, runs `tezgah-status` on the event bus (`hosts/opencode/tui/tezgah-tui.tsx:1-11`; wiring `bin/tezgah-setup::install_opencode`) | all seven: skill reads and kinds are classified in process (`hosts/opencode/plugins/tezgah.js:2183-2219`) |
23
+ | opencode | no lifecycle events; plugin hooks: config, permission.ask, tool.execute.before, shell.env, experimental.session.compacting, chat.message, tool.execute.after (`hosts/opencode/plugins/tezgah.js:2349-2714`) - `tool.execute.before` carries the whole gate, `tool.execute.after` the channel on the row, the provenance label on the result and the taint notice on the next effect (`untrustedSource` `hosts/opencode/plugins/tezgah.js:1919`, `labelResult` `:1993`) | static only: generated `instructions` files, because there is no session-start injection point (`hosts/opencode/plugins/tezgah.js:3-6`, `bin/tezgah-setup::opencode_contract_text`) | chat.message pushes a synthetic part (`hosts/opencode/plugins/tezgah.js:2629-2680`) | TUI plugin `tezgah-tui.tsx` declared in `tui.json`, runs `tezgah-status` on the event bus (`hosts/opencode/tui/tezgah-tui.tsx:1-11`; wiring `bin/tezgah-setup::install_opencode`) | all seven: skill reads and kinds are classified in process (`hosts/opencode/plugins/tezgah.js:2204-2240`) |
24
24
  | dsh | SessionStart, SubagentStart, UserPromptSubmit, PreToolUse, PostToolUse (`hosts/dsh/hooks.json:2-19`); no Stop rule, because the bridge drops every call's outcome and no check there can show a pass | hook: the same Claude-family scripts, named by `configPath` (`bin/tezgah-setup::dsh_patch_block`) | UserPromptSubmit, through the bridge (`hosts/dsh/hooks.json:9-11`) | web-profile plugin: authenticated `GET /api/tezgah.status` -> `tezgah-status` (`hosts/dsh/statusline/lib/index.js:15,66-104`) | five tool-use: the route passes `--observable=consult,research,graph,orch,judge` (`hosts/dsh/statusline/lib/index.js:16-19,84,96`) |
25
25
  | omp | session_start, session_compact, session_switch, turn_end, before_agent_start, tool_call, tool_result, session_stop (`hosts/omp/tezgah-hook.ts.in:541,588,607,612,614,634,653,688`) | both: static `RULES.md` (`bin/tezgah-setup::STATIC_RULES`, `bin/tezgah-setup::rules_block`) and a session payload carrying only what a static file cannot know (`hosts/omp/hook.py::handle`) | before_agent_start returns a hidden message (`hosts/omp/tezgah-hook.ts.in:614-632`) | extension draws `ctx.ui.setWidget` below the editor, `setStatus` as fallback (`hosts/omp/tezgah-hook.ts.in:170-203`); ANSI line from `hosts/omp/hook.py::status_line` | all seven: the embedded runner filters skill reads before asking python (`hosts/omp/tezgah-hook.ts.in:255-262`) and no `observable` is passed (`hosts/omp/hook.py::status_line`) |
26
26
 
@@ -216,7 +216,7 @@ and say which document it came from.
216
216
 
217
217
  A skill read is observable only where the host gives a channel that does not cost
218
218
  a process per read: Claude parses its transcript (`statusline.py::claude_used`),
219
- opencode classifies in process (`hosts/opencode/plugins/tezgah.js:2183-2219`),
219
+ opencode classifies in process (`hosts/opencode/plugins/tezgah.js:2204-2240`),
220
220
  omp filters in its embedded runner before it asks python
221
221
  (`hosts/omp/tezgah-hook.ts.in:255-262`). On codex, cursor and dsh a read is not
222
222
  observable at that price, so their surfaces pass the five tool-use measures as
@@ -339,9 +339,9 @@ In order, each step verified by the one below it:
339
339
  `hosts/dsh/hooks.json` until the bridge carries outcomes.
340
340
  - **opencode carries the untrusted-content control in the plugin**, not by
341
341
  importing `hooks/tezgah_untrusted.py`, which a plugin cannot do in process: the
342
- channel is decided in JS (`untrustedSource` `hosts/opencode/plugins/tezgah.js:1898`,
343
- the tier's argv reader at `:1881-1890`), the label and the taint notice ride the
344
- result the hook is handed (`labelResult` `:1972`). A shared corpus in
342
+ channel is decided in JS (`untrustedSource` `hosts/opencode/plugins/tezgah.js:1919`,
343
+ the tier's argv reader at `:1902-1911`), the label and the taint notice ride the
344
+ result the hook is handed (`labelResult` `:1993`). A shared corpus in
345
345
  `tests/test_opencode_plugin.py::OpenCodePlugin.test_the_classifier_agrees_with_the_python_half_on_a_shared_corpus` drives both halves over the same calls and
346
346
  fails if their answers differ, so neither can move without the other.
347
347
  - **omp carries both untrusted-content marks through the shared core.** Its
@@ -373,16 +373,16 @@ In order, each step verified by the one below it:
373
373
  - **opencode asks the core for the rules it cannot port, and counts the ask.** The
374
374
  task rule's phase and allowlist and the language rule's word list are answered by
375
375
  `bin/tezgah-gate decide` (the recording verb; `check` is the dry run) rather than copied into JavaScript (`IDENT_CMD`
376
- `hosts/opencode/plugins/tezgah.js:585`, the call site `:2459-2462`) - one spawn per write
376
+ `hosts/opencode/plugins/tezgah.js:585`, the call site `:2492-2495`) - one spawn per write
377
377
  and per identifier-creating command. The delegation fails open by design: a missing
378
378
  CLI, a non-zero exit, a broken pipe or a 10 s timeout refuses nothing (`collect`
379
379
  `hosts/opencode/plugins/tezgah.js:152-178`). One `delegation` row now records the class when the core could not
380
- answer (`noteDelegation` `:2283-2289`), so a silent fail-open is countable instead
380
+ answer (`noteDelegation` `:2304-2310`), so a silent fail-open is countable instead
381
381
  of invisible.
382
382
  - **opencode has no session-start hook**, so its always-on file must carry the
383
383
  pointer every hook host appends, and the per-session index, agent and contract
384
384
  refreshes ride the first `chat.message` instead (`bin/tezgah-setup::refresh_contract`,
385
- `hosts/opencode/plugins/tezgah.js:2630-2645`). It denies the native `skill`
385
+ `hosts/opencode/plugins/tezgah.js:2663-2678`). It denies the native `skill`
386
386
  tool - the generated router replaces the injected skill list
387
387
  (`bin/tezgah-setup::install_opencode`) - and `permission.ask` may never be emitted by a
388
388
  given build, which is why `tool.execute.before` stays the enforcing half