@r1z4x/tezgah 1.0.0 → 1.1.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 +71 -1
- package/MANIFEST +8 -0
- package/bin/tezgah-agents +10 -1
- package/bin/tezgah-docs +1 -1
- package/bin/tezgah-doctor +11 -0
- package/bin/tezgah-research +31 -14
- package/bin/tezgah-setup +154 -29
- package/bin/tezgah-skill +59 -0
- package/bin/tezgah-skill.py +59 -0
- package/bin/tezgah-status +62 -1
- package/bin/tezgah-triage +5 -3
- package/docs/README.md +1 -0
- package/docs/architecture.md +5 -2
- package/docs/citations-baseline.json +1 -1
- package/docs/clarity-baseline.json +9 -9
- package/docs/contract.md +68 -26
- package/docs/environment.md +6 -3
- package/docs/evidence.md +7 -0
- package/docs/gate.md +13 -7
- package/docs/glossary.md +1 -1
- package/docs/hosts.md +48 -1
- package/docs/index.json +29 -1
- package/docs/judge.md +61 -55
- package/docs/models.md +14 -8
- package/docs/operations.md +10 -5
- package/docs/orca.md +126 -0
- package/docs/research.md +38 -3
- package/docs/skills.md +74 -0
- package/docs/status-line.md +3 -1
- package/docs/testing.md +33 -21
- package/docs/vision.md +12 -8
- package/hooks/projects-pretooluse.py +17 -5
- package/hooks/tezgah_agents.py +135 -13
- package/hooks/tezgah_components.py +3 -0
- package/hooks/tezgah_context.py +65 -22
- package/hooks/tezgah_gate.py +133 -17
- package/hooks/tezgah_integrity.py +60 -13
- package/hooks/tezgah_judge.py +239 -56
- package/hooks/tezgah_models.py +41 -5
- package/hooks/tezgah_orca.py +144 -0
- package/hooks/tezgah_paths.py +40 -6
- package/hooks/tezgah_policy.py +109 -35
- package/hooks/tezgah_research.py +104 -8
- package/hooks/tezgah_skill_pick.py +459 -8
- package/hooks/tezgah_stack.py +382 -0
- package/hooks/tezgah_stopspec.py +2 -2
- package/hosts/codex/hook.py +7 -1
- package/hosts/cursor/hook.py +7 -4
- package/hosts/omp/hook.py +12 -6
- package/hosts/omp/tezgah-hook.ts.in +20 -7
- package/hosts/opencode/plugins/tezgah.js +18 -9
- package/output-styles/tezgah.md +34 -19
- package/package.json +1 -1
- package/skills/i-have-adhd/SKILL.md +2 -2
- package/skills/research/SKILL.md +60 -17
- package/skills/tezgah-contract/SKILL.md +55 -13
package/hooks/tezgah_judge.py
CHANGED
|
@@ -29,15 +29,19 @@ non-interactive shell where `.zshenv` never runs - the export is absent there,
|
|
|
29
29
|
and the key file is the channel that survives. That is why the file is read
|
|
30
30
|
rather than the environment trusted.
|
|
31
31
|
|
|
32
|
-
Behind the credential there are
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
Behind the credential there are three providers, asked in this order. First the
|
|
33
|
+
session's own CLI - `omp` or `claude`, the host this process runs under
|
|
34
|
+
(`tp.session_cli()`) - run headless with no tools, so the judgement is paid
|
|
35
|
+
with the session's own credential, OAuth subscription included, and answered by
|
|
36
|
+
the session's own vendor. TypeSafe and OpenRouter are third parties: they are
|
|
37
|
+
asked only as the `fallback` setting allows (`tp.fallback_policy()`): by
|
|
38
|
+
default (`vendor`) only when there is no session CLI at all, with `any` also
|
|
39
|
+
after the session CLI failed, with `none` never. A third party that answers,
|
|
40
|
+
and a fallback that was refused, is never silent: one stderr line, the
|
|
41
|
+
`fallback` field on the result, and the last-use record `tezgah-status
|
|
42
|
+
--judge` prints (`_record`, `last_use`). A reply that does not carry a
|
|
43
|
+
question's answer leaves that question absent, which every caller already reads
|
|
44
|
+
as no judgement.
|
|
41
45
|
|
|
42
46
|
Kill switch: `judge-off`, honoured by `available()`, so a caller can fall back to
|
|
43
47
|
its own deterministic path without knowing why the judgement is gone. It is the
|
|
@@ -148,58 +152,113 @@ def fallback_model():
|
|
|
148
152
|
return os.environ.get("TEZGAH_JUDGE_MODEL", "").strip() or cheap_default()
|
|
149
153
|
|
|
150
154
|
|
|
151
|
-
|
|
152
|
-
|
|
155
|
+
# The session CLIs the seam can ask, as the argv between the binary and the
|
|
156
|
+
# prompt: no tools, no session file, no project rules, skills, extensions, MCP
|
|
157
|
+
# servers or settings, and the chat fallback's own contract as the system
|
|
158
|
+
# prompt. Measured 2026-10-07 on a one-word answer: claude went from 37,038
|
|
159
|
+
# prompt tokens ($0.30) to 725 ($0.007); omp from 43,735 written to cache
|
|
160
|
+
# ($0.35) to about 37,500, 36,700 of them read from cache ($0.015) - omp has
|
|
161
|
+
# no flag here that drops the rest.
|
|
162
|
+
SESSION_ARGV = {
|
|
163
|
+
"omp": ["-p", "--no-tools", "--no-lsp", "--no-session", "--no-rules",
|
|
164
|
+
"--no-skills", "--no-extensions", "--no-title", "--thinking", "off",
|
|
165
|
+
"--mode", "json", "--system-prompt", CHAT_SYSTEM],
|
|
166
|
+
"claude": ["-p", "--tools", "", "--no-session-persistence",
|
|
167
|
+
"--strict-mcp-config", "--disable-slash-commands",
|
|
168
|
+
"--setting-sources", "", "--output-format", "json",
|
|
169
|
+
"--system-prompt", CHAT_SYSTEM],
|
|
170
|
+
}
|
|
171
|
+
NO_SESSION = "no session CLI to ask (OMPCODE/CLAUDECODE unset or the CLI missing)"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def providers():
|
|
175
|
+
"""[(provider, secret), ...] in the order `ask()` tries them, or [].
|
|
176
|
+
|
|
177
|
+
The session's own CLI first (its `secret` is the binary's path); then the
|
|
178
|
+
third parties - TypeSafe, then OpenRouter - only as `tp.fallback_policy()`
|
|
179
|
+
allows: `vendor` (the default) asks them only when no session CLI exists,
|
|
180
|
+
`any` after the session CLI too, `none` never. TypeSafe still wins over
|
|
181
|
+
OpenRouter whenever both resolve."""
|
|
182
|
+
session = tp.session_cli()
|
|
183
|
+
policy = tp.fallback_policy()
|
|
184
|
+
out = [(session, getattr(tp, session + "_bin")())] if session else []
|
|
185
|
+
if policy == "none" or (session and policy == "vendor"):
|
|
186
|
+
return out
|
|
187
|
+
return out + [(name, secret) for name, secret in
|
|
188
|
+
(("typesafe", key()), ("openrouter", openrouter_key())) if secret]
|
|
189
|
+
|
|
153
190
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if secret:
|
|
160
|
-
return "typesafe", secret
|
|
161
|
-
secret = openrouter_key()
|
|
162
|
-
return ("openrouter", secret) if secret else (None, None)
|
|
191
|
+
def credential():
|
|
192
|
+
"""`(provider, secret)` for the first provider `ask()` will try, else
|
|
193
|
+
`(None, None)` - so `available()` and `ask()` cannot disagree."""
|
|
194
|
+
found = providers()
|
|
195
|
+
return found[0] if found else (None, None)
|
|
163
196
|
|
|
164
197
|
|
|
165
198
|
def available():
|
|
166
|
-
"""True when a judgement can be asked for: a
|
|
199
|
+
"""True when a judgement can be asked for: a provider resolves and no kill switch."""
|
|
167
200
|
return bool(credential()[1]) and not tp.off("judge-off")
|
|
168
201
|
|
|
169
202
|
|
|
170
203
|
def ask(state, questions, *, model=MODEL, timeout=30, attempts=2, deadline=None):
|
|
171
204
|
"""One batched call over `questions`; `{"answers", "usage", "latency_ms",
|
|
172
|
-
"model", "provider"}`.
|
|
205
|
+
"model", "provider", "fallback"}`.
|
|
173
206
|
|
|
174
207
|
`questions` is the API's own map - id -> `{type, instructions, criteria}` -
|
|
175
208
|
so a caller that needs a per-line or per-state pass sends every question in
|
|
176
209
|
one request and pays for the state once. The reply's usage is carried back
|
|
177
210
|
because a caller quotes what the judgement cost, and the model and provider
|
|
178
|
-
beside it because the
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
211
|
+
beside it because the provider that answers is not the caller's choice. The
|
|
212
|
+
model is the one the reply names, else the one asked for: an alias such as
|
|
213
|
+
`jev-latest` hides a silent upgrade the reply's own field shows. `model` is
|
|
214
|
+
TypeSafe's alone; the session CLI and the chat fallback ignore it.
|
|
215
|
+
|
|
216
|
+
The providers are tried in `providers()` order - the session's own CLI
|
|
217
|
+
first - and the next one is asked only when the previous failed. `fallback`
|
|
218
|
+
is None when the session answered, else why someone else did; that answer,
|
|
219
|
+
and a refusal, is recorded (`_record`) and said on stderr. A transient
|
|
220
|
+
failure - a timeout, a connection error, a 5xx - gets one more attempt on
|
|
221
|
+
the same provider, because a live measurement saw 2 of 50 calls lost that
|
|
222
|
+
way while the same cells answered on retry; a 4xx, a CLI that exited
|
|
223
|
+
non-zero and a reply that parsed malformed are never retried, since the
|
|
224
|
+
second request would fail identically and only a call that already worked
|
|
225
|
+
must not be billed twice. A caller on a hook's budget passes `attempts=1`
|
|
226
|
+
and a `deadline` in seconds: urllib's `timeout` bounds one socket operation,
|
|
227
|
+
not the call, so only the deadline bounds its wall time.
|
|
228
|
+
|
|
229
|
+
A call that ends on a 401, 402, 5xx or a session CLI's non-zero exit marks
|
|
230
|
+
that provider down for `DOWN_FOR` seconds, and until then it is skipped
|
|
231
|
+
without a request (`_down`), so a dead credential is not re-paid on every
|
|
232
|
+
prompt.
|
|
233
|
+
|
|
234
|
+
Total by design: no provider, an unreadable state, a refused request, a
|
|
235
|
+
timeout, a reply that is not the documented shape - all `None`, never an
|
|
236
|
+
exception."""
|
|
237
|
+
if not credential()[1]:
|
|
202
238
|
return None
|
|
239
|
+
stop = None if deadline is None else time.monotonic() + deadline
|
|
240
|
+
failed = []
|
|
241
|
+
for provider, secret in providers():
|
|
242
|
+
result, why = _ask_one(provider, secret, state, questions, model,
|
|
243
|
+
timeout, attempts, stop)
|
|
244
|
+
if result is None:
|
|
245
|
+
failed.append("%s: %s" % (provider, why))
|
|
246
|
+
continue
|
|
247
|
+
note = "; ".join(failed) or (None if provider in SESSION_ARGV else NO_SESSION)
|
|
248
|
+
result["fallback"] = note
|
|
249
|
+
_record(result["provider"], result["model"], fallback=note)
|
|
250
|
+
return result
|
|
251
|
+
refused = None
|
|
252
|
+
session = tp.session_cli()
|
|
253
|
+
if session and (key() or openrouter_key()):
|
|
254
|
+
refused = ("fallback=%s refuses a third-party judge after %s failed"
|
|
255
|
+
% (tp.fallback_policy(), session))
|
|
256
|
+
_record(None, None, failed="; ".join(failed), refused=refused)
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _ask_one(provider, secret, state, questions, model, timeout, attempts, stop):
|
|
261
|
+
"""One provider's attempts: `(result, None)`, or `(None, why)`."""
|
|
203
262
|
try:
|
|
204
263
|
if provider == "typesafe":
|
|
205
264
|
used, url = model, endpoint()
|
|
@@ -208,34 +267,43 @@ def ask(state, questions, *, model=MODEL, timeout=30, attempts=2, deadline=None)
|
|
|
208
267
|
|
|
209
268
|
def send():
|
|
210
269
|
return _request(secret, body, timeout)
|
|
211
|
-
|
|
270
|
+
elif provider == "openrouter":
|
|
212
271
|
used, url = fallback_model(), openrouter_url()
|
|
213
272
|
body = json.dumps(_chat_body(state, questions, used)).encode()
|
|
214
273
|
|
|
215
274
|
def send():
|
|
216
275
|
return _chat_request(secret, body, timeout, questions)
|
|
276
|
+
else:
|
|
277
|
+
used, url = None, secret
|
|
278
|
+
prompt = json.dumps({"state": state, "questions": questions})
|
|
279
|
+
|
|
280
|
+
def send():
|
|
281
|
+
left = timeout if stop is None else max(0.1, min(timeout, stop - time.monotonic()))
|
|
282
|
+
return _session_request(provider, secret, prompt, left, questions)
|
|
217
283
|
marker = _down_marker(provider, url, secret)
|
|
218
284
|
if _down(marker):
|
|
219
|
-
return None
|
|
220
|
-
except Exception:
|
|
221
|
-
return None
|
|
222
|
-
stop = None if deadline is None else time.monotonic() + deadline
|
|
285
|
+
return None, "marked down for %ds after a refusal" % DOWN_FOR
|
|
286
|
+
except Exception as exc:
|
|
287
|
+
return None, _why(exc)
|
|
223
288
|
for attempt in range(attempts):
|
|
289
|
+
if stop is not None and time.monotonic() >= stop:
|
|
290
|
+
return None, "deadline passed"
|
|
224
291
|
try:
|
|
225
292
|
result = _bounded(send, stop)
|
|
226
293
|
except Exception as exc:
|
|
227
294
|
late = stop is not None and time.monotonic() >= stop
|
|
228
295
|
if attempt + 1 >= attempts or late or not _transient(exc):
|
|
229
296
|
code = getattr(exc, "code", None)
|
|
230
|
-
if code in (401, 402) or (isinstance(code, int) and code >= 500)
|
|
297
|
+
if code in (401, 402) or (isinstance(code, int) and code >= 500) \
|
|
298
|
+
or isinstance(exc, SessionFailed):
|
|
231
299
|
_mark_down(marker)
|
|
232
|
-
return None
|
|
300
|
+
return None, _why(exc)
|
|
233
301
|
continue
|
|
234
302
|
named = result.get("model")
|
|
235
|
-
result["model"] = named if isinstance(named, str) and named else used
|
|
303
|
+
result["model"] = named if isinstance(named, str) and named else used or "-"
|
|
236
304
|
result["provider"] = provider
|
|
237
|
-
return result
|
|
238
|
-
return None
|
|
305
|
+
return result, None
|
|
306
|
+
return None, "no attempt"
|
|
239
307
|
|
|
240
308
|
|
|
241
309
|
def _answer(result, id):
|
|
@@ -485,3 +553,118 @@ def _bounded(send, stop):
|
|
|
485
553
|
if "err" in box:
|
|
486
554
|
raise box["err"]
|
|
487
555
|
return box["ok"]
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
class SessionFailed(RuntimeError):
|
|
559
|
+
"""The session CLI exited non-zero or printed no answer: a logged-out CLI or
|
|
560
|
+
an exhausted plan fails the same way on the next call, so it is never
|
|
561
|
+
retried and it marks the provider down like a 401."""
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _session_request(name, exe, prompt, timeout, questions):
|
|
565
|
+
"""One headless run of the session's own CLI, mapped into the documented
|
|
566
|
+
return. It starts in an empty temp dir with stdin closed - the answer rests
|
|
567
|
+
on the prompt alone, and a CLI that wants a login exits instead of waiting -
|
|
568
|
+
with TEZGAH_NESTED set so tezgah's own hooks stand down in the child. The
|
|
569
|
+
answer is read from the CLI's JSON output: the model and the usage it names
|
|
570
|
+
are what the call really used."""
|
|
571
|
+
import shutil
|
|
572
|
+
import subprocess
|
|
573
|
+
import tempfile
|
|
574
|
+
cwd = tempfile.mkdtemp(prefix="tezgah-judge-")
|
|
575
|
+
started = time.monotonic()
|
|
576
|
+
try:
|
|
577
|
+
proc = subprocess.run([exe] + SESSION_ARGV[name] + [prompt], cwd=cwd,
|
|
578
|
+
stdin=subprocess.DEVNULL, capture_output=True,
|
|
579
|
+
text=True, timeout=timeout,
|
|
580
|
+
env=dict(os.environ, TEZGAH_NESTED="1"))
|
|
581
|
+
finally:
|
|
582
|
+
shutil.rmtree(cwd, ignore_errors=True)
|
|
583
|
+
if proc.returncode:
|
|
584
|
+
raise SessionFailed("%s exit %d: %s" % (name, proc.returncode,
|
|
585
|
+
(proc.stderr or proc.stdout).strip()[-200:]))
|
|
586
|
+
if name == "claude":
|
|
587
|
+
data = json.loads(proc.stdout)
|
|
588
|
+
if data.get("is_error"):
|
|
589
|
+
raise SessionFailed("claude: %s" % str(data.get("result"))[:200])
|
|
590
|
+
text = data.get("result") or ""
|
|
591
|
+
model = next(iter(data.get("modelUsage") or {}), None)
|
|
592
|
+
usage = data.get("usage") or {}
|
|
593
|
+
tokens_in = sum(int(usage.get(k) or 0) for k in (
|
|
594
|
+
"input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"))
|
|
595
|
+
tokens_out = int(usage.get("output_tokens") or 0)
|
|
596
|
+
else:
|
|
597
|
+
message = None
|
|
598
|
+
for line in proc.stdout.splitlines():
|
|
599
|
+
try:
|
|
600
|
+
event = json.loads(line)
|
|
601
|
+
except ValueError:
|
|
602
|
+
continue
|
|
603
|
+
if isinstance(event, dict) and event.get("type") == "message_end" \
|
|
604
|
+
and (event.get("message") or {}).get("role") == "assistant":
|
|
605
|
+
message = event["message"]
|
|
606
|
+
if message is None:
|
|
607
|
+
raise SessionFailed("omp printed no answer")
|
|
608
|
+
text = "".join(part.get("text") or "" for part in message.get("content") or []
|
|
609
|
+
if isinstance(part, dict) and part.get("type") == "text")
|
|
610
|
+
model = "/".join(str(message[k]) for k in ("provider", "model") if message.get(k))
|
|
611
|
+
usage = message.get("usage") or {}
|
|
612
|
+
tokens_in = sum(int(usage.get(k) or 0) for k in ("input", "cacheRead", "cacheWrite"))
|
|
613
|
+
tokens_out = int(usage.get("output") or 0)
|
|
614
|
+
# A chat model may fence its object; the outermost braces are the object.
|
|
615
|
+
answers = _chat_answers(json.loads(text[text.find("{"):text.rfind("}") + 1]), questions)
|
|
616
|
+
return {"answers": answers,
|
|
617
|
+
"usage": {"input_tokens": tokens_in, "output_tokens": tokens_out},
|
|
618
|
+
"latency_ms": int((time.monotonic() - started) * 1000),
|
|
619
|
+
"model": model}
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _why(exc):
|
|
623
|
+
"""A failure as one short reason: the HTTP code, else the exception's text."""
|
|
624
|
+
code = getattr(exc, "code", None)
|
|
625
|
+
if isinstance(code, int):
|
|
626
|
+
return "http %d" % code
|
|
627
|
+
return ("%s: %s" % (type(exc).__name__, exc))[:200]
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
# The last judgement's record, one file: what answered, and why it was not the
|
|
631
|
+
# session's own CLI when it was not. `tezgah-status --judge` prints it.
|
|
632
|
+
LAST = "judge-last.json"
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _say(line):
|
|
636
|
+
try:
|
|
637
|
+
import sys
|
|
638
|
+
sys.stderr.write("tezgah-judge: %s\n" % line)
|
|
639
|
+
except Exception:
|
|
640
|
+
pass
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _record(provider, model, fallback=None, failed=None, refused=None):
|
|
644
|
+
"""Write the last-use record and say any fallback or refusal on stderr.
|
|
645
|
+
Best effort like every hook write: a cache that cannot be written loses
|
|
646
|
+
the record, never the answer."""
|
|
647
|
+
if fallback and provider:
|
|
648
|
+
_say("answered by %s/%s - %s" % (provider, model, fallback))
|
|
649
|
+
elif refused:
|
|
650
|
+
_say("no judgement: %s (%s)" % (refused, failed))
|
|
651
|
+
row = {"at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "provider": provider,
|
|
652
|
+
"model": model, "fallback": fallback, "failed": failed or None,
|
|
653
|
+
"refused": refused, "policy": tp.fallback_policy()}
|
|
654
|
+
try:
|
|
655
|
+
path = os.path.join(tp.cache_dir(), LAST)
|
|
656
|
+
with open(path + ".tmp", "w", encoding="utf-8") as fh:
|
|
657
|
+
json.dump(row, fh)
|
|
658
|
+
os.replace(path + ".tmp", path)
|
|
659
|
+
except (OSError, TypeError, ValueError):
|
|
660
|
+
pass
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def last_use():
|
|
664
|
+
"""The last judgement's record (see `_record`), or None when none was kept."""
|
|
665
|
+
try:
|
|
666
|
+
with open(os.path.join(tp.cache_dir(), LAST), encoding="utf-8") as fh:
|
|
667
|
+
row = json.load(fh)
|
|
668
|
+
except (OSError, ValueError):
|
|
669
|
+
return None
|
|
670
|
+
return row if isinstance(row, dict) else None
|
package/hooks/tezgah_models.py
CHANGED
|
@@ -105,7 +105,10 @@ SLOTS = {
|
|
|
105
105
|
"any": ("anthropic/claude-opus-5.5", "high")},
|
|
106
106
|
}
|
|
107
107
|
AGENT_SLOT = {"tezgah-cheap": "cheap", "tezgah-standard": "standard",
|
|
108
|
-
"tezgah-frontier": "frontier", "tezgah-reviewer": "frontier"
|
|
108
|
+
"tezgah-frontier": "frontier", "tezgah-reviewer": "frontier",
|
|
109
|
+
"tezgah-tester": "cheap", "tezgah-docs": "standard",
|
|
110
|
+
"tezgah-security": "frontier", "tezgah-ui": "standard",
|
|
111
|
+
"tezgah-researcher": "frontier"}
|
|
109
112
|
# The OpenRouter id of every model the table names, with its list price per 1M
|
|
110
113
|
# tokens (input, output) on READ_ON, so `refresh` can say what moved.
|
|
111
114
|
SNAPSHOT = {"anthropic/claude-opus-5.5": (4.0, 20.0),
|
|
@@ -280,17 +283,50 @@ def _selector(family, slot):
|
|
|
280
283
|
return FAMILY_PREFIX[family] + model + (":" + effort if effort else "")
|
|
281
284
|
|
|
282
285
|
|
|
286
|
+
# The OpenRouter id of a host-column model: the route the default `vendor`
|
|
287
|
+
# fallback keeps - the same model through another provider and account - when
|
|
288
|
+
# the `any` column names another vendor's model on that slot.
|
|
289
|
+
OPENROUTER_ID = {"claude-opus-5-5": "anthropic/claude-opus-5.5",
|
|
290
|
+
"zai/glm-5.3-flash": "z-ai/glm-5.3-flash"}
|
|
291
|
+
VENDOR_STEMS = (("claude", "anthropic"), ("glm", "zai"), ("gpt", "openai"),
|
|
292
|
+
("deepseek", "deepseek"))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _vendor(model):
|
|
296
|
+
"""The vendor a table id's model comes from, or the id itself."""
|
|
297
|
+
name = model.split("/")[-1].lower()
|
|
298
|
+
return next((v for stem, v in VENDOR_STEMS if name.startswith(stem)), name)
|
|
299
|
+
|
|
300
|
+
|
|
283
301
|
def _chain(mode, slot, funded, agent=""):
|
|
284
302
|
"""[selector, ...] for one slot: the mode's family first - the user picked it,
|
|
285
|
-
so it leads even when its credential is not visible from here - then
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
303
|
+
so it leads even when its credential is not visible from here - then the
|
|
304
|
+
fallbacks the `fallback` setting allows (`tp.fallback_policy()`).
|
|
305
|
+
|
|
306
|
+
`vendor`, the default: only the mode's own model vendor - the same model
|
|
307
|
+
through OpenRouter, or another funded column whose model is that vendor's -
|
|
308
|
+
because a session on Opus that ran its subagents on GPT and GLM after one
|
|
309
|
+
Anthropic 429 is the failure the owner reported (2026-10-07). `none`: the
|
|
310
|
+
mode's selector alone. `any`: every other funded family, each on that
|
|
311
|
+
slot's own row, rotated by the agent's own name (`agent`): identical chains
|
|
289
312
|
for every agent meant one family's 429 moved all of them onto the same next
|
|
290
313
|
family in the same moment, which is the cascade the chain exists to break
|
|
291
314
|
(independent review, 2026-10-01). The rotation is deterministic - the same
|
|
292
315
|
agent resolves the same order on every machine - so nothing here is random."""
|
|
316
|
+
policy = tp.fallback_policy()
|
|
293
317
|
families = [mode] + [f for f in OMP_FAMILIES if f != mode and f in funded]
|
|
318
|
+
if policy == "none":
|
|
319
|
+
return [_selector(mode, slot)]
|
|
320
|
+
if policy == "vendor":
|
|
321
|
+
model, effort = SLOTS[slot][mode]
|
|
322
|
+
out = [_selector(mode, slot)]
|
|
323
|
+
for family in families[1:]:
|
|
324
|
+
if _vendor(SLOTS[slot][family][0]) == _vendor(model):
|
|
325
|
+
out.append(_selector(family, slot))
|
|
326
|
+
elif family == "any" and model in OPENROUTER_ID:
|
|
327
|
+
out.append("openrouter/" + OPENROUTER_ID[model]
|
|
328
|
+
+ (":" + effort if effort else ""))
|
|
329
|
+
return list(dict.fromkeys(out))
|
|
294
330
|
tail = families[1:]
|
|
295
331
|
if agent and len(tail) > 1:
|
|
296
332
|
shift = sum(ord(c) for c in agent) % len(tail)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Orca: is this session inside it, which CLI drives it, and what it tracks.
|
|
3
|
+
|
|
4
|
+
Orca (an IDE for agent sessions) owns worktrees, terminals, its embedded browser
|
|
5
|
+
and artifacts, and it exports its own markers into every terminal it starts.
|
|
6
|
+
tezgah reads those markers - never writes them - so a session in Orca is told to
|
|
7
|
+
create a checkout with `orca worktree create` rather than a raw `git worktree
|
|
8
|
+
add` Orca does not list, and `tezgah-status --orca` shows which of a repo's
|
|
9
|
+
checkouts Orca tracks. Stdlib only; detection forks nothing, so the session-start
|
|
10
|
+
line costs no process. docs/orca.md is the page.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import shlex
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
# The markers an Orca terminal exports (observed in Orca 1.4.221's env). Any one
|
|
20
|
+
# is enough; TERM_PROGRAM=Orca covers a shell that dropped the ORCA_* set.
|
|
21
|
+
MARKERS = ("ORCA_WORKTREE_ID", "ORCA_TERMINAL_HANDLE", "ORCA_PANE_KEY")
|
|
22
|
+
MAC_APP_CLI = "/Applications/Orca.app/Contents/Resources/bin/orca"
|
|
23
|
+
# The file Orca leaves in each per-account codex home it hands out as CODEX_HOME.
|
|
24
|
+
MANAGED_HOME = ".orca-managed-home"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def session(env=None):
|
|
28
|
+
"""{worktree, terminal, version} when this process runs in an Orca terminal,
|
|
29
|
+
else None. `worktree` is the path half of ORCA_WORKTREE_ID
|
|
30
|
+
(`<repoId>::<path>`)."""
|
|
31
|
+
env = os.environ if env is None else env
|
|
32
|
+
if not any(env.get(k) for k in MARKERS) and env.get("TERM_PROGRAM") != "Orca":
|
|
33
|
+
return None
|
|
34
|
+
return {"worktree": env.get("ORCA_WORKTREE_ID", "").partition("::")[2],
|
|
35
|
+
"terminal": env.get("ORCA_TERMINAL_HANDLE", ""),
|
|
36
|
+
"version": env.get("ORCA_APP_VERSION", "")}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cli(env=None, platform=None):
|
|
40
|
+
"""The argv prefix that runs Orca's CLI, or None. The order is the orca-cli
|
|
41
|
+
skill's: Orca's own ORCA_CLI_COMMAND (WSL), then its exported bin dir, then
|
|
42
|
+
PATH. On Linux outside an Orca terminal a bare `orca` is the GNOME screen
|
|
43
|
+
reader, so only `orca-ide` counts there. TEZGAH_ORCA_BIN pins one binary
|
|
44
|
+
(tests): a missing one is None, never a fallback to the real app."""
|
|
45
|
+
env = os.environ if env is None else env
|
|
46
|
+
platform = platform or sys.platform
|
|
47
|
+
pinned = env.get("TEZGAH_ORCA_BIN")
|
|
48
|
+
if pinned:
|
|
49
|
+
return [pinned] if os.access(pinned, os.X_OK) else None
|
|
50
|
+
if env.get("ORCA_CLI_COMMAND"):
|
|
51
|
+
return shlex.split(env["ORCA_CLI_COMMAND"])
|
|
52
|
+
bindir = env.get("ORCA_CLI_BIN_DIR")
|
|
53
|
+
if bindir and os.access(os.path.join(bindir, "orca"), os.X_OK):
|
|
54
|
+
return [os.path.join(bindir, "orca")]
|
|
55
|
+
path = env.get("PATH", os.defpath)
|
|
56
|
+
name = ("orca-ide" if platform.startswith("linux") and not session(env)
|
|
57
|
+
else "orca")
|
|
58
|
+
found = shutil.which(name, path=path)
|
|
59
|
+
if found:
|
|
60
|
+
return [found]
|
|
61
|
+
if platform == "darwin" and os.access(MAC_APP_CLI, os.X_OK):
|
|
62
|
+
return [MAC_APP_CLI]
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run(args, timeout=10):
|
|
67
|
+
"""`orca <args> --json`'s `result`, or None when there is no CLI, the
|
|
68
|
+
runtime is down or the answer is not `ok`."""
|
|
69
|
+
argv = cli()
|
|
70
|
+
if not argv:
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
proc = subprocess.run(argv + list(args) + ["--json"], capture_output=True,
|
|
74
|
+
text=True, timeout=timeout)
|
|
75
|
+
data = json.loads(proc.stdout)
|
|
76
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
77
|
+
return None
|
|
78
|
+
return data.get("result") if isinstance(data, dict) and data.get("ok") else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def checkouts(paths):
|
|
82
|
+
"""Orca's view of a repository's git checkouts (`paths`, real paths):
|
|
83
|
+
{"tracked": [row per checkout Orca lists], "untracked": [path Orca does not
|
|
84
|
+
list]}, or None when the runtime cannot answer. Read from `orca worktree ps`,
|
|
85
|
+
the one call that carries terminals and agent states together."""
|
|
86
|
+
ps = run(["worktree", "ps", "--limit", "1000"])
|
|
87
|
+
if ps is None:
|
|
88
|
+
return None
|
|
89
|
+
wanted = set(paths)
|
|
90
|
+
tracked = {}
|
|
91
|
+
for row in ps.get("worktrees") or []:
|
|
92
|
+
path = os.path.realpath(row.get("path") or "")
|
|
93
|
+
if path in wanted:
|
|
94
|
+
tracked[path] = {
|
|
95
|
+
"path": path, "name": row.get("displayName") or "",
|
|
96
|
+
"card": row.get("workspaceStatus") or "",
|
|
97
|
+
"status": row.get("status") or "",
|
|
98
|
+
"terminals": row.get("liveTerminalCount") or 0,
|
|
99
|
+
"agents": ["%s:%s" % (a.get("agentType"), a.get("state"))
|
|
100
|
+
for a in row.get("agents") or []]}
|
|
101
|
+
return {"tracked": [tracked[p] for p in paths if p in tracked],
|
|
102
|
+
"untracked": [p for p in paths if p not in tracked]}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def context_line(env=None):
|
|
106
|
+
"""The session-start line for a session inside Orca, or "" outside it."""
|
|
107
|
+
here = session(env)
|
|
108
|
+
if not here:
|
|
109
|
+
return ""
|
|
110
|
+
where = here["worktree"] or "unknown"
|
|
111
|
+
return ("Orca: this session runs in an Orca terminal (worktree `%s`); Orca "
|
|
112
|
+
"owns worktrees, terminals, its embedded browser and artifacts here. "
|
|
113
|
+
"A parallel slice that needs its own checkout gets one from `orca "
|
|
114
|
+
"worktree create --name <slug> --parent-worktree active --json` (add "
|
|
115
|
+
"`--agent <cli> --prompt <brief>` to start its worker there), not "
|
|
116
|
+
"`git worktree add`, which Orca does not list. A long job runs in "
|
|
117
|
+
"`orca terminal create --worktree <selector> --command <cmd> --json`, "
|
|
118
|
+
"not a background shell. Track with `orca worktree ps --json` and "
|
|
119
|
+
"`tezgah-status --orca`; mark checkpoints with `orca worktree set "
|
|
120
|
+
"--worktree active --comment <text>`. Full guide: `orca skills get "
|
|
121
|
+
"orca-cli`." % where)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def summary(codex_home):
|
|
125
|
+
"""One `orca:` line for `tezgah-setup --status`: app/runtime, whether this
|
|
126
|
+
shell is in an Orca terminal, and whose codex home CODEX_HOME names; ""
|
|
127
|
+
when Orca is neither installed nor around this shell."""
|
|
128
|
+
argv, here = cli(), session()
|
|
129
|
+
if not argv and not here:
|
|
130
|
+
return ""
|
|
131
|
+
runtime = ((run(["status"]) or {}).get("runtime") or {}) if argv else {}
|
|
132
|
+
parts = ["orca: %s" % (
|
|
133
|
+
"runtime %s reachable" % runtime.get("appVersion", "")
|
|
134
|
+
if runtime.get("reachable") else
|
|
135
|
+
"CLI %s, runtime not reachable (`orca open`)" % argv[-1] if argv else
|
|
136
|
+
"no orca CLI found")]
|
|
137
|
+
parts.append("this shell runs in an Orca terminal (worktree %s)"
|
|
138
|
+
% (here["worktree"] or "unknown") if here
|
|
139
|
+
else "this shell is not an Orca terminal")
|
|
140
|
+
if os.path.isfile(os.path.join(codex_home, MANAGED_HOME)):
|
|
141
|
+
parts.append("CODEX_HOME %s is an Orca-managed codex home: tezgah adds "
|
|
142
|
+
"its hook groups beside Orca's and leaves Orca's entries "
|
|
143
|
+
"and env alone" % codex_home)
|
|
144
|
+
return "; ".join(parts)
|
package/hooks/tezgah_paths.py
CHANGED
|
@@ -497,18 +497,23 @@ def have_typesafe_key():
|
|
|
497
497
|
|
|
498
498
|
|
|
499
499
|
def have_judge_key():
|
|
500
|
-
"""True when tezgah's own judgement seam
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
500
|
+
"""True when tezgah's own judgement seam can ask anyone: the session's own
|
|
501
|
+
CLI (`session_cli()`), else - when the `fallback` setting lets a third party
|
|
502
|
+
answer - the TypeSafe env var or key file, then the OpenRouter fallback's own
|
|
503
|
+
two channels (`OPENROUTER_API_KEY`, `~/.config/openrouter/key`), which are
|
|
504
|
+
the channels `hooks/tezgah_judge.py`'s `providers()` picks between. This is
|
|
505
|
+
what `bin/tezgah-triage`, `bin/tezgah-docs` and the skill picker spend. Never
|
|
506
|
+
omp's login store: the seam does not open it.
|
|
506
507
|
|
|
507
508
|
Kept next to `have_typesafe_key()` because the two are asked together and a
|
|
508
509
|
report that showed one answer under both questions was wrong in both
|
|
509
510
|
directions - a key file alone (seam works, omp does not) and a login-store
|
|
510
511
|
record alone (omp works, seam does not). A blank file is not a credential
|
|
511
512
|
here either, so this stays in step with the seam's own `strip() or None`."""
|
|
513
|
+
if session_cli():
|
|
514
|
+
return True
|
|
515
|
+
if fallback_policy() == "none":
|
|
516
|
+
return False
|
|
512
517
|
for env, home in (("TYPESAFE_API_KEY", ".config/typesafe/key"),
|
|
513
518
|
("OPENROUTER_API_KEY", ".config/openrouter/key")):
|
|
514
519
|
if os.environ.get(env, "").strip():
|
|
@@ -522,6 +527,35 @@ def have_judge_key():
|
|
|
522
527
|
return False
|
|
523
528
|
|
|
524
529
|
|
|
530
|
+
# How far a model call may leave the session's own vendor (config.json
|
|
531
|
+
# `fallback`). `vendor`, the default: the session's own model first, and a
|
|
532
|
+
# fallback only to the same vendor's model (a subagent chain may reach Opus
|
|
533
|
+
# through OpenRouter; the judge has no such route, so it falls back to nobody
|
|
534
|
+
# while the session CLI exists, and to a third party only when there is no
|
|
535
|
+
# session CLI at all). `any`: a cross-vendor fallback is allowed after the
|
|
536
|
+
# session's own model fails. `none`: no provider but the session's, ever.
|
|
537
|
+
FALLBACKS = ("vendor", "any", "none")
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def fallback_policy():
|
|
541
|
+
"""The configured `fallback` value, `vendor` when unset or unknown."""
|
|
542
|
+
cfg = config()
|
|
543
|
+
value = cfg.get("fallback") if isinstance(cfg, dict) else None
|
|
544
|
+
return value if value in FALLBACKS else "vendor"
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def session_cli():
|
|
548
|
+
"""`omp` or `claude` - the CLI of the host this process runs under, when it
|
|
549
|
+
is installed - else None. omp sets CLAUDECODE too, so OMPCODE is read first,
|
|
550
|
+
and an omp session whose binary is missing is None rather than Claude: the
|
|
551
|
+
session's credential is omp's, not whatever `claude` is logged in as."""
|
|
552
|
+
if os.environ.get("OMPCODE"):
|
|
553
|
+
return "omp" if omp_bin() else None
|
|
554
|
+
if os.environ.get("CLAUDECODE"):
|
|
555
|
+
return "claude" if claude_bin() else None
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
|
|
525
559
|
def orx_bin():
|
|
526
560
|
"""The OpenResearch `orx` executable, or None when it is not installed.
|
|
527
561
|
|