@r1z4x/tezgah 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +103 -1
- package/MANIFEST +3 -0
- package/agents/tezgah-reviewer.md +19 -10
- package/bin/tezgah-docs +3 -3
- package/bin/tezgah-doctor +75 -18
- package/bin/tezgah-route +5 -2
- package/bin/tezgah-setup +30 -18
- package/bin/tezgah-status +3 -1
- package/bin/tezgah-task +130 -37
- package/bin/tezgah-taste +74 -55
- package/bin/tezgah-triage +12 -7
- package/docs/architecture.md +4 -4
- package/docs/clarity-baseline.json +9 -9
- package/docs/contract.md +22 -12
- package/docs/environment.md +4 -1
- package/docs/evidence.md +71 -34
- package/docs/gate.md +21 -21
- package/docs/glossary.md +5 -5
- package/docs/hosts.md +8 -8
- package/docs/judge.md +93 -27
- package/docs/operations.md +69 -30
- package/docs/skills.md +2 -2
- package/docs/status-line.md +7 -7
- package/docs/vision.md +10 -5
- package/hooks/tezgah_agents.py +22 -11
- package/hooks/tezgah_attest.py +15 -22
- package/hooks/tezgah_context.py +84 -124
- package/hooks/tezgah_gate.py +26 -23
- package/hooks/tezgah_guard.py +9 -11
- package/hooks/tezgah_integrity.py +77 -178
- package/hooks/tezgah_judge.py +270 -122
- package/hooks/tezgah_lessons.py +18 -63
- package/hooks/tezgah_paths.py +183 -55
- package/hooks/tezgah_policy.py +35 -8
- package/hooks/tezgah_replay.py +57 -33
- package/hooks/tezgah_shapes.py +4 -4
- package/hooks/tezgah_skill_pick.py +6 -35
- package/hooks/tezgah_snapshot.py +25 -46
- package/hooks/tezgah_store.py +1268 -0
- package/hooks/tezgah_task.py +146 -40
- package/hooks/tezgah_taste.py +21 -18
- package/hooks/tezgah_taste_ledger.py +63 -88
- package/hooks/tezgah_update.py +6 -26
- package/hosts/codex/hook.py +5 -0
- package/hosts/cursor/hook.py +19 -31
- package/hosts/opencode/plugins/tezgah.js +309 -108
- package/package.json +1 -1
- package/skills/harness/SKILL.md +4 -0
- package/skills/plan-add/SKILL.md +23 -7
- package/skills/plan-add/render_table.py +3 -2
- package/skills/plan-status/SKILL.md +12 -4
- package/skills/plan-sync/SKILL.md +19 -9
- package/skills/tezgah-contract/SKILL.md +25 -12
package/hooks/tezgah_lessons.py
CHANGED
|
@@ -7,16 +7,16 @@ The session block, the per-turn block and the per-turn digest
|
|
|
7
7
|
ledger here, so they agree on what a lesson is and on its key. A module of its
|
|
8
8
|
own because the gate must not import `tezgah_context` (its import cost, and the
|
|
9
9
|
rule `hooks/tezgah_task.py::repo_root` states): this one imports only
|
|
10
|
-
`tezgah_paths` at load, and `tezgah_gate` lazily, only for a
|
|
11
|
-
gate rule as its enforcer.
|
|
10
|
+
`tezgah_paths` and `tezgah_store` at load, and `tezgah_gate` lazily, only for a
|
|
11
|
+
line that names a gate rule as its enforcer.
|
|
12
12
|
|
|
13
13
|
Stdlib only."""
|
|
14
14
|
import hashlib
|
|
15
|
-
import json
|
|
16
15
|
import os
|
|
17
16
|
import re
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
import tezgah_store
|
|
19
|
+
from tezgah_paths import off
|
|
20
20
|
|
|
21
21
|
# A line carries its metadata after its text, never in it: from the first
|
|
22
22
|
# `|| check:` or `|| enforced_by:` clause outside a code span to the end of the
|
|
@@ -114,73 +114,28 @@ def is_ledger(path):
|
|
|
114
114
|
and os.path.basename(os.path.dirname(path)) == ".tezgah")
|
|
115
115
|
|
|
116
116
|
|
|
117
|
-
def
|
|
118
|
-
"""The
|
|
119
|
-
each keyed row
|
|
120
|
-
later session reads one
|
|
121
|
-
ledger on the machine (3173 files, 30 MB on the
|
|
122
|
-
2026-10-07)."""
|
|
123
|
-
|
|
124
|
-
return os.path.join(cache_dir(), "lessons", name.hexdigest()[:16] + ".jsonl")
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def _index_rows(path):
|
|
128
|
-
"""The parseable keyed rows of an index file, oldest first; [] without one.
|
|
129
|
-
A damaged line costs only itself."""
|
|
130
|
-
rows = []
|
|
131
|
-
try:
|
|
132
|
-
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
133
|
-
for ln in fh:
|
|
134
|
-
try:
|
|
135
|
-
row = json.loads(ln)
|
|
136
|
-
except ValueError:
|
|
137
|
-
continue
|
|
138
|
-
if isinstance(row, dict) and row.get("key"):
|
|
139
|
-
rows.append(row)
|
|
140
|
-
except OSError:
|
|
141
|
-
pass
|
|
142
|
-
return rows
|
|
117
|
+
def taint_root(root):
|
|
118
|
+
"""The key of the repository at `root` in the `lesson_taint` store: the gate
|
|
119
|
+
keeps each keyed row there as well as in the session's ledger, so the
|
|
120
|
+
context of a later session reads one repository's rows for the per-line
|
|
121
|
+
label instead of every ledger on the machine (3173 files, 30 MB on the
|
|
122
|
+
measuring machine, 2026-10-07)."""
|
|
123
|
+
return hashlib.sha1(os.path.realpath(root).encode("utf-8", "replace")).hexdigest()[:16]
|
|
143
124
|
|
|
144
125
|
|
|
145
126
|
def write_taint(root, keep, rows):
|
|
146
127
|
"""Rewrite the index of the repository at `root`: the rows it holds whose key
|
|
147
|
-
is in `keep` (the ledger's lines plus the write's new ones), then `rows
|
|
148
|
-
the
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
path = taint_path(root)
|
|
152
|
-
kept = [r for r in _index_rows(path) if r["key"] in keep] + rows
|
|
153
|
-
tmp = "%s.%d.tmp" % (path, os.getpid())
|
|
154
|
-
try:
|
|
155
|
-
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
|
156
|
-
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
157
|
-
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
158
|
-
fh.write("".join(json.dumps(r) + "\n" for r in kept))
|
|
159
|
-
os.replace(tmp, path)
|
|
160
|
-
except OSError:
|
|
161
|
-
pass
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
# The index as last read, per (path, mtime, size): the session block, the
|
|
165
|
-
# per-turn block and the digest of one prompt each ask for it, and a hook
|
|
166
|
-
# process serves one prompt.
|
|
167
|
-
_TAINTED = {}
|
|
128
|
+
is in `keep` (the ledger's lines plus the write's new ones), then `rows`, in
|
|
129
|
+
one transaction. So the index never outgrows the ledger it labels. Best
|
|
130
|
+
effort, like a ledger row."""
|
|
131
|
+
tezgah_store.write_taint(taint_root(root), keep, rows)
|
|
168
132
|
|
|
169
133
|
|
|
170
134
|
def tainted(root):
|
|
171
135
|
"""{key: source} for the lessons of the repository at `root` that a gate
|
|
172
|
-
recorded as written in a turn that had read untrusted text (`
|
|
173
|
-
{} when there is none.
|
|
174
|
-
|
|
175
|
-
try:
|
|
176
|
-
st = os.stat(path)
|
|
177
|
-
except OSError:
|
|
178
|
-
return {}
|
|
179
|
-
key = (path, st.st_mtime_ns, st.st_size)
|
|
180
|
-
if key not in _TAINTED:
|
|
181
|
-
_TAINTED[key] = {str(r["key"]): str(r.get("source") or "")
|
|
182
|
-
for r in _index_rows(path)}
|
|
183
|
-
return _TAINTED[key]
|
|
136
|
+
recorded as written in a turn that had read untrusted text (`taint_root`);
|
|
137
|
+
{} when there is none."""
|
|
138
|
+
return tezgah_store.taint(taint_root(root))
|
|
184
139
|
|
|
185
140
|
|
|
186
141
|
def lines(root, retired=None):
|
package/hooks/tezgah_paths.py
CHANGED
|
@@ -499,32 +499,129 @@ def have_typesafe_key():
|
|
|
499
499
|
def have_judge_key():
|
|
500
500
|
"""True when tezgah's own judgement seam can ask anyone: the session's own
|
|
501
501
|
CLI (`session_cli()`), else - when the `fallback` setting lets a third party
|
|
502
|
-
answer -
|
|
503
|
-
two channels (`OPENROUTER_API_KEY`,
|
|
504
|
-
the channels `hooks/tezgah_judge.py`'s
|
|
505
|
-
what `bin/tezgah-triage`,
|
|
506
|
-
|
|
502
|
+
answer - a chosen Jev carrier's credential (`jev_carriers()`) or the
|
|
503
|
+
OpenRouter chat fallback's own two channels (`OPENROUTER_API_KEY`,
|
|
504
|
+
`~/.config/openrouter/key`), which are the channels `hooks/tezgah_judge.py`'s
|
|
505
|
+
`providers()` picks between. This is what `bin/tezgah-triage`,
|
|
506
|
+
`bin/tezgah-docs` and the skill picker spend.
|
|
507
507
|
|
|
508
508
|
Kept next to `have_typesafe_key()` because the two are asked together and a
|
|
509
509
|
report that showed one answer under both questions was wrong in both
|
|
510
|
-
directions - a key file alone
|
|
511
|
-
|
|
512
|
-
|
|
510
|
+
directions - a key file alone serves the seam and not omp. A blank file is
|
|
511
|
+
not a credential here either, so this stays in step with the seam's own
|
|
512
|
+
`strip() or None`."""
|
|
513
513
|
if session_cli():
|
|
514
514
|
return True
|
|
515
515
|
if fallback_policy() == "none":
|
|
516
516
|
return False
|
|
517
|
-
for
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
517
|
+
return bool(openrouter_key() or any(s for _, s in jev_carriers()))
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def key_from(envs, path):
|
|
521
|
+
"""The first non-blank env var of `envs`, else the file at `path` (`~`
|
|
522
|
+
expanded), else None - stripped either way, because a newline inside an
|
|
523
|
+
Authorization header is an injection, not a typo. The file is the channel a
|
|
524
|
+
hook needs: it runs where `~/.zshenv` never exported the variable."""
|
|
525
|
+
for env in envs:
|
|
526
|
+
value = os.environ.get(env, "").strip()
|
|
527
|
+
if value:
|
|
528
|
+
return value
|
|
529
|
+
try:
|
|
530
|
+
with open(os.path.expanduser(path), encoding="utf-8") as fh:
|
|
531
|
+
return fh.read().strip() or None
|
|
532
|
+
except OSError:
|
|
533
|
+
return None
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def omp_login_key(provider):
|
|
537
|
+
"""The API key omp's login store holds for `provider` (`omp auth login`),
|
|
538
|
+
stripped, else None: the newest enabled `api_key` row's `data.key`, read
|
|
539
|
+
read-only. Any error - no store, another schema, a locked file - is None."""
|
|
540
|
+
import sqlite3 # deferred: see have_typesafe_key()
|
|
541
|
+
store = os.path.join(os.path.expanduser("~"), ".omp", "agent", "agent.db")
|
|
542
|
+
try:
|
|
543
|
+
conn = sqlite3.connect("file:%s?mode=ro" % store, uri=True)
|
|
521
544
|
try:
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
545
|
+
rows = conn.execute(
|
|
546
|
+
"select data from auth_credentials where provider = ? and "
|
|
547
|
+
"disabled_cause is null order by id desc", (provider,)).fetchall()
|
|
548
|
+
finally:
|
|
549
|
+
conn.close()
|
|
550
|
+
except (sqlite3.Error, OSError):
|
|
551
|
+
return None
|
|
552
|
+
for (data,) in rows:
|
|
553
|
+
try:
|
|
554
|
+
value = json.loads(data).get("key")
|
|
555
|
+
except (TypeError, ValueError, AttributeError):
|
|
556
|
+
continue
|
|
557
|
+
if isinstance(value, str) and value.strip():
|
|
558
|
+
return value.strip()
|
|
559
|
+
return None
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def typesafe_key():
|
|
563
|
+
"""TypeSafe's credential: TYPESAFE_API_KEY, else `~/.config/typesafe/key`,
|
|
564
|
+
else the active omp login (`omp_login_key("typesafe")`), else None."""
|
|
565
|
+
return (key_from(("TYPESAFE_API_KEY",), "~/.config/typesafe/key")
|
|
566
|
+
or omp_login_key("typesafe"))
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def openrouter_key():
|
|
570
|
+
"""OpenRouter's credential: OPENROUTER_API_KEY, else its key file."""
|
|
571
|
+
return key_from(("OPENROUTER_API_KEY",), "~/.config/openrouter/key")
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def cloudflare_account():
|
|
575
|
+
"""The Workers AI account id: CLOUDFLARE_ACCOUNT_ID, else its file."""
|
|
576
|
+
return key_from(("CLOUDFLARE_ACCOUNT_ID",), "~/.config/cloudflare/account_id")
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def cloudflare_token():
|
|
580
|
+
"""The Workers AI token: JEV_CLOUDFLARE_API_TOKEN, else CLOUDFLARE_API_TOKEN,
|
|
581
|
+
else `~/.config/cloudflare/token`."""
|
|
582
|
+
return key_from(("JEV_CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN"),
|
|
583
|
+
"~/.config/cloudflare/token")
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# The Jev carrier a user may choose (`jev_choice()`), and the order `auto`
|
|
587
|
+
# tries them in: TypeSafe direct, OpenRouter's System One endpoint, Cloudflare
|
|
588
|
+
# Workers AI, any System One-compatible URL (JEV_API_BASE_URL + JEV_API_KEY,
|
|
589
|
+
# the names the public jev-mcp server reads), then Cloudflare's own System One
|
|
590
|
+
# models Clef and Clef-flash on Workers AI (the same Cloudflare credential).
|
|
591
|
+
JEV_CHOICES = ("auto", "typesafe", "openrouter", "cloudflare", "compatible",
|
|
592
|
+
"clef", "clef-flash")
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def jev_choice():
|
|
596
|
+
"""TEZGAH_JEV_PROVIDER, else JEV_PROVIDER (jev-mcp's name), else config.json
|
|
597
|
+
`jev`, else `auto` - lowercased. An unknown value is kept, so it resolves to
|
|
598
|
+
no carrier instead of silently to another one."""
|
|
599
|
+
for env in ("TEZGAH_JEV_PROVIDER", "JEV_PROVIDER"):
|
|
600
|
+
value = os.environ.get(env, "").strip().lower()
|
|
601
|
+
if value:
|
|
602
|
+
return value
|
|
603
|
+
cfg = config()
|
|
604
|
+
value = cfg.get("jev") if isinstance(cfg, dict) else None
|
|
605
|
+
value = value.strip().lower() if isinstance(value, str) else ""
|
|
606
|
+
return value or "auto"
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def jev_carriers():
|
|
610
|
+
"""[(carrier, secret), ...] the chosen Jev carriers in the order the seam
|
|
611
|
+
asks them. `auto`: every carrier whose credential resolves. A named one: that
|
|
612
|
+
carrier alone, its secret None when it does not resolve - the seam records
|
|
613
|
+
why rather than substituting another carrier."""
|
|
614
|
+
compatible = (os.environ.get("JEV_API_KEY", "").strip() or None
|
|
615
|
+
if os.environ.get("JEV_API_BASE_URL", "").strip() else None)
|
|
616
|
+
workers_ai = cloudflare_token() if cloudflare_account() else None
|
|
617
|
+
found = [("typesafe", typesafe_key()), ("jev-openrouter", openrouter_key()),
|
|
618
|
+
("jev-cloudflare", workers_ai), ("jev-compatible", compatible),
|
|
619
|
+
("clef", workers_ai), ("clef-flash", workers_ai)]
|
|
620
|
+
choice = jev_choice()
|
|
621
|
+
if choice == "auto":
|
|
622
|
+
return [(name, secret) for name, secret in found if secret]
|
|
623
|
+
name = choice if choice in ("typesafe", "clef", "clef-flash") else "jev-" + choice
|
|
624
|
+
return [(name, dict(found).get(name))]
|
|
528
625
|
|
|
529
626
|
|
|
530
627
|
# How far a model call may leave the session's own vendor (config.json
|
|
@@ -544,16 +641,39 @@ def fallback_policy():
|
|
|
544
641
|
return value if value in FALLBACKS else "vendor"
|
|
545
642
|
|
|
546
643
|
|
|
644
|
+
# The session CLIs the judgement seam can ask, in detection order, each with
|
|
645
|
+
# the env vars its host sets in the processes it spawns. omp sets CLAUDECODE
|
|
646
|
+
# too, so it comes first. opencode sets OPENCODE=1 in its own process at start
|
|
647
|
+
# (`process.env.OPENCODE="1"` in the 1.18.31 binary), so its plugin's children
|
|
648
|
+
# and its shell tool inherit it. cursor-agent gives its shell tool CURSOR_AGENT=1
|
|
649
|
+
# and its hooks CURSOR_VERSION (`buildHookEnvironment` in the 2026.10.01
|
|
650
|
+
# bundle). codex gives its shell tool CODEX_THREAD_ID (codex-rs
|
|
651
|
+
# protocol/src/shell_environment.rs `populate_env`); its hooks get only codex's
|
|
652
|
+
# own process env (codex-rs hooks/src/registry.rs), so a codex hook names no
|
|
653
|
+
# session - `judge_cli` picks it there.
|
|
654
|
+
SESSION_CLIS = (("omp", ("OMPCODE",)), ("claude", ("CLAUDECODE",)),
|
|
655
|
+
("opencode", ("OPENCODE",)), ("cursor", ("CURSOR_AGENT", "CURSOR_VERSION")),
|
|
656
|
+
("codex", ("CODEX_THREAD_ID",)))
|
|
657
|
+
|
|
658
|
+
|
|
547
659
|
def session_cli():
|
|
548
|
-
"""
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
660
|
+
"""The session CLI the judgement seam asks - `omp`, `claude`, `opencode`,
|
|
661
|
+
`cursor` or `codex` - else None.
|
|
662
|
+
|
|
663
|
+
TEZGAH_JUDGE_CLI, else config.json `judge_cli`, picks one by name; `auto`
|
|
664
|
+
(the default, and any other value) reads the host this process runs under:
|
|
665
|
+
the first CLI in SESSION_CLIS whose marker is set. Either way the answer is
|
|
666
|
+
None when that CLI's binary is missing - an omp session is not handed to
|
|
667
|
+
whatever `claude` is logged in as, and a picked CLI has no stand-in."""
|
|
668
|
+
bins = {"omp": omp_bin, "claude": claude_bin, "opencode": opencode_bin,
|
|
669
|
+
"cursor": cursor_bin, "codex": codex_bin}
|
|
670
|
+
cfg = config()
|
|
671
|
+
pick = (os.environ.get("TEZGAH_JUDGE_CLI", "").strip()
|
|
672
|
+
or (cfg.get("judge_cli") if isinstance(cfg, dict) else None))
|
|
673
|
+
if not isinstance(pick, str) or pick not in bins:
|
|
674
|
+
pick = next((name for name, markers in SESSION_CLIS
|
|
675
|
+
if any(os.environ.get(m) for m in markers)), None)
|
|
676
|
+
return pick if pick and bins[pick]() else None
|
|
557
677
|
|
|
558
678
|
|
|
559
679
|
def orx_bin():
|
|
@@ -584,6 +704,24 @@ def claude_bin():
|
|
|
584
704
|
return which_user(os.environ.get("TEZGAH_CLAUDE_BIN") or "claude")
|
|
585
705
|
|
|
586
706
|
|
|
707
|
+
def opencode_bin():
|
|
708
|
+
"""The opencode CLI (TEZGAH_OPENCODE_BIN, else `opencode`), or None - the
|
|
709
|
+
lookup `omp_bin()` makes."""
|
|
710
|
+
return which_user(os.environ.get("TEZGAH_OPENCODE_BIN") or "opencode")
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def cursor_bin():
|
|
714
|
+
"""Cursor's agent CLI (TEZGAH_CURSOR_BIN, else `cursor-agent`), or None -
|
|
715
|
+
the lookup `omp_bin()` makes."""
|
|
716
|
+
return which_user(os.environ.get("TEZGAH_CURSOR_BIN") or "cursor-agent")
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def codex_bin():
|
|
720
|
+
"""The Codex CLI (TEZGAH_CODEX_BIN, else `codex`), or None - the lookup
|
|
721
|
+
`omp_bin()` makes."""
|
|
722
|
+
return which_user(os.environ.get("TEZGAH_CODEX_BIN") or "codex")
|
|
723
|
+
|
|
724
|
+
|
|
587
725
|
# The values `reply_lang` (config.json) takes. The value itself is the switch:
|
|
588
726
|
# `tr` (the default, and what an install without the key reads) asks for Turkish
|
|
589
727
|
# replies, `en` for English ones, and `any` drops the Stop language check.
|
|
@@ -612,50 +750,40 @@ def reply_lang():
|
|
|
612
750
|
# `authorized` row, the stand-down text) - the control rule refuses the routes
|
|
613
751
|
# the gate sees, and an interpreter writing them is SECURITY.md's residual.
|
|
614
752
|
STAND_DOWN = "tezgah-setup --uninstall --full\n"
|
|
615
|
-
_LATCH = {"ledger": None, "since": None
|
|
753
|
+
_LATCH = {"ledger": None, "since": None}
|
|
616
754
|
|
|
617
755
|
|
|
618
756
|
def latch(ledger):
|
|
619
757
|
"""Bind this process's `off()` to one session's ledger path (None unbinds)."""
|
|
620
|
-
_LATCH.update(ledger=ledger, since=None
|
|
758
|
+
_LATCH.update(ledger=ledger, since=None)
|
|
621
759
|
|
|
622
760
|
|
|
623
761
|
def _first_ts(ledger):
|
|
762
|
+
import tezgah_store # deferred: an unbound process never opens the ledger
|
|
624
763
|
try:
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
except (OSError, ValueError, AttributeError):
|
|
764
|
+
ts = json.loads(tezgah_store.evidence_first(ledger) or "null").get("ts")
|
|
765
|
+
except (*tezgah_store.ERRORS, ValueError, AttributeError):
|
|
628
766
|
return None
|
|
629
767
|
return ts if isinstance(ts, (int, float)) else None
|
|
630
768
|
|
|
631
769
|
|
|
632
770
|
def _authorized(ledger):
|
|
633
|
-
"""The switch names this session's `authorized` rows carry
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
size = os.path.getsize(ledger)
|
|
637
|
-
except OSError:
|
|
638
|
-
return frozenset()
|
|
639
|
-
memo = _LATCH["auth"]
|
|
640
|
-
if memo and memo[0] == size:
|
|
641
|
-
return memo[1]
|
|
771
|
+
"""The switch names this session's `authorized` rows carry: read only when
|
|
772
|
+
a switch is newer than the first row."""
|
|
773
|
+
import tezgah_store
|
|
642
774
|
names = set()
|
|
643
775
|
try:
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
if '"authorized"' not in line:
|
|
647
|
-
continue
|
|
648
|
-
try:
|
|
649
|
-
row = json.loads(line)
|
|
650
|
-
except ValueError:
|
|
651
|
-
continue
|
|
652
|
-
if row.get("kind") == "authorized" and isinstance(
|
|
653
|
-
row.get("authorized"), list):
|
|
654
|
-
names.update(n for n in row["authorized"] if isinstance(n, str))
|
|
655
|
-
except OSError:
|
|
776
|
+
found = tezgah_store.evidence_rows(ledger, kind="authorized")
|
|
777
|
+
except tezgah_store.ERRORS:
|
|
656
778
|
return frozenset()
|
|
657
|
-
|
|
658
|
-
|
|
779
|
+
for text in found:
|
|
780
|
+
try:
|
|
781
|
+
row = json.loads(text)
|
|
782
|
+
except ValueError:
|
|
783
|
+
continue
|
|
784
|
+
if isinstance(row, dict) and isinstance(row.get("authorized"), list):
|
|
785
|
+
names.update(n for n in row["authorized"] if isinstance(n, str))
|
|
786
|
+
return frozenset(names)
|
|
659
787
|
|
|
660
788
|
|
|
661
789
|
def _honored(path, name):
|
package/hooks/tezgah_policy.py
CHANGED
|
@@ -237,7 +237,15 @@ rewrite it plainly.
|
|
|
237
237
|
on a merge. Review first with a FRESH reviewer (never my own read of my own
|
|
238
238
|
code), run the full suite, and if the review is clean and every test passes,
|
|
239
239
|
merge, move the plan to `.tezgah/plans/done/`, and report the outcome. Bring it back
|
|
240
|
-
to the user only on a critical or
|
|
240
|
+
to the user only on a critical or major finding left after round two, or a
|
|
241
|
+
failing test. One review per plan, over its whole diff in the verification
|
|
242
|
+
phase; at most two rounds - only a confirmed critical or major finding opens
|
|
243
|
+
round two, a minor or suggestion one is recorded fix-later, and after round two
|
|
244
|
+
what is left goes to the user or is recorded fix-later, never a third round.
|
|
245
|
+
A round spawns its reviewers in ONE message, one per dimension (correctness
|
|
246
|
+
and contract, security, tests and performance) or file group; a diff under 300
|
|
247
|
+
changed lines gets one reviewer. Per-task work gets tests, not a reviewer, and
|
|
248
|
+
`tezgah-task review` refuses a third round.
|
|
241
249
|
|
|
242
250
|
**Attribution: none, anywhere, ever.** Nothing you persist or publish may name
|
|
243
251
|
the assistant, model, vendor or "AI" as author, co-author, generator or helper -
|
|
@@ -283,9 +291,11 @@ independent work remains. Fan out only when
|
|
|
283
291
|
the subtasks share no mutable file and no interface: if two of them would edit
|
|
284
292
|
the same file, or one's answer decides the other's, keep them in one context or
|
|
285
293
|
sequence them. The shared artifact is the coordination channel, not chatter -
|
|
286
|
-
naming a lead coordinates nothing by itself.
|
|
287
|
-
|
|
288
|
-
|
|
294
|
+
naming a lead coordinates nothing by itself. Only a single item - one file, one
|
|
295
|
+
bounded change, a strictly serial chain - is done directly; a list of
|
|
296
|
+
independent items is never worked one by one on the main thread, however small
|
|
297
|
+
each item is, and that holds at every phase: a plan's tasks, its acceptance
|
|
298
|
+
commands and the sources of a survey fan out the same way.
|
|
289
299
|
A slice that needs its own checkout: inside Orca (the session-start `Orca:`
|
|
290
300
|
line) create it with `orca worktree create --name <slug> --parent-worktree
|
|
291
301
|
active --json` and track it with `orca worktree ps --json` or `tezgah-status
|
|
@@ -330,7 +340,8 @@ guards against.
|
|
|
330
340
|
|
|
331
341
|
### The gate, for anything either tier produces
|
|
332
342
|
Read the diff yourself. Then tests, lint and type check must pass, and anything
|
|
333
|
-
non-trivial gets an independent review before it lands
|
|
343
|
+
non-trivial gets an independent review before it lands - once per plan, not
|
|
344
|
+
per task or commit. A delegate's output is
|
|
334
345
|
a draft until the router has evidence; "the agent said it works" is not
|
|
335
346
|
evidence.
|
|
336
347
|
|
|
@@ -897,8 +908,14 @@ project root and never `git add -f` anything under `.tezgah/`. Off:
|
|
|
897
908
|
**Parallel by default: fan out independent items.** A request with two or
|
|
898
909
|
more independent items - a numbered or bulleted list, several files or areas, a
|
|
899
910
|
todo list whose entries share no file and no interface - is delegated without
|
|
900
|
-
being asked: one subagent per item
|
|
901
|
-
|
|
911
|
+
being asked: count the independent items, spawn one subagent per item in ONE
|
|
912
|
+
message, and keep the main thread for decomposing, integrating, verifying and
|
|
913
|
+
reporting. It holds at every phase, not only the first message: a plan's tasks,
|
|
914
|
+
its acceptance commands and the sources of a survey are items too, and checks
|
|
915
|
+
that share no state are tool calls in one message, never a queue (one suite run
|
|
916
|
+
at a time: two runs of one suite storm the CPU and race on its temp state).
|
|
917
|
+
No item is too small to delegate while another runs beside it; only a single
|
|
918
|
+
item is done inline. Every writing slice gets
|
|
902
919
|
its own branch and checkout - inside Orca `orca worktree create --name <slug>
|
|
903
920
|
--parent-worktree active --json`, else `git worktree add -b <branch> <absolute
|
|
904
921
|
path>`; its brief names every read, edit and shell `cwd` as an absolute path
|
|
@@ -907,7 +924,8 @@ not grow. Read-only slices need no worktree. Pick each agent by task class from
|
|
|
907
924
|
the specialists line; `{ROUTE_BIN}` names the tier for code work. Serialize
|
|
908
925
|
only a real dependency - one slice's output is another's input, or two slices
|
|
909
926
|
edit one file - and say which. Never wait idle on a spawned slice while
|
|
910
|
-
independent work remains.
|
|
927
|
+
independent work remains. A subagent does its own slice and never fans out
|
|
928
|
+
again. Off: `orchestrate-off`.
|
|
911
929
|
|
|
912
930
|
**Kill switches:** each one removes its own rule from this text, not just the
|
|
913
931
|
status mark. `~/.config/tezgah/`: `exec-mode.off`, `orchestrate-off`,
|
|
@@ -928,6 +946,15 @@ The ponytail intensity level is not a switch: `tezgah-pony lite|full|ultra`.
|
|
|
928
946
|
# CORE_RULES labels in hooks/tezgah_context.py.
|
|
929
947
|
CONDITIONAL_KEYS = ("spec", "consult", "research", "product", "graph", "fanout")
|
|
930
948
|
|
|
949
|
+
# The fan-out rule's action, which the one-line repeat of an armed paragraph
|
|
950
|
+
# (hooks/tezgah_context.armed_again) carries: a bare "Armed again" named a rule
|
|
951
|
+
# the session had to recall, and the second list of a session was worked one
|
|
952
|
+
# item at a time. It is the paragraph's own clause, cut to one sentence.
|
|
953
|
+
FANOUT_ACTION = ("count the independent items, spawn one subagent per item in "
|
|
954
|
+
"ONE message (each writing slice in its own worktree, absolute "
|
|
955
|
+
"paths), and keep the main thread for integrating and verifying;"
|
|
956
|
+
" a subagent does its own slice and never fans out again.")
|
|
957
|
+
|
|
931
958
|
# The always-on replacement for the conditional paragraphs: one line each so a
|
|
932
959
|
# host without a per-turn hook still knows the rule exists and where the full
|
|
933
960
|
# text lives.
|