@r1z4x/tezgah 1.6.0 → 1.8.2

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 (71) hide show
  1. package/CHANGELOG.md +140 -1
  2. package/MANIFEST +7 -0
  3. package/README.md +6 -3
  4. package/bin/tezgah-decisions +6 -14
  5. package/bin/tezgah-design +21 -5
  6. package/bin/tezgah-docs +18 -16
  7. package/bin/tezgah-doctor +152 -0
  8. package/bin/tezgah-lessons +4 -1
  9. package/bin/tezgah-research +249 -17
  10. package/bin/tezgah-route +70 -8
  11. package/bin/tezgah-setup +26 -13
  12. package/bin/tezgah-task +877 -60
  13. package/bin/tezgah-taste +56 -15
  14. package/docs/architecture.md +28 -11
  15. package/docs/clarity-baseline.json +2 -2
  16. package/docs/contract.md +108 -24
  17. package/docs/environment.md +3 -1
  18. package/docs/evidence.md +209 -28
  19. package/docs/feature-audit.md +1 -1
  20. package/docs/gate.md +78 -23
  21. package/docs/glossary.md +4 -4
  22. package/docs/hosts.md +8 -8
  23. package/docs/index.json +11 -5
  24. package/docs/judge.md +9 -3
  25. package/docs/models.md +75 -14
  26. package/docs/operations.md +133 -3
  27. package/docs/research.md +349 -15
  28. package/docs/skills.md +1 -1
  29. package/docs/status-line.md +5 -5
  30. package/docs/testing.md +20 -13
  31. package/docs/vision.md +12 -4
  32. package/hooks/hooks.json +2 -2
  33. package/hooks/lang/en.json +323 -0
  34. package/hooks/lang/tr.json +344 -0
  35. package/hooks/projects-auto-init.py +29 -1
  36. package/hooks/projects-posttooluse.py +24 -5
  37. package/hooks/projects-pretooluse.py +9 -4
  38. package/hooks/projects-stop.py +28 -16
  39. package/hooks/tezgah_agents.py +53 -17
  40. package/hooks/tezgah_clarity.py +7 -3
  41. package/hooks/tezgah_context.py +489 -438
  42. package/hooks/tezgah_gate.py +996 -80
  43. package/hooks/tezgah_integrity.py +1547 -197
  44. package/hooks/tezgah_models.py +427 -45
  45. package/hooks/tezgah_paths.py +147 -16
  46. package/hooks/tezgah_policy.py +261 -129
  47. package/hooks/tezgah_rank.py +29 -27
  48. package/hooks/tezgah_replay.py +200 -30
  49. package/hooks/tezgah_research.py +1303 -14
  50. package/hooks/tezgah_stopspec.py +7 -0
  51. package/hooks/tezgah_store.py +74 -27
  52. package/hooks/tezgah_task.py +1201 -40
  53. package/hooks/tezgah_taste.py +8 -4
  54. package/hosts/codex/hook.py +32 -13
  55. package/hosts/cursor/hook.py +32 -14
  56. package/hosts/dsh/hooks.json +2 -2
  57. package/hosts/omp/hook.py +44 -23
  58. package/hosts/omp/tezgah-hook.ts.in +93 -7
  59. package/hosts/opencode/plugins/tezgah.js +273 -6
  60. package/output-styles/tezgah.md +76 -42
  61. package/package.json +1 -1
  62. package/skills/i-have-adhd/EVALS.md +22 -2
  63. package/skills/i-have-adhd/SKILL.md +12 -5
  64. package/skills/plan-add/SKILL.md +27 -11
  65. package/skills/plan-add/render_table.py +8 -3
  66. package/skills/plan-sync/SKILL.md +5 -3
  67. package/skills/ponytail/EVALS.md +21 -2
  68. package/skills/ponytail/SKILL.md +4 -4
  69. package/skills/research/SKILL.md +89 -1
  70. package/skills/tezgah-contract/EVALS.md +52 -7
  71. package/skills/tezgah-contract/SKILL.md +154 -59
@@ -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",
@@ -254,14 +255,15 @@ def root_for(path):
254
255
 
255
256
 
256
257
  def _toplevel(real):
257
- """The nearest directory at or above `real` holding a `.git` entry, else None."""
258
+ """The nearest directory at or above `real` holding a `.git` entry, else
259
+ None - the project for a path inside its `.tezgah` (`project_top`)."""
258
260
  cur = real
259
261
  while not os.path.lexists(os.path.join(cur, ".git")):
260
262
  parent = os.path.dirname(cur)
261
263
  if parent == cur:
262
264
  return None
263
265
  cur = parent
264
- return cur
266
+ return project_top(cur)
265
267
 
266
268
 
267
269
  def linked_main(path):
@@ -737,17 +739,99 @@ def codex_bin():
737
739
  return which_user(os.environ.get("TEZGAH_CODEX_BIN") or "codex")
738
740
 
739
741
 
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")
742
+ # The reply language (config.json `language`; an older config's `reply_lang`
743
+ # still reads and is never rewritten). The value is the switch: a code with a
744
+ # pack under hooks/lang/ asks for replies in that language, `any` for the
745
+ # user's own, and only a pack that ships reply `markers` (tr) is judged by the
746
+ # Stop language check. A config naming neither key reads as `tr`, the default
747
+ # every install made before the packs keeps; a first install writes the
748
+ # environment's language instead (`env_language`, bin/tezgah-setup).
749
+ LANG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lang")
750
+ _PACKS = []
744
751
 
745
752
 
746
- def reply_lang():
747
- """The configured reply language, `tr` when unset or not one of REPLY_LANGS."""
753
+ def languages():
754
+ """The values `language` takes: every shipped pack's code, then `any`."""
755
+ return tuple(packs()) + ("any",)
756
+
757
+
758
+ def language():
759
+ """The configured reply language, `tr` when unset or not one of `languages()`."""
760
+ cfg = config()
761
+ for key in ("language", "reply_lang"):
762
+ value = cfg.get(key) if isinstance(cfg, dict) else None
763
+ if value in languages():
764
+ return value
765
+ return "tr"
766
+
767
+
768
+ def spend_cap():
769
+ """The standing spend cap in USD, `spend_cap` in config.json: a paid run at
770
+ or under it is a process parameter the agent picks, one over it is asked
771
+ and waits (the ask policy's never-default class). 0 when unset, negative
772
+ or not a number, so every spend is asked."""
748
773
  cfg = config()
749
- value = cfg.get("reply_lang") if isinstance(cfg, dict) else None
750
- return value if value in REPLY_LANGS else "tr"
774
+ value = cfg.get("spend_cap") if isinstance(cfg, dict) else None
775
+ ok = isinstance(value, (int, float)) and not isinstance(value, bool)
776
+ return float(value) if ok and value > 0 else 0.0
777
+
778
+
779
+ def env_language(environ=None):
780
+ """The language a first install asks for: the first of LANGUAGE (its own
781
+ first `:` entry), LC_ALL, LC_MESSAGES and LANG that is set and not empty -
782
+ GNU gettext's order - cut to its language code (`tr_TR.UTF-8` -> `tr`), or
783
+ `en` when that code has no pack (`C`, `POSIX`, a language not shipped)."""
784
+ environ = os.environ if environ is None else environ
785
+ for key in ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"):
786
+ value = (environ.get(key) or "").split(":")[0]
787
+ if value:
788
+ code = value.split(".")[0].split("@")[0].split("_")[0].lower()
789
+ return code if code in packs() else "en"
790
+ return "en"
791
+
792
+
793
+ def packs():
794
+ """{code: pack} for every hooks/lang/<code>.json, read once per process.
795
+
796
+ A pack is data: one language's half of every detection class (S5's
797
+ inventory) and the contract's own words (`say`), keyed alike in every pack
798
+ (tests/test_lang_packs.py). The detectors compile the union of all packs,
799
+ so a reply or prompt in any shipped language arms the same classes whatever
800
+ `language` says; a language is added by one file, never a regex edit."""
801
+ if not _PACKS:
802
+ found = {}
803
+ for name in sorted(os.listdir(LANG_DIR)):
804
+ if name.endswith(".json"):
805
+ with open(os.path.join(LANG_DIR, name), encoding="utf-8") as fh:
806
+ found[name[:-5]] = json.load(fh)
807
+ _PACKS.append(found)
808
+ return _PACKS[0]
809
+
810
+
811
+ def pack_values(*path):
812
+ """Each pack's value at `path` (nested keys), in pack order, skipping a
813
+ pack that lacks it."""
814
+ for pack in packs().values():
815
+ node = pack
816
+ for key in path:
817
+ node = node.get(key) if isinstance(node, dict) else None
818
+ if node is not None:
819
+ yield node
820
+
821
+
822
+ def alts(*path):
823
+ """The fragments every pack lists at `path`, joined into one alternation;
824
+ `(?!)`, which never matches, when no pack lists one."""
825
+ return "|".join(f for frags in pack_values(*path) for f in frags) or "(?!)"
826
+
827
+
828
+ def say(code=None):
829
+ """The contract's words for `code` (the configured language by default):
830
+ the English pack's `say` map with the code's own pack laid over it, so a
831
+ language with no pack, or `any`, speaks the canonical English tokens."""
832
+ words = dict(packs().get("en", {}).get("say", {}))
833
+ words.update(packs().get(code or language(), {}).get("say", {}))
834
+ return words
751
835
 
752
836
 
753
837
  # Switch latching (plan 051, ADR 018). A hook process that knows its session
@@ -861,6 +945,16 @@ def pony_level():
861
945
  return value if value in PONY_LEVELS else "full"
862
946
 
863
947
 
948
+ # The router's input-side tokens (input plus cache reads and writes) on its
949
+ # newest step, above which the per-turn reminder asks it to push heavy reads
950
+ # through a slice (tezgah_context.context_nudge). Measured on 12,576 omp
951
+ # main-thread steps (orchestrator-flow-audit S2 F1): median 396 k, p90 724 k. The
952
+ # median is the normal working state and would fire on every other turn, so the
953
+ # line sits just under p90: it marks the heaviest tenth of steps and stays rare
954
+ # enough to be read.
955
+ CONTEXT_NUDGE_TOKENS = 700000
956
+
957
+
864
958
  def tool(name):
865
959
  """Stable installed path for a tezgah CLI, else the plugin's own copy, so
866
960
  the text injected into a session names something that actually exists."""
@@ -897,6 +991,26 @@ def workspace(repo):
897
991
  return os.path.join(repo, WORKSPACE)
898
992
 
899
993
 
994
+ def project_top(path):
995
+ """`path`, or the project owning the workspace when the nearest `.git` at or
996
+ above `path` is a `.tezgah`'s own repository.
997
+
998
+ git and the `.git` walk both answer the workspace for a path inside it, and a
999
+ caller that joins `.tezgah` to that answer or sets the project up there wrote
1000
+ `<x>/.tezgah/.tezgah/plans/README.md`, `.tezgah/.cursor` and `.tezgah/.codegraph`.
1001
+ A repository cloned inside the workspace answers for itself. Every resolver
1002
+ (`_toplevel`, the hooks' and CLIs' `repo_root`) passes its answer through here."""
1003
+ cur = path
1004
+ while not os.path.lexists(os.path.join(cur, ".git")):
1005
+ parent = os.path.dirname(cur)
1006
+ if parent == cur:
1007
+ return path
1008
+ cur = parent
1009
+ if os.path.basename(cur) == WORKSPACE:
1010
+ return project_top(os.path.dirname(cur))
1011
+ return path
1012
+
1013
+
900
1014
  # The workspace paths whose text is injected as a standing constraint: each is
901
1015
  # checked for a symlink, because a clone can carry one pointing into its own tree.
902
1016
  WORKSPACE_INJECTED = (".tezgah", os.path.join(".tezgah", "lessons.md"),
@@ -1252,15 +1366,32 @@ def seed_identity(repo, ws=None):
1252
1366
  pass
1253
1367
 
1254
1368
 
1369
+ # What git says when another git process holds the private repository's index
1370
+ # or a ref: every worktree commits into the main checkout's one `.tezgah/.git`,
1371
+ # so two closes or a close and a research commit collide (plan 097: 10 of 19
1372
+ # concurrent rounds).
1373
+ _GIT_LOCKED = (".lock': File exists", "cannot lock ref")
1374
+ # The waits between tries, ~1.5 s in all: a commit there takes milliseconds,
1375
+ # and a lock still held after it is a crashed git, which the caller reports.
1376
+ _LOCK_WAITS = (0.1, 0.2, 0.4, 0.8)
1377
+
1378
+
1255
1379
  def ws_git(repo, *args, **kw):
1256
1380
  """`git -C <repo>/.tezgah <args>` with tezgah's commit identity, as a
1257
- CompletedProcess (text, captured unless `kw` says otherwise). Raises OSError
1258
- when git is not installed, like subprocess.run."""
1381
+ CompletedProcess (text, captured unless `kw` says otherwise), tried again
1382
+ while another git holds a lock there (`_GIT_LOCKED`). Raises OSError when
1383
+ git is not installed, like subprocess.run."""
1259
1384
  import subprocess # deferred: see ensure_workspace
1385
+ import time
1260
1386
  kw.setdefault("capture_output", True)
1261
1387
  kw.setdefault("text", True)
1262
- return subprocess.run(["git", "-C", workspace(repo)] + list(WS_IDENTITY)
1263
- + list(args), **kw)
1388
+ cmd = ["git", "-C", workspace(repo)] + list(WS_IDENTITY) + list(args)
1389
+ for wait in _LOCK_WAITS + (None,):
1390
+ done = subprocess.run(cmd, **kw)
1391
+ err = done.stderr if isinstance(done.stderr, str) else ""
1392
+ if not done.returncode or wait is None or not any(s in err for s in _GIT_LOCKED):
1393
+ return done
1394
+ time.sleep(wait)
1264
1395
 
1265
1396
 
1266
1397
  def guarded_opener(drop_auth=False):