@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/MANIFEST +8 -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-triage +5 -3
  12. package/docs/README.md +1 -0
  13. package/docs/architecture.md +5 -2
  14. package/docs/citations-baseline.json +1 -1
  15. package/docs/clarity-baseline.json +9 -9
  16. package/docs/contract.md +68 -26
  17. package/docs/environment.md +6 -3
  18. package/docs/evidence.md +7 -0
  19. package/docs/gate.md +13 -7
  20. package/docs/glossary.md +1 -1
  21. package/docs/hosts.md +48 -1
  22. package/docs/index.json +29 -1
  23. package/docs/judge.md +61 -55
  24. package/docs/models.md +14 -8
  25. package/docs/operations.md +10 -5
  26. package/docs/orca.md +126 -0
  27. package/docs/research.md +38 -3
  28. package/docs/skills.md +74 -0
  29. package/docs/status-line.md +3 -1
  30. package/docs/testing.md +33 -21
  31. package/docs/vision.md +12 -8
  32. package/hooks/projects-pretooluse.py +17 -5
  33. package/hooks/tezgah_agents.py +135 -13
  34. package/hooks/tezgah_components.py +3 -0
  35. package/hooks/tezgah_context.py +65 -22
  36. package/hooks/tezgah_gate.py +133 -17
  37. package/hooks/tezgah_integrity.py +60 -13
  38. package/hooks/tezgah_judge.py +239 -56
  39. package/hooks/tezgah_models.py +41 -5
  40. package/hooks/tezgah_orca.py +144 -0
  41. package/hooks/tezgah_paths.py +40 -6
  42. package/hooks/tezgah_policy.py +109 -35
  43. package/hooks/tezgah_research.py +104 -8
  44. package/hooks/tezgah_skill_pick.py +459 -8
  45. package/hooks/tezgah_stack.py +382 -0
  46. package/hooks/tezgah_stopspec.py +2 -2
  47. package/hosts/codex/hook.py +7 -1
  48. package/hosts/cursor/hook.py +7 -4
  49. package/hosts/omp/hook.py +12 -6
  50. package/hosts/omp/tezgah-hook.ts.in +20 -7
  51. package/hosts/opencode/plugins/tezgah.js +18 -9
  52. package/output-styles/tezgah.md +34 -19
  53. package/package.json +1 -1
  54. package/skills/i-have-adhd/SKILL.md +2 -2
  55. package/skills/research/SKILL.md +60 -17
  56. 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
@@ -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
- def credential():
152
- """`(provider, secret)` for the first channel that resolves, else `(None, None)`.
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
- 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)
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 key resolves and no kill switch."""
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 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:
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
- else:
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
@@ -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)
@@ -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)
@@ -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 resolves a credential: the env var
501
- it reads, else its key file - and, when neither resolves, the OpenRouter
502
- fallback's own two channels (`OPENROUTER_API_KEY`, `~/.config/openrouter/key`),
503
- which are the channels `hooks/tezgah_judge.py`'s `credential()` picks
504
- between. This is what `bin/tezgah-triage`, `bin/tezgah-docs` and the skill
505
- picker spend. Never omp's login store: the seam does not open it.
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