@r1z4x/tezgah 1.5.1 → 1.7.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 (81) hide show
  1. package/CHANGELOG.md +126 -1
  2. package/MANIFEST +11 -0
  3. package/README.md +6 -3
  4. package/bin/consult +13 -10
  5. package/bin/tezgah-docs +18 -16
  6. package/bin/tezgah-doctor +152 -0
  7. package/bin/tezgah-research +286 -12
  8. package/bin/tezgah-route +48 -1
  9. package/bin/tezgah-setup +27 -14
  10. package/bin/tezgah-task +771 -46
  11. package/bin/tezgah-taste +48 -10
  12. package/docs/architecture.md +7 -4
  13. package/docs/citations-baseline.json +1 -1
  14. package/docs/clarity-baseline.json +5 -5
  15. package/docs/contract.md +112 -25
  16. package/docs/evidence.md +201 -28
  17. package/docs/feature-audit.md +25 -4
  18. package/docs/gate.md +70 -33
  19. package/docs/glossary.md +4 -4
  20. package/docs/hosts.md +8 -8
  21. package/docs/index.json +30 -10
  22. package/docs/judge.md +9 -3
  23. package/docs/models.md +35 -2
  24. package/docs/operations.md +103 -3
  25. package/docs/research.md +392 -13
  26. package/docs/skills.md +24 -6
  27. package/docs/status-line.md +5 -5
  28. package/docs/testing.md +2 -2
  29. package/docs/vision.md +12 -4
  30. package/hooks/hooks.json +2 -2
  31. package/hooks/lang/en.json +323 -0
  32. package/hooks/lang/tr.json +344 -0
  33. package/hooks/projects-posttooluse.py +24 -5
  34. package/hooks/projects-pretooluse.py +9 -4
  35. package/hooks/projects-stop.py +28 -16
  36. package/hooks/tezgah_agents.py +29 -9
  37. package/hooks/tezgah_clarity.py +7 -3
  38. package/hooks/tezgah_components.py +2 -1
  39. package/hooks/tezgah_context.py +460 -390
  40. package/hooks/tezgah_gate.py +640 -104
  41. package/hooks/tezgah_integrity.py +1480 -197
  42. package/hooks/tezgah_models.py +337 -15
  43. package/hooks/tezgah_paths.py +103 -10
  44. package/hooks/tezgah_policy.py +286 -130
  45. package/hooks/tezgah_product.py +107 -0
  46. package/hooks/tezgah_rank.py +29 -27
  47. package/hooks/tezgah_replay.py +200 -30
  48. package/hooks/tezgah_research.py +1297 -12
  49. package/hooks/tezgah_skill_pick.py +58 -17
  50. package/hooks/tezgah_stopspec.py +7 -0
  51. package/hooks/tezgah_store.py +74 -27
  52. package/hooks/tezgah_task.py +917 -8
  53. package/hosts/codex/hook.py +9 -10
  54. package/hosts/cursor/hook.py +21 -14
  55. package/hosts/dsh/hooks.json +2 -2
  56. package/hosts/omp/hook.py +44 -23
  57. package/hosts/omp/tezgah-hook.ts.in +93 -7
  58. package/hosts/opencode/plugins/tezgah.js +119 -39
  59. package/output-styles/tezgah.md +76 -42
  60. package/package.json +1 -1
  61. package/skills/analyze-app/SKILL.md +57 -0
  62. package/skills/design-contract/EVALS.md +24 -1
  63. package/skills/design-contract/SKILL.md +17 -1
  64. package/skills/design-contract/data-views.md +86 -0
  65. package/skills/feature-audit/SKILL.md +32 -3
  66. package/skills/i-have-adhd/EVALS.md +22 -2
  67. package/skills/i-have-adhd/SKILL.md +12 -5
  68. package/skills/plan-add/EVALS.md +8 -8
  69. package/skills/plan-add/SKILL.md +22 -21
  70. package/skills/plan-sync/EVALS.md +2 -2
  71. package/skills/plan-sync/SKILL.md +14 -8
  72. package/skills/pm-frameworks/SKILL.md +6 -0
  73. package/skills/ponytail/EVALS.md +21 -2
  74. package/skills/ponytail/SKILL.md +4 -4
  75. package/skills/product-analysis/EVALS.md +23 -1
  76. package/skills/product-analysis/SKILL.md +50 -11
  77. package/skills/product-flow/EVALS.md +81 -0
  78. package/skills/product-flow/SKILL.md +91 -0
  79. package/skills/research/SKILL.md +99 -2
  80. package/skills/tezgah-contract/EVALS.md +52 -7
  81. package/skills/tezgah-contract/SKILL.md +204 -85
@@ -37,6 +37,7 @@ tier from the static table, and with no phase the middle tier is used.
37
37
 
38
38
  Stdlib only; every network or subprocess failure is a fallback, never a raise.
39
39
  """
40
+ import contextlib
40
41
  import datetime
41
42
  import json
42
43
  import os
@@ -232,18 +233,331 @@ def _omp_stored_providers():
232
233
  return {row[0] for row in rows}
233
234
 
234
235
 
235
- def funded_families():
236
+ def funded_families(live=True):
236
237
  """The `OMP_FAMILIES` with a credential this machine shows: an omp auth-store
237
- entry, an env key omp reads, or (for `any`) `openrouter_ready`.
238
+ entry, an env key omp reads, or (for `any`) `openrouter_ready` - minus, with
239
+ `live`, the families the health record holds as down (`down_families`).
238
240
 
239
241
  A credential is not a balance - a funded-looking key can still answer 402 -
240
- which is why each agent gets a chain rather than one more pin. A family whose
241
- credential cannot be detected is left out, never guessed in."""
242
+ which is why each agent gets a chain rather than one more pin, and why a
243
+ family whose provider answered a limit leaves the chain until its reset. A
244
+ family whose credential cannot be detected is left out, never guessed in."""
242
245
  stored = _omp_stored_providers()
246
+ down = down_families() if live else set()
243
247
  return [family for family in OMP_FAMILIES
244
- if OMP_AUTH[family][0] in stored
245
- or any(os.environ.get(env, "").strip() for env in OMP_AUTH[family][1])
246
- or (family == "any" and openrouter_ready())]
248
+ if family not in down
249
+ and (OMP_AUTH[family][0] in stored
250
+ or any(os.environ.get(env, "").strip() for env in OMP_AUTH[family][1])
251
+ or (family == "any" and openrouter_ready()))]
252
+
253
+
254
+ # ------------------------------------------------------------ provider health
255
+ #
256
+ # A family with a credential can still be spent: on 2026-10-11 every subagent
257
+ # chain read `anthropic, openrouter`, one Anthropic 429 moved the subagents onto
258
+ # an OpenRouter key past its limit (403), and they died; zai, funded in omp's
259
+ # store, answered `429 Weekly/Monthly Limit Exhausted. Your limit will reset at
260
+ # 2026-10-12 02:02:35`. The record below holds which family is down and until
261
+ # when; `funded_families` leaves those out, and `tezgah-route --health` - started
262
+ # in the background at session start when due - probes the non-mode families
263
+ # and re-applies omp's chain, so a family rejoins it once its reset has passed.
264
+ HEALTH_FILE = "model-health.json"
265
+ HEALTH_EVERY = 6 * 3600 # re-probe at most this often, unless a reset passed
266
+ HEALTH_DOWN = 6 * 3600 # how long a limit with no reset time keeps a family out
267
+ HEALTH_RATE = 600 # the same for a rate limit (429): it lifts in minutes
268
+ HEALTH_DOWN_MAX = 7 * 86400 # the longest a named reset is believed
269
+ OPENROUTER_KEY = "https://openrouter.ai/api/v1/key"
270
+ _RESET_AT = re.compile(r"reset[s]? (?:at|on) (\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})")
271
+ _RETRY_MS = re.compile(r"retry-after-ms=(\d+)")
272
+ _LIMITED = re.compile(r"\b(?:429|402|403)\b|rate[ _-]?limit|quota|limit exhausted|"
273
+ r"key limit exceeded|usage limit|insufficient (?:credit|balance)",
274
+ re.I)
275
+
276
+
277
+ def _health_path():
278
+ return os.path.join(tp.cache_dir(), HEALTH_FILE)
279
+
280
+
281
+ def read_health():
282
+ """{"checked": epoch or None, "down": {family: {"until": epoch, "reason": str}}};
283
+ an unreadable record reads as empty - nothing known down."""
284
+ try:
285
+ with open(_health_path(), encoding="utf-8") as fh:
286
+ data = json.load(fh)
287
+ except (OSError, ValueError):
288
+ data = {}
289
+ if not isinstance(data, dict):
290
+ data = {}
291
+ down = data.get("down") if isinstance(data.get("down"), dict) else {}
292
+ return {"checked": data.get("checked"), "down": down}
293
+
294
+
295
+ def write_health(data):
296
+ """Write the record atomically; False when the cache dir cannot be written."""
297
+ path = _health_path()
298
+ try:
299
+ os.makedirs(os.path.dirname(path), exist_ok=True)
300
+ tmp = "%s.%d.tmp" % (path, os.getpid())
301
+ with open(tmp, "w", encoding="utf-8") as fh:
302
+ json.dump(data, fh, indent=1, sort_keys=True)
303
+ os.replace(tmp, path)
304
+ return True
305
+ except OSError:
306
+ return False
307
+
308
+
309
+ def down_families(now=None, data=None):
310
+ """The families the record holds as down whose reset has not passed yet."""
311
+ import time
312
+ now = time.time() if now is None else now
313
+ data = read_health() if data is None else data
314
+ return {family for family, row in data["down"].items()
315
+ if isinstance(row, dict) and isinstance(row.get("until"), (int, float))
316
+ and row["until"] > now}
317
+
318
+
319
+ def limit_until(text, now=None, default=HEALTH_DOWN):
320
+ """When a provider's limit answer says it lifts, as an epoch, or None when the
321
+ text is no limit answer at all. A named reset (`reset at 2026-10-12 02:02:35`,
322
+ read as local time) or a `retry-after-ms` wins; else `default` from now. A
323
+ reset further than HEALTH_DOWN_MAX away is capped: a wrong year must not lock a
324
+ family out for good."""
325
+ import time
326
+ now = time.time() if now is None else now
327
+ text = str(text or "")
328
+ if not _LIMITED.search(text):
329
+ return None
330
+ until = None
331
+ hit = _RESET_AT.search(text)
332
+ if hit:
333
+ try:
334
+ until = time.mktime(time.strptime("%s %s" % hit.groups(), "%Y-%m-%d %H:%M:%S"))
335
+ except (ValueError, OverflowError):
336
+ until = None
337
+ if until is not None and until <= now:
338
+ # a named reset already past is a clock we misread (the provider's
339
+ # zone is not stated): a minute's hold would re-probe every prompt
340
+ until = now + default
341
+ if until is None:
342
+ ms = _RETRY_MS.search(text)
343
+ until = now + int(ms.group(1)) / 1000.0 if ms else now + default
344
+ return min(max(until, now + 60), now + HEALTH_DOWN_MAX)
345
+
346
+
347
+ @contextlib.contextmanager
348
+ def _health_locked():
349
+ """An exclusive flock on the record's lock file around one re-read, change
350
+ and write, so two writers (a slice's stop, the probe) do not drop each
351
+ other's hold. Unlocked where fcntl is missing or the file cannot be opened."""
352
+ fh = None
353
+ try:
354
+ import fcntl
355
+ os.makedirs(os.path.dirname(_health_path()), exist_ok=True)
356
+ fh = open(_health_path() + ".lock", "a", encoding="utf-8")
357
+ fcntl.flock(fh, fcntl.LOCK_EX)
358
+ except (ImportError, OSError):
359
+ pass
360
+ try:
361
+ yield
362
+ finally:
363
+ if fh is not None:
364
+ fh.close()
365
+
366
+
367
+ def _family(provider="", model="", default=""):
368
+ """The OMP_FAMILIES entry a provider id (omp's auth-store id), else a model id
369
+ (`_vendor`), else `default` runs under; None when none is one."""
370
+ by_provider = {auth[0]: family for family, auth in OMP_AUTH.items()}
371
+ for family in (by_provider.get(str(provider or "").lower()),
372
+ _vendor(str(model)) if model else None, default):
373
+ if family in OMP_FAMILIES:
374
+ return family
375
+ return None
376
+
377
+
378
+ def note_limit(text, provider="", model="", transient=False, default="", now=None):
379
+ """Hold down the family a slice stopped on with a limit answer (`text`), and
380
+ re-apply omp's chain, so the next spawn of this session leaves that family
381
+ instead of the next session start's probe moving it (research
382
+ orchestrator-flow-audit S2, F3a). A rate limit (`transient`) with no reset
383
+ named holds HEALTH_RATE, a quota HEALTH_DOWN. Returns the family newly held,
384
+ else None: no family resolves (`_family`), `text` is no limit answer
385
+ (`limit_until`), the family is already held, or TEZGAH_HEALTH_CHECK=0 (the
386
+ suite's switch)."""
387
+ import time
388
+ family = _family(provider, model, default)
389
+ if not family or os.environ.get("TEZGAH_HEALTH_CHECK") == "0":
390
+ return None
391
+ now = time.time() if now is None else now
392
+ until = limit_until(text, now, HEALTH_RATE if transient else HEALTH_DOWN)
393
+ if until is None:
394
+ return None
395
+ with _health_locked():
396
+ data = read_health()
397
+ if family in down_families(now, data):
398
+ return None
399
+ data["down"][family] = {"until": until,
400
+ "reason": " ".join(str(text).split())[:200]}
401
+ if not write_health(data):
402
+ return None
403
+ apply_omp()
404
+ return family
405
+
406
+
407
+ def other_family_up(text):
408
+ """Where work a limit stopped on can move: the first family the routing lets
409
+ a chain fall back to (`_chain` under `tp.fallback_policy()`, every slot) that
410
+ is funded and not down (`funded_families`) and that `text` does not name
411
+ (`named_families`), else None. None with routing off or under
412
+ TEZGAH_HEALTH_CHECK=0, so the suite never reads this machine's credentials."""
413
+ if os.environ.get("TEZGAH_HEALTH_CHECK") == "0":
414
+ return None
415
+ mode = omp_mode()
416
+ if mode not in OMP_FAMILIES:
417
+ return None
418
+ funded = funded_families(live=True)
419
+ chained = {sel for slot in SLOTS for sel in _chain(mode, slot, funded)}
420
+ named = named_families(text)
421
+ return next((family for family in funded if family not in named and any(
422
+ sel.startswith(OMP_AUTH[family][0] + "/") for sel in chained)), None)
423
+
424
+
425
+ def named_families(text):
426
+ """The OMP_FAMILIES `text` names by an auth-store provider id (a whole word)
427
+ or a model stem (`VENDOR_STEMS`, a word start): `openrouter 403` names `any`,
428
+ `glm-5.3` names `zai`."""
429
+ low = str(text or "").lower()
430
+ return {family for family in OMP_FAMILIES
431
+ if re.search(r"\b%s\b" % re.escape(OMP_AUTH[family][0]), low)
432
+ or any(re.search(r"\b%s" % re.escape(s), low)
433
+ for s, v in VENDOR_STEMS if v == family)}
434
+
435
+
436
+ def _openrouter_key():
437
+ env, home = tp.PROVIDER_KEYS["openrouter"]
438
+ key = os.environ.get(env, "").strip()
439
+ if key:
440
+ return key
441
+ try:
442
+ with open(os.path.join(tp.HOME, home), encoding="utf-8") as fh:
443
+ return fh.read().strip()
444
+ except OSError:
445
+ return ""
446
+
447
+
448
+ def probe_family(family, timeout=60):
449
+ """(up, reason) for one family: None for up when the probe could not tell.
450
+
451
+ `any` asks OpenRouter's key endpoint, which costs no tokens: a key whose
452
+ `limit_remaining` is 0 or less is down. A family omp runs is asked one short
453
+ prompt on that family's cheap row through `omp -p --no-session`; exit 0 is
454
+ up, a limit answer is down (its reset in `reason`), anything else is unknown."""
455
+ if family == "any":
456
+ key = _openrouter_key()
457
+ if not key:
458
+ return None, "no OpenRouter key"
459
+ req = urllib.request.Request(OPENROUTER_KEY,
460
+ headers={"Authorization": "Bearer " + key})
461
+ try:
462
+ with urllib.request.urlopen(req, timeout=15) as resp:
463
+ info = (json.loads(resp.read().decode("utf-8")) or {}).get("data") or {}
464
+ except Exception as exc: # noqa: BLE001 - any network failure is "cannot tell"
465
+ text = str(exc)
466
+ return (False, text) if _LIMITED.search(text) else (None, text)
467
+ left = info.get("limit_remaining")
468
+ if isinstance(left, (int, float)) and left <= 0:
469
+ return False, "OpenRouter key limit exhausted (limit %s, usage %s)" % (
470
+ info.get("limit"), info.get("usage"))
471
+ return True, "OpenRouter key has room"
472
+ exe = tp.omp_bin()
473
+ if not exe:
474
+ return None, "omp not found"
475
+ # TEZGAH_NESTED, as consult and the judge set it: a hook that does load in
476
+ # the child records nothing and refuses nothing
477
+ env = dict(os.environ, PI_CODING_AGENT_DIR=OMP_AGENT, TEZGAH_NESTED="1")
478
+ try:
479
+ # the consult argv: no tools, rules, skills, extensions or title call, so
480
+ # the probe is one short request and fires no nested tezgah hooks
481
+ argv = [exe] + tp.CONSULT_CLIS["omp"][0][1:]
482
+ proc = subprocess.run(argv + ["--model", _selector(family, "cheap"),
483
+ "--max-time=%d" % timeout, "Reply with exactly: OK"],
484
+ capture_output=True, text=True, timeout=timeout + 15,
485
+ env=env, cwd=tp.HOME)
486
+ except (OSError, subprocess.SubprocessError) as exc:
487
+ return None, str(exc)
488
+ text = (proc.stdout or "") + (proc.stderr or "")
489
+ if proc.returncode == 0:
490
+ return True, "answered"
491
+ tail = " ".join(text.split())[-200:]
492
+ return (False, tail) if _LIMITED.search(text) else (None, tail)
493
+
494
+
495
+ def refresh_health(mode=None, probe=probe_family, now=None):
496
+ """Probe every family with a credential except the mode's own (it leads the
497
+ chain whatever its state, and the session is running on it), record the
498
+ downs with their resets, and return the record. A family the probe cannot
499
+ judge keeps the state it had. The probes run unlocked; their results land on
500
+ the record re-read under the lock (`_health_locked`), so a hold a slice's
501
+ stop wrote meanwhile stays."""
502
+ import time
503
+ now = time.time() if now is None else now
504
+ mode = mode or omp_mode()
505
+ seen = {family: probe(family) for family in funded_families(live=False)
506
+ if family != mode}
507
+ with _health_locked():
508
+ down = dict(read_health()["down"])
509
+ for family, (up, reason) in seen.items():
510
+ if up is True:
511
+ down.pop(family, None)
512
+ elif up is False:
513
+ down[family] = {"until": limit_until(reason, now) or now + HEALTH_DOWN,
514
+ "reason": reason[:200]}
515
+ data = {"checked": now, "down": {f: r for f, r in down.items()
516
+ if isinstance(r, dict) and r.get("until", 0) > now}}
517
+ write_health(data)
518
+ return data
519
+
520
+
521
+ def health_due(data=None, now=None):
522
+ """True when the record was never checked, is older than HEALTH_EVERY, or
523
+ holds a down family whose reset has passed since it was checked. Never with
524
+ TEZGAH_HEALTH_CHECK=0 (the test suite's switch, like TEZGAH_UPDATE_CHECK)."""
525
+ import time
526
+ if os.environ.get("TEZGAH_HEALTH_CHECK") == "0":
527
+ return False
528
+ now = time.time() if now is None else now
529
+ data = read_health() if data is None else data
530
+ checked = data.get("checked")
531
+ if not isinstance(checked, (int, float)) or now - checked >= HEALTH_EVERY:
532
+ return True
533
+ return any(isinstance(row, dict) and checked < row.get("until", 0) <= now
534
+ for row in data["down"].values())
535
+
536
+
537
+ def start_health():
538
+ """Stamp the record, then start one detached `tezgah-route --health`, so the
539
+ session starts that follow read a fresh stamp and start nothing. False when
540
+ nothing started."""
541
+ import sys
542
+ import time
543
+ with _health_locked():
544
+ data = read_health()
545
+ data["checked"] = time.time()
546
+ if not write_health(data):
547
+ return False
548
+ route = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))),
549
+ "bin", "tezgah-route")
550
+ kwargs = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL,
551
+ "stderr": subprocess.DEVNULL, "close_fds": True}
552
+ if os.name == "posix":
553
+ kwargs["start_new_session"] = True
554
+ else: # pragma: no cover - Windows: no console, outlives its parent
555
+ kwargs["creationflags"] = 0x00000008 | 0x00000200
556
+ try:
557
+ subprocess.Popen([sys.executable, route, "--health"], **kwargs)
558
+ return True
559
+ except OSError:
560
+ return False
247
561
 
248
562
 
249
563
  def omp_mode(default_selector=None):
@@ -308,11 +622,14 @@ def _chain(mode, slot, funded, agent=""):
308
622
  because a session on Opus that ran its subagents on GPT and GLM after one
309
623
  Anthropic 429 is the failure the owner reported (2026-10-07). `none`: the
310
624
  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
312
- for every agent meant one family's 429 moved all of them onto the same next
313
- family in the same moment, which is the cascade the chain exists to break
314
- (independent review, 2026-10-01). The rotation is deterministic - the same
315
- agent resolves the same order on every machine - so nothing here is random."""
625
+ slot's own row. The families omp runs on an account (zai) come before
626
+ OpenRouter, which bills per token - the owner's ask (2026-10-11) was to use
627
+ zai when it is up - and among those the order is rotated by the agent's own
628
+ name (`agent`): identical chains for every agent meant one family's 429
629
+ moved all of them onto the same next family in the same moment, which is the
630
+ cascade the chain exists to break (independent review, 2026-10-01). The
631
+ rotation is deterministic - the same agent resolves the same order on every
632
+ machine - so nothing here is random."""
316
633
  policy = tp.fallback_policy()
317
634
  families = [mode] + [f for f in OMP_FAMILIES if f != mode and f in funded]
318
635
  if policy == "none":
@@ -327,10 +644,11 @@ def _chain(mode, slot, funded, agent=""):
327
644
  out.append("openrouter/" + OPENROUTER_ID[model]
328
645
  + (":" + effort if effort else ""))
329
646
  return list(dict.fromkeys(out))
330
- tail = families[1:]
647
+ tail = [f for f in families[1:] if f != "any"]
331
648
  if agent and len(tail) > 1:
332
649
  shift = sum(ord(c) for c in agent) % len(tail)
333
- families = families[:1] + tail[shift:] + tail[:shift]
650
+ tail = tail[shift:] + tail[:shift]
651
+ families = families[:1] + tail + [f for f in families[1:] if f == "any"]
334
652
  return [_selector(family, slot) for family in families]
335
653
 
336
654
 
@@ -552,7 +870,11 @@ def apply_omp(remove=False):
552
870
  owns.update(owns_chains)
553
871
  merged_chains = dict(chains)
554
872
  for key in flat_chains:
555
- role = key.split(".", 1)[1]
873
+ role = key.rsplit(".", 1)[1]
874
+ # an earlier release wrote the chain under `fallbackChains.<role>`, a key
875
+ # omp never reads (the `split(".", 1)` of the flat key); it is tezgah's
876
+ # own and goes
877
+ merged_chains.pop("fallbackChains." + role, None)
556
878
  value = new_chains.get(key)
557
879
  if value is None:
558
880
  merged_chains.pop(role, None)
@@ -70,7 +70,8 @@ OFF_DIRS = (CONFIG_DIR, os.path.join(HOME, ".claude"))
70
70
  # the opencode plugin's own `off()` and the gate's control rule all answer from
71
71
  # it, and tests/test_paths.py fails when any of them names a switch it lacks.
72
72
  # CORE_SWITCHES are the sixteen the always-on contract names. ADR 007 leaves
73
- # UNCLASSIFIED_SWITCHES open - two `off()` names CORE does not list and two
73
+ # UNCLASSIFIED_SWITCHES open - three `off()` names CORE does not list (the
74
+ # third, `slices-off`, stops the slice journal: hooks/tezgah_task.py) and two
74
75
  # opt-in markers read with `armed()` - and until the owner classifies them the
75
76
  # control rule protects them like the rest (bin/tezgah-docs carries the same
76
77
  # set for its inventory check; the test pins the two equal).
@@ -78,7 +79,7 @@ CORE_SWITCHES = ("adhd-off", "consult-off", "docs-judge-off", "exec-mode.off",
78
79
  "judge-off", "lang-off", "orchestrate-off", "ponytail-auto.off",
79
80
  "pretooluse-off", "reminder-off", "research-off", "spec-off",
80
81
  "task-off", "triage-off", "verify-off", "workspace-off")
81
- UNCLASSIFIED_SWITCHES = ("agents-off", "skill-suggest-on", "taste-on",
82
+ UNCLASSIFIED_SWITCHES = ("agents-off", "skill-suggest-on", "slices-off", "taste-on",
82
83
  "update-check-off")
83
84
  SWITCHES = CORE_SWITCHES + UNCLASSIFIED_SWITCHES
84
85
  REPO_MARKS = (".no-adhd", ".no-graph", ".no-lessons", ".no-plan-gate",
@@ -737,17 +738,99 @@ def codex_bin():
737
738
  return which_user(os.environ.get("TEZGAH_CODEX_BIN") or "codex")
738
739
 
739
740
 
740
- # The values `reply_lang` (config.json) takes. The value itself is the switch:
741
- # `tr` (the default, and what an install without the key reads) asks for Turkish
742
- # replies, `en` for English ones, and `any` drops the Stop language check.
743
- REPLY_LANGS = ("tr", "en", "any")
741
+ # The reply language (config.json `language`; an older config's `reply_lang`
742
+ # still reads and is never rewritten). The value is the switch: a code with a
743
+ # pack under hooks/lang/ asks for replies in that language, `any` for the
744
+ # user's own, and only a pack that ships reply `markers` (tr) is judged by the
745
+ # Stop language check. A config naming neither key reads as `tr`, the default
746
+ # every install made before the packs keeps; a first install writes the
747
+ # environment's language instead (`env_language`, bin/tezgah-setup).
748
+ LANG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lang")
749
+ _PACKS = []
744
750
 
745
751
 
746
- def reply_lang():
747
- """The configured reply language, `tr` when unset or not one of REPLY_LANGS."""
752
+ def languages():
753
+ """The values `language` takes: every shipped pack's code, then `any`."""
754
+ return tuple(packs()) + ("any",)
755
+
756
+
757
+ def language():
758
+ """The configured reply language, `tr` when unset or not one of `languages()`."""
748
759
  cfg = config()
749
- value = cfg.get("reply_lang") if isinstance(cfg, dict) else None
750
- return value if value in REPLY_LANGS else "tr"
760
+ for key in ("language", "reply_lang"):
761
+ value = cfg.get(key) if isinstance(cfg, dict) else None
762
+ if value in languages():
763
+ return value
764
+ return "tr"
765
+
766
+
767
+ def spend_cap():
768
+ """The standing spend cap in USD, `spend_cap` in config.json: a paid run at
769
+ or under it is a process parameter the agent picks, one over it is asked
770
+ and waits (the ask policy's never-default class). 0 when unset, negative
771
+ or not a number, so every spend is asked."""
772
+ cfg = config()
773
+ value = cfg.get("spend_cap") if isinstance(cfg, dict) else None
774
+ ok = isinstance(value, (int, float)) and not isinstance(value, bool)
775
+ return float(value) if ok and value > 0 else 0.0
776
+
777
+
778
+ def env_language(environ=None):
779
+ """The language a first install asks for: the first of LANGUAGE (its own
780
+ first `:` entry), LC_ALL, LC_MESSAGES and LANG that is set and not empty -
781
+ GNU gettext's order - cut to its language code (`tr_TR.UTF-8` -> `tr`), or
782
+ `en` when that code has no pack (`C`, `POSIX`, a language not shipped)."""
783
+ environ = os.environ if environ is None else environ
784
+ for key in ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"):
785
+ value = (environ.get(key) or "").split(":")[0]
786
+ if value:
787
+ code = value.split(".")[0].split("@")[0].split("_")[0].lower()
788
+ return code if code in packs() else "en"
789
+ return "en"
790
+
791
+
792
+ def packs():
793
+ """{code: pack} for every hooks/lang/<code>.json, read once per process.
794
+
795
+ A pack is data: one language's half of every detection class (S5's
796
+ inventory) and the contract's own words (`say`), keyed alike in every pack
797
+ (tests/test_lang_packs.py). The detectors compile the union of all packs,
798
+ so a reply or prompt in any shipped language arms the same classes whatever
799
+ `language` says; a language is added by one file, never a regex edit."""
800
+ if not _PACKS:
801
+ found = {}
802
+ for name in sorted(os.listdir(LANG_DIR)):
803
+ if name.endswith(".json"):
804
+ with open(os.path.join(LANG_DIR, name), encoding="utf-8") as fh:
805
+ found[name[:-5]] = json.load(fh)
806
+ _PACKS.append(found)
807
+ return _PACKS[0]
808
+
809
+
810
+ def pack_values(*path):
811
+ """Each pack's value at `path` (nested keys), in pack order, skipping a
812
+ pack that lacks it."""
813
+ for pack in packs().values():
814
+ node = pack
815
+ for key in path:
816
+ node = node.get(key) if isinstance(node, dict) else None
817
+ if node is not None:
818
+ yield node
819
+
820
+
821
+ def alts(*path):
822
+ """The fragments every pack lists at `path`, joined into one alternation;
823
+ `(?!)`, which never matches, when no pack lists one."""
824
+ return "|".join(f for frags in pack_values(*path) for f in frags) or "(?!)"
825
+
826
+
827
+ def say(code=None):
828
+ """The contract's words for `code` (the configured language by default):
829
+ the English pack's `say` map with the code's own pack laid over it, so a
830
+ language with no pack, or `any`, speaks the canonical English tokens."""
831
+ words = dict(packs().get("en", {}).get("say", {}))
832
+ words.update(packs().get(code or language(), {}).get("say", {}))
833
+ return words
751
834
 
752
835
 
753
836
  # Switch latching (plan 051, ADR 018). A hook process that knows its session
@@ -861,6 +944,16 @@ def pony_level():
861
944
  return value if value in PONY_LEVELS else "full"
862
945
 
863
946
 
947
+ # The router's input-side tokens (input plus cache reads and writes) on its
948
+ # newest step, above which the per-turn reminder asks it to push heavy reads
949
+ # through a slice (tezgah_context.context_nudge). Measured on 12,576 omp
950
+ # main-thread steps (orchestrator-flow-audit S2 F1): median 396 k, p90 724 k. The
951
+ # median is the normal working state and would fire on every other turn, so the
952
+ # line sits just under p90: it marks the heaviest tenth of steps and stays rare
953
+ # enough to be read.
954
+ CONTEXT_NUDGE_TOKENS = 700000
955
+
956
+
864
957
  def tool(name):
865
958
  """Stable installed path for a tezgah CLI, else the plugin's own copy, so
866
959
  the text injected into a session names something that actually exists."""