@r1z4x/tezgah 1.0.0 → 1.2.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/MANIFEST +10 -0
  3. package/bin/tezgah-agents +10 -1
  4. package/bin/tezgah-docs +1 -1
  5. package/bin/tezgah-doctor +11 -0
  6. package/bin/tezgah-research +31 -14
  7. package/bin/tezgah-setup +154 -29
  8. package/bin/tezgah-skill +59 -0
  9. package/bin/tezgah-skill.py +59 -0
  10. package/bin/tezgah-status +62 -1
  11. package/bin/tezgah-taste +504 -5
  12. package/bin/tezgah-triage +5 -3
  13. package/docs/README.md +1 -0
  14. package/docs/architecture.md +5 -2
  15. package/docs/citations-baseline.json +1 -1
  16. package/docs/clarity-baseline.json +9 -9
  17. package/docs/contract.md +68 -26
  18. package/docs/environment.md +6 -3
  19. package/docs/evidence.md +7 -0
  20. package/docs/gate.md +13 -7
  21. package/docs/glossary.md +1 -1
  22. package/docs/hosts.md +48 -1
  23. package/docs/index.json +34 -3
  24. package/docs/judge.md +75 -60
  25. package/docs/models.md +14 -8
  26. package/docs/operations.md +80 -25
  27. package/docs/orca.md +126 -0
  28. package/docs/research.md +38 -3
  29. package/docs/skills.md +74 -0
  30. package/docs/status-line.md +3 -1
  31. package/docs/testing.md +33 -21
  32. package/docs/vision.md +12 -8
  33. package/hooks/projects-posttooluse.py +7 -0
  34. package/hooks/projects-pretooluse.py +17 -5
  35. package/hooks/tezgah_agents.py +135 -13
  36. package/hooks/tezgah_components.py +3 -0
  37. package/hooks/tezgah_context.py +84 -22
  38. package/hooks/tezgah_gate.py +133 -17
  39. package/hooks/tezgah_integrity.py +60 -13
  40. package/hooks/tezgah_judge.py +288 -66
  41. package/hooks/tezgah_models.py +41 -5
  42. package/hooks/tezgah_orca.py +144 -0
  43. package/hooks/tezgah_paths.py +40 -6
  44. package/hooks/tezgah_policy.py +109 -35
  45. package/hooks/tezgah_research.py +104 -8
  46. package/hooks/tezgah_skill_pick.py +459 -8
  47. package/hooks/tezgah_stack.py +382 -0
  48. package/hooks/tezgah_stopspec.py +2 -2
  49. package/hooks/tezgah_taste.py +35 -9
  50. package/hooks/tezgah_taste_ledger.py +628 -0
  51. package/hosts/codex/hook.py +12 -1
  52. package/hosts/cursor/hook.py +12 -4
  53. package/hosts/omp/hook.py +18 -6
  54. package/hosts/omp/tezgah-hook.ts.in +20 -7
  55. package/hosts/opencode/plugins/tezgah.js +28 -15
  56. package/output-styles/tezgah.md +34 -19
  57. package/package.json +1 -1
  58. package/skills/i-have-adhd/SKILL.md +2 -2
  59. package/skills/research/SKILL.md +60 -17
  60. package/skills/tezgah-contract/SKILL.md +55 -13
@@ -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 two providers. TypeSafe answers in its own
33
- schema; when no TypeSafe key resolves, the same questions go to an
34
- OpenAI-compatible chat endpoint - OpenRouter, whose key rides the same two
35
- channels (`OPENROUTER_API_KEY`, then `~/.config/openrouter/key`) - and its JSON
36
- reply is mapped back into the shapes the callers already read. That path is a
37
- fallback, never a peer: it is chosen only when `key()` is empty, it sends the
38
- same state to a different host, and a reply that does not carry a question's
39
- answer leaves that question absent, which every caller already reads as no
40
- judgement.
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
@@ -64,9 +68,10 @@ FALLBACK_MODEL = "deepseek/deepseek-v4-flash"
64
68
  # The contract the fallback asks a chat model to answer in, one shape per
65
69
  # question type this repository's callers send (`bin/tezgah-triage` and
66
70
  # `hooks/tezgah_skill_pick.py` ask `noul`, `bin/tezgah-docs` and the triage's
67
- # pair rows ask `choice`). It is prose because a chat endpoint has no schema
68
- # field to carry it; every field it names is validated on the way back, and a
69
- # question of any other type reads as unanswered rather than as a guess.
71
+ # pair rows ask `choice`, `bin/tezgah-taste` asks `text` for a learning's
72
+ # readable line). It is prose because a chat endpoint has no schema field to
73
+ # carry it; every field it names is validated on the way back, and a question
74
+ # of any other type reads as unanswered rather than as a guess.
70
75
  CHAT_SYSTEM = (
71
76
  "You answer questions about the state that follows. Reply with one JSON "
72
77
  "object and nothing else: {\"answers\": {\"<id>\": {...}}}, one entry per "
@@ -74,6 +79,7 @@ CHAT_SYSTEM = (
74
79
  "noul -> {\"noul\": <probability 0..1 that the statement is true>}\n"
75
80
  "choice -> {\"choice\": \"<the chosen label>\", \"probabilities\": "
76
81
  "{\"<label>\": <probability>, ...}, \"confidence\": <0..1>}\n"
82
+ "text -> {\"text\": \"<the answer as plain prose>\"}\n"
77
83
  "Every probability is a number in 0..1 and the ones you list for one "
78
84
  "question sum to 1. Answer every id you were given.")
79
85
 
@@ -148,58 +154,139 @@ def fallback_model():
148
154
  return os.environ.get("TEZGAH_JUDGE_MODEL", "").strip() or cheap_default()
149
155
 
150
156
 
151
- def credential():
152
- """`(provider, secret)` for the first channel that resolves, else `(None, None)`.
157
+ # The session CLIs the seam can ask, as the argv between the binary and the
158
+ # prompt: no tools, no session file, no project rules, skills, extensions, MCP
159
+ # servers or settings, and the chat fallback's own contract as the system
160
+ # prompt. Measured 2026-10-07 on a one-word answer: claude went from 37,038
161
+ # prompt tokens ($0.30) to 725 ($0.007); omp from 43,735 written to cache
162
+ # ($0.35) to about 37,500, 36,700 of them read from cache ($0.015) - omp has
163
+ # no flag here that drops the rest.
164
+ SESSION_ARGV = {
165
+ "omp": ["-p", "--no-tools", "--no-lsp", "--no-session", "--no-rules",
166
+ "--no-skills", "--no-extensions", "--no-title", "--thinking", "off",
167
+ "--mode", "json", "--system-prompt", CHAT_SYSTEM],
168
+ "claude": ["-p", "--tools", "", "--no-session-persistence",
169
+ "--strict-mcp-config", "--disable-slash-commands",
170
+ "--setting-sources", "", "--output-format", "json",
171
+ "--system-prompt", CHAT_SYSTEM],
172
+ }
173
+ NO_SESSION = "no session CLI to ask (OMPCODE/CLAUDECODE unset or the CLI missing)"
174
+
175
+
176
+ def providers():
177
+ """[(provider, secret), ...] in the order `ask()` tries them, or [].
178
+
179
+ The session's own CLI first (its `secret` is the binary's path); then the
180
+ third parties - TypeSafe, then OpenRouter - only as `tp.fallback_policy()`
181
+ allows: `vendor` (the default) asks them only when no session CLI exists,
182
+ `any` after the session CLI too, `none` never. TypeSafe still wins over
183
+ OpenRouter whenever both resolve."""
184
+ session = tp.session_cli()
185
+ policy = tp.fallback_policy()
186
+ out = [(session, getattr(tp, session + "_bin")())] if session else []
187
+ if policy == "none" or (session and policy == "vendor"):
188
+ return out
189
+ return out + [(name, secret) for name, secret in
190
+ (("typesafe", key()), ("openrouter", openrouter_key())) if secret]
191
+
192
+
193
+ def named(only):
194
+ """`providers()` for a caller that requires particular ones: every provider
195
+ in `only` that resolves, in `only`'s order. The `vendor` policy's
196
+ session-first order does not apply - the caller named the provider, so
197
+ nothing is a silent fallback - but `none` still keeps every third party
198
+ out."""
199
+ session = tp.session_cli()
200
+ third = tp.fallback_policy() != "none"
201
+ found = {session: getattr(tp, session + "_bin")()} if session else {}
202
+ if third:
203
+ found.update(typesafe=key(), openrouter=openrouter_key())
204
+ return [(name, found[name]) for name in only if found.get(name)]
205
+
153
206
 
154
- TypeSafe wins whenever it resolves, so adding a fallback cannot move a
155
- machine that already judges with Jev. The pair is returned rather than the
156
- secret alone so that `available()` and `ask()` cannot disagree about which
157
- provider a call will use."""
158
- secret = key()
159
- if secret:
160
- return "typesafe", secret
161
- secret = openrouter_key()
162
- return ("openrouter", secret) if secret else (None, None)
207
+ def credential():
208
+ """`(provider, secret)` for the first provider `ask()` will try, else
209
+ `(None, None)` - so `available()` and `ask()` cannot disagree."""
210
+ found = providers()
211
+ return found[0] if found else (None, None)
163
212
 
164
213
 
165
214
  def available():
166
- """True when a judgement can be asked for: a key resolves and no kill switch."""
215
+ """True when a judgement can be asked for: a provider resolves and no kill switch."""
167
216
  return bool(credential()[1]) and not tp.off("judge-off")
168
217
 
169
218
 
170
- def ask(state, questions, *, model=MODEL, timeout=30, attempts=2, deadline=None):
219
+ def ask(state, questions, *, model=MODEL, timeout=30, attempts=2, deadline=None,
220
+ only=None):
171
221
  """One batched call over `questions`; `{"answers", "usage", "latency_ms",
172
- "model", "provider"}`.
222
+ "model", "provider", "fallback"}`.
173
223
 
174
224
  `questions` is the API's own map - id -> `{type, instructions, criteria}` -
175
225
  so a caller that needs a per-line or per-state pass sends every question in
176
226
  one request and pays for the state once. The reply's usage is carried back
177
227
  because a caller quotes what the judgement cost, and the model and provider
178
- beside it because the fallback answers with a different one than the caller
179
- named. The model is the one the reply names, else the one asked for: an alias
180
- such as `jev-latest` hides a silent upgrade the reply's own field shows.
181
-
182
- One request per call in the normal case, to whichever provider the credential
183
- resolves - TypeSafe first, the chat fallback only when `key()` is empty. A
184
- transient failure - a timeout, a connection error, a 5xx - gets one more
185
- attempt, because a live measurement saw 2 of 50 calls lost that way while the
186
- same cells answered on retry; a 4xx (a refused credential, a rejected body)
187
- and a reply that parsed malformed are never retried, since the second request
188
- would fail identically and only a call that already worked must not be billed
189
- twice. The ceiling is one, so the worst case is one duplicated request on a
190
- call that answered nothing anyway. A caller on a hook's budget passes
191
- `attempts=1` and a `deadline` in seconds: urllib's `timeout` bounds one
192
- socket operation, not the call, so only the deadline bounds its wall time.
193
-
194
- A call that ends on a 401, 402 or 5xx marks the provider down for
195
- `DOWN_FOR` seconds, and until then every call returns None without a
196
- request (`_down`), so a dead credential is not re-paid on every prompt.
197
-
198
- Total by design: no key, an unreadable state, a refused request, a timeout,
199
- a reply that is not the documented shape - all `None`, never an exception."""
200
- provider, secret = credential()
201
- if not secret:
228
+ beside it because the provider that answers is not the caller's choice. The
229
+ model is the one the reply names, else the one asked for: an alias such as
230
+ `jev-latest` hides a silent upgrade the reply's own field shows. `model` is
231
+ TypeSafe's alone; the session CLI and the chat fallback ignore it.
232
+
233
+ The providers are tried in `providers()` order - the session's own CLI
234
+ first - and the next one is asked only when the previous failed. `fallback`
235
+ is None when the session answered, else why someone else did; that answer,
236
+ and a refusal, is recorded (`_record`) and said on stderr. A transient
237
+ failure - a timeout, a connection error, a 5xx - gets one more attempt on
238
+ the same provider, because a live measurement saw 2 of 50 calls lost that
239
+ way while the same cells answered on retry; a 4xx, a CLI that exited
240
+ non-zero and a reply that parsed malformed are never retried, since the
241
+ second request would fail identically and only a call that already worked
242
+ must not be billed twice. A caller on a hook's budget passes `attempts=1`
243
+ and a `deadline` in seconds: urllib's `timeout` bounds one socket operation,
244
+ not the call, so only the deadline bounds its wall time.
245
+
246
+ A call that ends on a 401, 402, 5xx or a session CLI's non-zero exit marks
247
+ that provider down for `DOWN_FOR` seconds, and until then it is skipped
248
+ without a request (`_down`), so a dead credential is not re-paid on every
249
+ prompt.
250
+
251
+ Total by design: no provider, an unreadable state, a refused request, a
252
+ timeout, a reply that is not the documented shape - all `None`, never an
253
+ exception.
254
+
255
+ `only` names the providers the caller requires (`named()`); a caller whose
256
+ decision must come from a typed model passes `only=("typesafe",)` and gets
257
+ None rather than another provider's answer."""
258
+ tried = providers() if only is None else named(only)
259
+ if any(isinstance(q, dict) and q.get("type") == "text" for q in questions.values()):
260
+ # TypeSafe answers no prose: left out up front, not logged as a failure
261
+ tried = [(p, s) for p, s in tried if p != "typesafe"]
262
+ if not tried or not tried[0][1]:
202
263
  return None
264
+ stop = None if deadline is None else time.monotonic() + deadline
265
+ failed = []
266
+ for provider, secret in tried:
267
+ result, why = _ask_one(provider, secret, state, questions, model,
268
+ timeout, attempts, stop)
269
+ if result is None:
270
+ failed.append("%s: %s" % (provider, why))
271
+ continue
272
+ # a provider the caller named is not a stand-in: no note, unless an
273
+ # earlier one it named failed
274
+ note = "; ".join(failed) or (None if only is not None or provider in SESSION_ARGV
275
+ else NO_SESSION)
276
+ result["fallback"] = note
277
+ _record(result["provider"], result["model"], fallback=note)
278
+ return result
279
+ refused = None
280
+ session = tp.session_cli()
281
+ if session and (key() or openrouter_key()):
282
+ refused = ("fallback=%s refuses a third-party judge after %s failed"
283
+ % (tp.fallback_policy(), session))
284
+ _record(None, None, failed="; ".join(failed), refused=refused)
285
+ return None
286
+
287
+
288
+ def _ask_one(provider, secret, state, questions, model, timeout, attempts, stop):
289
+ """One provider's attempts: `(result, None)`, or `(None, why)`."""
203
290
  try:
204
291
  if provider == "typesafe":
205
292
  used, url = model, endpoint()
@@ -208,34 +295,43 @@ def ask(state, questions, *, model=MODEL, timeout=30, attempts=2, deadline=None)
208
295
 
209
296
  def send():
210
297
  return _request(secret, body, timeout)
211
- else:
298
+ elif provider == "openrouter":
212
299
  used, url = fallback_model(), openrouter_url()
213
300
  body = json.dumps(_chat_body(state, questions, used)).encode()
214
301
 
215
302
  def send():
216
303
  return _chat_request(secret, body, timeout, questions)
304
+ else:
305
+ used, url = None, secret
306
+ prompt = json.dumps({"state": state, "questions": questions})
307
+
308
+ def send():
309
+ left = timeout if stop is None else max(0.1, min(timeout, stop - time.monotonic()))
310
+ return _session_request(provider, secret, prompt, left, questions)
217
311
  marker = _down_marker(provider, url, secret)
218
312
  if _down(marker):
219
- return None
220
- except Exception:
221
- return None
222
- stop = None if deadline is None else time.monotonic() + deadline
313
+ return None, "marked down for %ds after a refusal" % DOWN_FOR
314
+ except Exception as exc:
315
+ return None, _why(exc)
223
316
  for attempt in range(attempts):
317
+ if stop is not None and time.monotonic() >= stop:
318
+ return None, "deadline passed"
224
319
  try:
225
320
  result = _bounded(send, stop)
226
321
  except Exception as exc:
227
322
  late = stop is not None and time.monotonic() >= stop
228
323
  if attempt + 1 >= attempts or late or not _transient(exc):
229
324
  code = getattr(exc, "code", None)
230
- if code in (401, 402) or (isinstance(code, int) and code >= 500):
325
+ if code in (401, 402) or (isinstance(code, int) and code >= 500) \
326
+ or isinstance(exc, SessionFailed):
231
327
  _mark_down(marker)
232
- return None
328
+ return None, _why(exc)
233
329
  continue
234
330
  named = result.get("model")
235
- result["model"] = named if isinstance(named, str) and named else used
331
+ result["model"] = named if isinstance(named, str) and named else used or "-"
236
332
  result["provider"] = provider
237
- return result
238
- return None
333
+ return result, None
334
+ return None, "no attempt"
239
335
 
240
336
 
241
337
  def _answer(result, id):
@@ -260,6 +356,14 @@ def choice(result, id):
260
356
  return value if isinstance(value, str) else None
261
357
 
262
358
 
359
+ def text(result, id):
360
+ """The prose a `text` question came back with, stripped, or None. Only a
361
+ generative provider answers one; TypeSafe is never sent one."""
362
+ answer = _answer(result, id)
363
+ value = answer.get("text") if answer else None
364
+ return (value.strip() or None) if isinstance(value, str) else None
365
+
366
+
263
367
  def noul(result, id, option=None):
264
368
  """The probability a Noul question came back with, or None.
265
369
 
@@ -380,17 +484,20 @@ def _chat_answers(parsed, questions):
380
484
  def _clean_answer(raw, kind):
381
485
  """One answer in the shape its question type names, or None.
382
486
 
383
- The two shapes this repository's callers send - `noul`, the probability a
384
- statement holds, and `choice`, the label taken - map onto exactly what
385
- `choice()`/`noul()` already read, so a fallback answer and a TypeSafe answer
386
- are indistinguishable downstream. Any other type reads as no answer rather
387
- than as a guess. Probabilities are kept only when they are numbers, because
388
- `noul()` does the arithmetic on them."""
487
+ The shapes this repository's callers send - `noul`, the probability a
488
+ statement holds, `choice`, the label taken, and `text`, prose - map onto
489
+ exactly what `choice()`/`noul()`/`text()` already read, so a fallback answer
490
+ and a TypeSafe answer are indistinguishable downstream. Any other type reads
491
+ as no answer rather than as a guess. Probabilities are kept only when they
492
+ are numbers, because `noul()` does the arithmetic on them."""
389
493
  if not isinstance(raw, dict):
390
494
  return None
391
495
  if kind == "noul":
392
496
  value = raw.get("noul")
393
497
  return {"noul": float(value)} if isinstance(value, (int, float)) else None
498
+ if kind == "text":
499
+ value = raw.get("text")
500
+ return {"text": value} if isinstance(value, str) and value.strip() else None
394
501
  if kind != "choice":
395
502
  return None
396
503
  if not isinstance(raw.get("choice"), str):
@@ -485,3 +592,118 @@ def _bounded(send, stop):
485
592
  if "err" in box:
486
593
  raise box["err"]
487
594
  return box["ok"]
595
+
596
+
597
+ class SessionFailed(RuntimeError):
598
+ """The session CLI exited non-zero or printed no answer: a logged-out CLI or
599
+ an exhausted plan fails the same way on the next call, so it is never
600
+ retried and it marks the provider down like a 401."""
601
+
602
+
603
+ def _session_request(name, exe, prompt, timeout, questions):
604
+ """One headless run of the session's own CLI, mapped into the documented
605
+ return. It starts in an empty temp dir with stdin closed - the answer rests
606
+ on the prompt alone, and a CLI that wants a login exits instead of waiting -
607
+ with TEZGAH_NESTED set so tezgah's own hooks stand down in the child. The
608
+ answer is read from the CLI's JSON output: the model and the usage it names
609
+ are what the call really used."""
610
+ import shutil
611
+ import subprocess
612
+ import tempfile
613
+ cwd = tempfile.mkdtemp(prefix="tezgah-judge-")
614
+ started = time.monotonic()
615
+ try:
616
+ proc = subprocess.run([exe] + SESSION_ARGV[name] + [prompt], cwd=cwd,
617
+ stdin=subprocess.DEVNULL, capture_output=True,
618
+ text=True, timeout=timeout,
619
+ env=dict(os.environ, TEZGAH_NESTED="1"))
620
+ finally:
621
+ shutil.rmtree(cwd, ignore_errors=True)
622
+ if proc.returncode:
623
+ raise SessionFailed("%s exit %d: %s" % (name, proc.returncode,
624
+ (proc.stderr or proc.stdout).strip()[-200:]))
625
+ if name == "claude":
626
+ data = json.loads(proc.stdout)
627
+ if data.get("is_error"):
628
+ raise SessionFailed("claude: %s" % str(data.get("result"))[:200])
629
+ text = data.get("result") or ""
630
+ model = next(iter(data.get("modelUsage") or {}), None)
631
+ usage = data.get("usage") or {}
632
+ tokens_in = sum(int(usage.get(k) or 0) for k in (
633
+ "input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"))
634
+ tokens_out = int(usage.get("output_tokens") or 0)
635
+ else:
636
+ message = None
637
+ for line in proc.stdout.splitlines():
638
+ try:
639
+ event = json.loads(line)
640
+ except ValueError:
641
+ continue
642
+ if isinstance(event, dict) and event.get("type") == "message_end" \
643
+ and (event.get("message") or {}).get("role") == "assistant":
644
+ message = event["message"]
645
+ if message is None:
646
+ raise SessionFailed("omp printed no answer")
647
+ text = "".join(part.get("text") or "" for part in message.get("content") or []
648
+ if isinstance(part, dict) and part.get("type") == "text")
649
+ model = "/".join(str(message[k]) for k in ("provider", "model") if message.get(k))
650
+ usage = message.get("usage") or {}
651
+ tokens_in = sum(int(usage.get(k) or 0) for k in ("input", "cacheRead", "cacheWrite"))
652
+ tokens_out = int(usage.get("output") or 0)
653
+ # A chat model may fence its object; the outermost braces are the object.
654
+ answers = _chat_answers(json.loads(text[text.find("{"):text.rfind("}") + 1]), questions)
655
+ return {"answers": answers,
656
+ "usage": {"input_tokens": tokens_in, "output_tokens": tokens_out},
657
+ "latency_ms": int((time.monotonic() - started) * 1000),
658
+ "model": model}
659
+
660
+
661
+ def _why(exc):
662
+ """A failure as one short reason: the HTTP code, else the exception's text."""
663
+ code = getattr(exc, "code", None)
664
+ if isinstance(code, int):
665
+ return "http %d" % code
666
+ return ("%s: %s" % (type(exc).__name__, exc))[:200]
667
+
668
+
669
+ # The last judgement's record, one file: what answered, and why it was not the
670
+ # session's own CLI when it was not. `tezgah-status --judge` prints it.
671
+ LAST = "judge-last.json"
672
+
673
+
674
+ def _say(line):
675
+ try:
676
+ import sys
677
+ sys.stderr.write("tezgah-judge: %s\n" % line)
678
+ except Exception:
679
+ pass
680
+
681
+
682
+ def _record(provider, model, fallback=None, failed=None, refused=None):
683
+ """Write the last-use record and say any fallback or refusal on stderr.
684
+ Best effort like every hook write: a cache that cannot be written loses
685
+ the record, never the answer."""
686
+ if fallback and provider:
687
+ _say("answered by %s/%s - %s" % (provider, model, fallback))
688
+ elif refused:
689
+ _say("no judgement: %s (%s)" % (refused, failed))
690
+ row = {"at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "provider": provider,
691
+ "model": model, "fallback": fallback, "failed": failed or None,
692
+ "refused": refused, "policy": tp.fallback_policy()}
693
+ try:
694
+ path = os.path.join(tp.cache_dir(), LAST)
695
+ with open(path + ".tmp", "w", encoding="utf-8") as fh:
696
+ json.dump(row, fh)
697
+ os.replace(path + ".tmp", path)
698
+ except (OSError, TypeError, ValueError):
699
+ pass
700
+
701
+
702
+ def last_use():
703
+ """The last judgement's record (see `_record`), or None when none was kept."""
704
+ try:
705
+ with open(os.path.join(tp.cache_dir(), LAST), encoding="utf-8") as fh:
706
+ row = json.load(fh)
707
+ except (OSError, ValueError):
708
+ return None
709
+ return row if isinstance(row, dict) else None
@@ -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 every
286
- other funded family, each on that slot's own row.
287
-
288
- The fallbacks are rotated by the agent's own name (`agent`): identical chains
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)