@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
@@ -112,7 +112,10 @@ def merged(lines, groups):
112
112
 
113
113
  def main(argv):
114
114
  ap = argparse.ArgumentParser(prog="tezgah-lessons", description=__doc__.split("\n")[0])
115
- ap.add_argument("--root", default=os.getcwd())
115
+ # a linked worktree's lessons are the main checkout's (plan 097)
116
+ from tezgah_paths import linked_main
117
+ here = tc.repo_root(os.getcwd())
118
+ ap.add_argument("--root", default=linked_main(here) or here)
116
119
  ap.add_argument("--fixture")
117
120
  args = ap.parse_args(argv)
118
121
  lines = tezgah_lessons.lines(args.root)
@@ -2,8 +2,10 @@
2
2
  """tezgah-research - the research workspace CLI.
3
3
 
4
4
  tezgah-research init <slug> --ask "<the user's words>" [--question "..."]
5
- [--method qualitative|measured] [--in "<covered>"]... [--out "<left alone>"]...
6
- [--allow-open "<why>"] [--supersedes <slug>] scaffold a line
5
+ [--method qualitative|measured|exploratory] [--in "<covered>"]...
6
+ [--out "<left alone>"]... [--allow-open "<why>"] [--supersedes <slug>]
7
+ [--arena "<problem>" --budget "<budget>" [--evaluator <path> --metric "<m>"]]
8
+ scaffold a line
7
9
  tezgah-research commit <slug> "<message>" commit the line in .tezgah's own repo
8
10
  tezgah-research check [<slug>] [--json] [--strict] [--orx] [--all-lines] [-- <slug>]
9
11
  the discipline checks
@@ -27,6 +29,14 @@
27
29
  bring lines from another checkout, with history
28
30
  tezgah-research next --topic <t> [--flow metric-dashboard|feature] [--json]
29
31
  the product-flow steps of a topic and the next one
32
+ tezgah-research idea <slug> record one idea of an exploratory line from stdin
33
+ tezgah-research stage <slug> <idea> <stage> [--evidence "<ref or JSON>"] [--reason "<why>"]
34
+ move an idea to its next stage
35
+ tezgah-research match <slug> record one judged tournament match from stdin
36
+ tezgah-research board <slug> [--json] each idea's stage, score and next action
37
+ tezgah-research dedup <slug> [--threshold <(0,1]>] [--json]
38
+ cluster the proposed ideas, write nothing
39
+ tezgah-research yield [--json] [--all-lines] how far ideas got, per operator and family
30
40
 
31
41
  A research line lives at <repo>/.tezgah/research/<slug>/ and holds the state an
32
42
  auditable investigation needs: the question, the decision log, the findings, the
@@ -95,10 +105,18 @@ without it is refused. `compare` appends one cell to
95
105
  `check` prints the same list of open lines after the per-line report, and so does
96
106
  `status`, so neither state has to be discovered by being refused.
97
107
 
108
+ `init --method exploratory` (rules 7) opens a campaign instead of a question:
109
+ `--arena` and `--budget` (and, for a measurable arena, `--evaluator` with its
110
+ `--metric`, whose sha256 `init` records) are the lock committed before the
111
+ first idea. `idea`, `stage` and `match` append the campaign's rows under the
112
+ lock, each judged by the one function `check` applies to it; a `prior-art`
113
+ stage whose verdict is `known` takes `--reason` and is killed in the same
114
+ append. `board`, `dedup` and `yield` are reports: they write nothing and exit 0.
115
+
98
116
  Exit codes: 0 clean, 1 problems found - a broken line for `check`, a refused
99
- claim for `claim`, a refused row for `predict`, which writes nothing and prints
100
- one reason per problem, or a new line refused for `init` over unfinished ones - 2
101
- misuse.
117
+ claim for `claim`, a refused row for `predict`, `idea`, `stage` or `match`, which
118
+ writes nothing and prints one reason per problem, or a new line refused for
119
+ `init` over unfinished ones - 2 misuse.
102
120
  """
103
121
  import json
104
122
  import os
@@ -113,20 +131,27 @@ import tezgah_product as tp_product # noqa: E402
113
131
  import tezgah_research as tr # noqa: E402
114
132
 
115
133
 
116
- def repo_root(path):
134
+ def checkout_root(path):
135
+ """The checkout `path` is in: git's top level, the project around it inside
136
+ `.tezgah`, else the directory itself."""
117
137
  try:
118
138
  out = subprocess.run(["git", "-C", path, "rev-parse", "--show-toplevel"],
119
139
  capture_output=True, text=True)
120
140
  if out.returncode == 0 and out.stdout.strip():
121
- top = out.stdout.strip()
122
- # inside `.tezgah` git answers with the private repository; the line
123
- # belongs to the project around it
124
- return os.path.dirname(top) if os.path.basename(top) == tp.WORKSPACE else top
141
+ return tp.project_top(out.stdout.strip())
125
142
  except OSError:
126
143
  pass
127
144
  return os.path.realpath(path)
128
145
 
129
146
 
147
+ def repo_root(path):
148
+ """The checkout whose `.tezgah` holds the lines: the main checkout, from a
149
+ linked worktree too, as for bin/tezgah-task - a line written into a
150
+ worktree's own `.tezgah` went with `git worktree remove` (plan 097)."""
151
+ top = checkout_root(path)
152
+ return tp.linked_main(top) or top
153
+
154
+
130
155
  def misuse(message):
131
156
  """Misuse exits 2, so a caller can tell it from a line that fails the checks."""
132
157
  print("tezgah-research: %s" % message, file=sys.stderr)
@@ -145,6 +170,7 @@ def cmd_init(args):
145
170
  "and dashes" % args[0])
146
171
  question, allow, supersedes = "", None, None
147
172
  ask, tier, method, scope_in, scope_out = "", str(tr.DEFAULT_TIER), "", [], []
173
+ arena = {}
148
174
  rest = args[1:]
149
175
  while rest:
150
176
  flag = rest.pop(0)
@@ -164,11 +190,34 @@ def cmd_init(args):
164
190
  allow = rest.pop(0)
165
191
  elif flag == "--supersedes" and rest:
166
192
  supersedes = rest.pop(0)
193
+ elif flag in ("--arena", "--budget", "--evaluator", "--metric") and rest \
194
+ and flag not in arena:
195
+ arena[flag] = rest.pop(0)
167
196
  else:
168
197
  return misuse('init <slug> --ask "<the user\'s words>" [--tier quick|study|program]'
169
- ' [--method qualitative|measured] [--in "<covered>"]...'
198
+ ' [--method qualitative|measured|exploratory] [--in "<covered>"]...'
170
199
  ' [--out "<left alone>"]... [--question "..."]'
171
- ' [--allow-open "<reason>"] [--supersedes <slug>]')
200
+ ' [--allow-open "<reason>"] [--supersedes <slug>]'
201
+ ' [--arena "<problem>" --budget "<budget>"'
202
+ ' [--evaluator <path> --metric "<metric>"]]')
203
+ if method == "exploratory":
204
+ # The arena is the lock an exploratory line commits before its first idea,
205
+ # so a line without one is a call that cannot work, not a line to repair.
206
+ if not all(arena.get(f, "").strip() for f in ("--arena", "--budget")):
207
+ return misuse('--method exploratory needs --arena "<the problem, verbatim>" '
208
+ 'and --budget "<ideas, pilots, full runs, wall time>"')
209
+ if bool(arena.get("--evaluator", "").strip()) != bool(
210
+ arena.get("--metric", "").strip()):
211
+ return misuse("--evaluator and --metric go together: the file that scores a "
212
+ "candidate and the number it returns")
213
+ evaluator = arena.get("--evaluator", "").strip()
214
+ if evaluator and (os.path.isabs(evaluator) or ".." in evaluator.split("/")
215
+ or not os.path.isfile(os.path.join(repo_root(os.getcwd()),
216
+ evaluator))):
217
+ return misuse("--evaluator %r is not a file under the repository" % evaluator)
218
+ elif arena:
219
+ return misuse("--arena, --budget, --evaluator and --metric belong to "
220
+ "--method exploratory")
172
221
  problems = tr.ask_problems(ask, tier)
173
222
  if problems:
174
223
  for problem in problems:
@@ -211,9 +260,14 @@ def cmd_init(args):
211
260
  "init %s --supersedes %s" % (", ".join(twins), slug, twins[0]))
212
261
  return 1
213
262
  created = time.strftime("%Y-%m-%d")
263
+ record = None
264
+ if method == "exploratory":
265
+ record = tr.arena_record(repo, arena["--arena"], arena["--budget"],
266
+ arena.get("--evaluator", "").strip(),
267
+ arena.get("--metric", "").strip())
214
268
  made = tr.init(repo, slug, question=question, created=created,
215
269
  supersedes=supersedes, ask=ask, tier=tier, method=method,
216
- scope_in=scope_in, scope_out=scope_out)
270
+ scope_in=scope_in, scope_out=scope_out, arena=record)
217
271
  if allow is not None:
218
272
  problem = tr.note_open(repo, slug, allow, created)
219
273
  if problem:
@@ -224,7 +278,14 @@ def cmd_init(args):
224
278
  print(" wrote %s" % path)
225
279
  for problem in tr.scope_problems(tr.line_state(repo, slug)):
226
280
  print(" owed: %s" % problem)
227
- if method == "qualitative":
281
+ if method == "exploratory":
282
+ print("\nnext: commit the arena now, before any idea - `tezgah-research commit "
283
+ "%s \"arena\"`; an idea committed with it or before it is refused. Then "
284
+ "record ideas (`tezgah-research idea %s`, one JSON row on stdin, each "
285
+ "naming its operator, generator and kill result), commit them, and "
286
+ "follow `tezgah-research board %s`: dedup, prior art, matches, pilots, a "
287
+ "full run, a confirmation on unseen data." % (slug, slug, slug))
288
+ elif method == "qualitative":
228
289
  print("\nnext: read the primary sources for each state.json scope.in item "
229
290
  "(`orx discover`, `orx paper`), save each to literature/ as you read "
230
291
  "it, and record every claim with a `trace` naming the S<n> it answers "
@@ -301,7 +362,7 @@ def cmd_check(args):
301
362
  report = {}
302
363
  if known:
303
364
  report = tr.check(repo, rest[0] if rest else None, strict=strict,
304
- all_lines=all_lines)
365
+ all_lines=all_lines, code=checkout_root(os.getcwd()))
305
366
  for slug, row in report.items():
306
367
  for err in row["errors"]:
307
368
  problems += 1
@@ -589,6 +650,172 @@ def cmd_compare(args):
589
650
  return 0
590
651
 
591
652
 
653
+ def _campaign_slug(args, usage, count=1):
654
+ """(repo, slug, None) for a campaign command's line, or (None, None, exit code)."""
655
+ if len(args) < count:
656
+ return None, None, misuse(usage)
657
+ repo = repo_root(os.getcwd())
658
+ known = tr.slugs(repo)
659
+ if args[0] not in known:
660
+ return None, None, misuse("no such research line (have: %s)"
661
+ % (", ".join(known) or "none"))
662
+ return repo, args[0], None
663
+
664
+
665
+ def _stdin_row(what):
666
+ """(row, None) for the one JSON object on stdin, or (None, exit code)."""
667
+ try:
668
+ body = sys.stdin.buffer.read().decode("utf-8")
669
+ row = json.loads(body)
670
+ except (UnicodeDecodeError, ValueError):
671
+ return None, misuse("%s needs one JSON object on stdin" % what)
672
+ if not isinstance(row, dict):
673
+ return None, misuse("%s needs one JSON object on stdin" % what)
674
+ return row, None
675
+
676
+
677
+ def _refused(slug, problems):
678
+ for problem in problems:
679
+ print("FAIL %s: %s" % (slug, problem))
680
+ return 1 if problems else 0
681
+
682
+
683
+ def cmd_idea(args):
684
+ """Record one idea of an exploratory line from stdin (`append_idea`)."""
685
+ repo, slug, code = _campaign_slug(args, "idea <slug>")
686
+ if code is not None or len(args) != 1:
687
+ return code if code is not None else misuse("idea <slug>")
688
+ row, code = _stdin_row("idea")
689
+ if code is not None:
690
+ return code
691
+ try:
692
+ iid, problems = tr.append_idea(repo, slug, row)
693
+ except OSError as exc:
694
+ iid, problems = row.get("id"), ["cannot append to ideas.jsonl: %s" % exc]
695
+ if _refused(slug, problems):
696
+ return 1
697
+ print("idea %s recorded" % iid)
698
+ return 0
699
+
700
+
701
+ def cmd_stage(args):
702
+ """Move one idea to a stage, with the evidence that stage takes (a path or a
703
+ JSON object) and the reason a kill - or a known prior art - owes."""
704
+ usage = ('stage <slug> <idea> <stage> [--evidence "<ref or JSON>"] '
705
+ '[--reason "<why>"]')
706
+ repo, slug, code = _campaign_slug(args, usage, 3)
707
+ if code is not None:
708
+ return code
709
+ row = {"idea": args[1], "stage": args[2]}
710
+ reason, rest = None, args[3:]
711
+ while rest:
712
+ flag = rest.pop(0)
713
+ if flag == "--evidence" and rest and "evidence" not in row:
714
+ text = rest.pop(0)
715
+ try:
716
+ row["evidence"] = json.loads(text) if text.lstrip()[:1] in "{[" \
717
+ and text.strip() else text
718
+ except ValueError:
719
+ return misuse("--evidence starts like JSON and does not parse")
720
+ elif flag == "--reason" and rest and reason is None:
721
+ reason = rest.pop(0)
722
+ else:
723
+ return misuse(usage)
724
+ row["date"] = time.strftime("%Y-%m-%d")
725
+ kill_reason = None
726
+ if row["stage"] == "killed":
727
+ row["reason"] = reason
728
+ else:
729
+ kill_reason = reason
730
+ try:
731
+ problems = tr.append_stage(repo, slug, {k: v for k, v in row.items()
732
+ if v is not None}, kill_reason)
733
+ except OSError as exc:
734
+ problems = ["cannot append to stages.jsonl: %s" % exc]
735
+ if _refused(slug, problems):
736
+ return 1
737
+ killed = row["stage"] == "prior-art" and kill_reason \
738
+ and isinstance(row.get("evidence"), dict) \
739
+ and row["evidence"].get("verdict") == "known"
740
+ print("%s: %s recorded%s" % (row["idea"], row["stage"],
741
+ " and killed (%s)" % kill_reason if killed else ""))
742
+ return 0
743
+
744
+
745
+ def cmd_match(args):
746
+ """Record one judged tournament match from stdin (`append_match`)."""
747
+ repo, slug, code = _campaign_slug(args, "match <slug>")
748
+ if code is not None or len(args) != 1:
749
+ return code if code is not None else misuse("match <slug>")
750
+ row, code = _stdin_row("match")
751
+ if code is not None:
752
+ return code
753
+ try:
754
+ problems = tr.append_match(repo, slug, row)
755
+ except OSError as exc:
756
+ problems = ["cannot append to tournament.jsonl: %s" % exc]
757
+ if _refused(slug, problems):
758
+ return 1
759
+ print("match %s v %s recorded" % (row.get("a"), row.get("b")))
760
+ return 0
761
+
762
+
763
+ def cmd_board(args):
764
+ """The campaign's board: a report a fresh session resumes from, exit 0."""
765
+ flags = [a for a in args if a != "--json"]
766
+ repo, slug, code = _campaign_slug(flags, "board <slug> [--json]")
767
+ if code is not None or len(flags) != 1:
768
+ return code if code is not None else misuse("board <slug> [--json]")
769
+ report = tr.board(repo, slug)
770
+ if "--json" in args:
771
+ print(json.dumps(report, indent=2))
772
+ return 0
773
+ for line in tr.board_text(report):
774
+ print(line)
775
+ return 0
776
+
777
+
778
+ def cmd_dedup(args):
779
+ """Cluster the proposed ideas by statement; writes nothing, exit 0."""
780
+ usage = "dedup <slug> [--threshold <(0,1]>] [--json]"
781
+ rest, threshold, as_json = list(args), None, False
782
+ if "--json" in rest:
783
+ rest.remove("--json")
784
+ as_json = True
785
+ if "--threshold" in rest:
786
+ i = rest.index("--threshold")
787
+ try:
788
+ threshold = float(rest[i + 1])
789
+ except (IndexError, ValueError):
790
+ return misuse(usage)
791
+ if not 0 < threshold <= 1:
792
+ return misuse("--threshold is a similarity in (0, 1]")
793
+ rest = rest[:i] + rest[i + 2:]
794
+ repo, slug, code = _campaign_slug(rest, usage)
795
+ if code is not None or len(rest) != 1:
796
+ return code if code is not None else misuse(usage)
797
+ report = tr.dedup(repo, slug, threshold)
798
+ if as_json:
799
+ print(json.dumps(report, indent=2))
800
+ return 0
801
+ for line in tr.dedup_text(report, slug):
802
+ print(line)
803
+ return 0
804
+
805
+
806
+ def cmd_yield(args):
807
+ """Per operator and generator family, how far the ideas got; exit 0."""
808
+ if [a for a in args if a not in ("--json", "--all-lines")]:
809
+ return misuse("yield [--json] [--all-lines]")
810
+ report = tr.yield_report(repo_root(os.getcwd()), all_lines="--all-lines" in args)
811
+ if "--json" in args:
812
+ print(json.dumps(report, indent=2))
813
+ return 0
814
+ for line in tr.yield_text(report):
815
+ print(line)
816
+ return 0
817
+
818
+
592
819
  def cmd_next(args):
593
820
  """The product-flow steps of one topic with their status, then the first
594
821
  missing or due step's skill and command; a report, so exit 0."""
@@ -784,7 +1011,8 @@ def cmd_import(args):
784
1011
  def cmd_all(args):
785
1012
  if args:
786
1013
  return misuse("--all takes no arguments")
787
- for row in tr.across(repo_root(os.getcwd())):
1014
+ # every checkout's own `.tezgah`, marking the one this runs in
1015
+ for row in tr.across(checkout_root(os.getcwd())):
788
1016
  print(row)
789
1017
  return 0
790
1018
 
@@ -830,9 +1058,13 @@ def main(argv):
830
1058
  return cmd_seal(rest)
831
1059
  if cmd == "import":
832
1060
  return cmd_import(rest)
1061
+ campaign = {"idea": cmd_idea, "stage": cmd_stage, "match": cmd_match,
1062
+ "board": cmd_board, "dedup": cmd_dedup, "yield": cmd_yield}
1063
+ if cmd in campaign:
1064
+ return campaign[cmd](rest)
833
1065
  return misuse("unknown command %r (init, commit, check, status, --all, claim, predict, "
834
1066
  "components, migrate, source, compare, verdict, conclude, migrate-layout, "
835
- "close, seal, import, next)" % cmd)
1067
+ "close, seal, import, next, idea, stage, match, board, dedup, yield)" % cmd)
836
1068
 
837
1069
 
838
1070
  if __name__ == "__main__":
package/bin/tezgah-route CHANGED
@@ -6,6 +6,11 @@
6
6
  tezgah-route --refresh re-read prices/ids (OpenRouter),
7
7
  resolve opencode selectors
8
8
  tezgah-route --mode auto|anthropic|any omp's family, then re-apply omp
9
+ tezgah-route --health probe the other funded families;
10
+ one at a limit leaves the chain
11
+ until its reset; re-apply omp
12
+ tezgah-route --fallback vendor|any|none what a chain falls back to, then
13
+ re-apply omp
9
14
  tezgah-route --report each route's first check, by tier
10
15
 
11
16
  A brief naming stored data, a migration, credentials, the gate or security goes to
@@ -21,7 +26,10 @@ model through TypeSafe, OpenRouter System One, Cloudflare Workers AI or a
21
26
  compatible endpoint (TEZGAH_JEV_PROVIDER picks one), else the OpenRouter chat
22
27
  fallback - so write it in English, the language the judge reads best.
23
28
 
24
- Every routed call writes one `route` row to the session's ledger (TEZGAH_SESSION):
29
+ Every routed call writes one `route` row to the session's ledger, named by
30
+ TEZGAH_SESSION (omp, opencode, Claude), CODEX_THREAD_ID (Codex) or
31
+ CURSOR_CONVERSATION_ID (Cursor); when two of them name different sessions (a host
32
+ started from another host's shell) it writes none. The row holds the
25
33
  tier, agent, what set the tier (`via`: rule|jev|phase|default), the static
26
34
  table's tier when --phase was given, the worker's model, and `override` (user: an
27
35
  omp entry the user owns; mode: no entry, so the session model). `--report` joins
@@ -31,28 +39,33 @@ order only where no spawn could be swapped - and counts a route it cannot prove
31
39
  as `unjoined`. That outcome is what the V2-as-default
32
40
  decision waits for (docs/models.md).
33
41
 
34
- A worker that answers `ESCALATE:` is restarted on tezgah-frontier with the same
35
- brief; its trajectory is not continued. Exit codes: 0 routed / current, 1 the
42
+ A report missing an output field, or an `ESCALATE:`, gets one narrower re-brief
43
+ of the same role naming that field or judgement; only a second miss is restarted
44
+ on tezgah-frontier with the original brief, never continued. Exit codes: 0 routed / current, 1 the
36
45
  table is stale or flagged (`--check`) or the refresh failed, 2 misuse.
37
46
  """
38
47
  import argparse
39
48
  import json
40
49
  import os
41
50
  import sys
51
+ import time
42
52
 
43
53
  HERE = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
44
54
  sys.path.insert(0, os.path.join(HERE, "hooks"))
45
55
  import tezgah_integrity as ti # noqa: E402
46
56
  import tezgah_judge # noqa: E402
47
57
  import tezgah_models as tm # noqa: E402
58
+ import tezgah_paths as tp # noqa: E402
48
59
 
49
60
 
50
61
  def main(argv):
51
62
  parser = argparse.ArgumentParser(
52
63
  prog="tezgah-route",
53
64
  description="Name the tier worker a delegated task goes to, and keep the model table.",
54
- epilog="A worker that answers `ESCALATE:` is restarted on tezgah-frontier with "
55
- "the same brief; its trajectory is not continued.")
65
+ epilog="A report missing an output field, or an `ESCALATE:`, gets one "
66
+ "narrower re-brief of the same role naming that field or judgement; "
67
+ "only a second miss is restarted on tezgah-frontier with the original "
68
+ "brief, never continued.")
56
69
  parser.add_argument("brief", nargs="*", help="the delegation brief, in English")
57
70
  parser.add_argument("--phase", choices=sorted(tm.PHASE_TIER),
58
71
  help="the static table's row when no judgement is available")
@@ -62,6 +75,12 @@ def main(argv):
62
75
  parser.add_argument("--refresh", action="store_true",
63
76
  help="re-read prices and ids, resolve opencode selectors")
64
77
  parser.add_argument("--mode", choices=tm.MODES, help="omp's family, then re-apply")
78
+ parser.add_argument("--health", action="store_true",
79
+ help="probe the other funded families, record the ones at a "
80
+ "limit until their reset, then re-apply omp's chain")
81
+ parser.add_argument("--fallback", choices=tp.FALLBACKS,
82
+ help="which families a chain falls back to (config.json "
83
+ "`fallback`), then re-apply")
65
84
  parser.add_argument("--report", action="store_true",
66
85
  help="every ledger's routes joined to the first check after each")
67
86
  args = parser.parse_args(argv[1:])
@@ -89,6 +108,40 @@ def main(argv):
89
108
  print(line("%s %s" % (via, tier), counts))
90
109
  print(line("jev-below-static", below))
91
110
  return 0
111
+ if args.fallback:
112
+ # config.json `fallback` has one other writer: none. A config that does not
113
+ # parse is refused, not replaced (like tezgah-setup --jev).
114
+ try:
115
+ with open(tp.CONFIG, encoding="utf-8") as fh:
116
+ cfg = json.load(fh)
117
+ except FileNotFoundError:
118
+ cfg = {}
119
+ except (OSError, ValueError):
120
+ print("%s is not valid JSON; fallback not set" % tp.CONFIG, file=sys.stderr)
121
+ return 1
122
+ if cfg.get("fallback") != args.fallback:
123
+ cfg["fallback"] = args.fallback
124
+ os.makedirs(os.path.dirname(tp.CONFIG), exist_ok=True)
125
+ tmp = tp.CONFIG + ".tmp"
126
+ with open(tmp, "w", encoding="utf-8") as fh:
127
+ json.dump(cfg, fh, indent=2, sort_keys=True)
128
+ fh.write("\n")
129
+ os.replace(tmp, tp.CONFIG)
130
+ print("fallback: %s (%s)" % (args.fallback, tp.CONFIG))
131
+ print(tm.apply_omp() or "omp not answering; the chain applies at the next setup")
132
+ return 0
133
+ if args.health:
134
+ mode = tm.omp_mode()
135
+ data = tm.refresh_health(mode)
136
+ for family in tm.funded_families(live=False):
137
+ row = data["down"].get(family)
138
+ state = ("the mode's own family, not probed" if family == mode else
139
+ "down until %s - %s" % (time.strftime("%Y-%m-%d %H:%M",
140
+ time.localtime(row["until"])),
141
+ row.get("reason", "")) if row else "up")
142
+ print("%s: %s" % (family, state))
143
+ print(tm.apply_omp() or "omp not answering; the chain applies at the next setup")
144
+ return 0
92
145
  if args.mode:
93
146
  data = tm.overlay()
94
147
  data.pop("mode", None)
@@ -102,11 +155,21 @@ def main(argv):
102
155
  return 0
103
156
  if not brief:
104
157
  print("usage: tezgah-route \"<brief>\" [--phase P] [--json] | --check | --refresh "
105
- "| --mode M | --report", file=sys.stderr)
158
+ "| --mode M | --health | --fallback F | --report", file=sys.stderr)
106
159
  return 2
107
160
  ask = tezgah_judge.ask if tezgah_judge.available() else None
108
161
  out = tm.route(brief, args.phase, ask)
109
162
  judged = out.pop("judged")
163
+ # The session the row goes under: omp, opencode and Claude (CLAUDE_ENV_FILE)
164
+ # export TEZGAH_SESSION, a Codex shell carries CODEX_THREAD_ID (the root
165
+ # thread its hooks' session_id names), a Cursor agent shell
166
+ # CURSOR_CONVERSATION_ID (its hooks' conversation_id). A host started from
167
+ # another host's agent shell inherits that host's id and markers alike, so
168
+ # two different ids name no host: no row, and the route is never written
169
+ # into another host's ledger.
170
+ ids = {os.environ.get(v) for v in ("TEZGAH_SESSION", "CODEX_THREAD_ID",
171
+ "CURSOR_CONVERSATION_ID")} - {None, ""}
172
+ session = ids.pop() if len(ids) == 1 else None
110
173
  if judged:
111
174
  usage = judged.get("usage")
112
175
  usage = usage if isinstance(usage, dict) else {}
@@ -114,8 +177,7 @@ def main(argv):
114
177
  judged.get("model", tezgah_judge.MODEL), out["tier"],
115
178
  usage.get("input_tokens"), usage.get("output_tokens"),
116
179
  judged.get("latency_ms"))
117
- ti.note(os.environ.get("TEZGAH_SESSION"), "judge", detail)
118
- session = os.environ.get("TEZGAH_SESSION")
180
+ ti.note(session, "judge", detail)
119
181
  if session: # the omp read in worker_model costs a process; skip it with no ledger
120
182
  ti.note(session, "route", tm.route_detail(out, args.phase,
121
183
  *tm.worker_model(out["agent"])))
package/bin/tezgah-setup CHANGED
@@ -233,8 +233,9 @@ MATERIALISED = os.path.join(tp.CONFIG_DIR, "materialised")
233
233
  # name too - a switch left behind is a rule that stays silent with no file saying why.
234
234
  OFF_SWITCH_NAMES = ("adhd-off", "agents-off", "consult-off", "docs-judge-off", "exec-mode.off",
235
235
  "judge-off", "lang-off", "orchestrate-off", "ponytail-auto.off",
236
- "pretooluse-off", "reminder-off", "research-off", "spec-off", "task-off",
237
- "triage-off", "update-check-off", "verify-off", "workspace-off")
236
+ "pretooluse-off", "reminder-off", "research-off", "slices-off",
237
+ "spec-off", "task-off", "triage-off", "update-check-off", "verify-off",
238
+ "workspace-off")
238
239
  # The ~/.config/tezgah entries this installer generates. A full uninstall
239
240
  # removes exactly these (plus any `*.off` switch and the `.tezgah-bak` of one of
240
241
  # these names) and keeps anything else: a file the user put there is not
@@ -314,9 +315,11 @@ CURSOR_HOOK_EVENTS = {
314
315
  # makes a config written before this existed behave exactly as it used to.
315
316
  # --enable/--disable/--devtools and the wizard set it before anything is written.
316
317
  FEATURES = None
317
- # The `reply_lang` this run stores (--reply-lang); None keeps what config.json
318
- # says, and a config without the key reads as `tr` (tezgah_paths.reply_lang).
319
- REPLY_LANG = None
318
+ # The `language` this run stores (--language, or its older spelling
319
+ # --reply-lang); None keeps what config.json says. A first install with no flag
320
+ # takes the environment's (`tp.env_language`); a config made before the key
321
+ # existed is pinned to `tr`, the default it ran under (tezgah_paths.language).
322
+ LANGUAGE = None
320
323
  OK, NO = " ok ", " MISS "
321
324
 
322
325
 
@@ -1028,6 +1031,10 @@ def install_common(new_roots, hosts):
1028
1031
  cfg = read_config(tp.CONFIG, {})
1029
1032
  own = cfg is not None
1030
1033
  cfg = cfg if own else {}
1034
+ # a first install is a config no install wrote yet: `--jev` alone writes
1035
+ # the file too, so its existence says nothing; an install always writes
1036
+ # `roots` and `prefix`
1037
+ first = not any(key in cfg for key in ("roots", "prefix"))
1031
1038
  cfg["roots"] = [expand(r) for r in new_roots] if new_roots else \
1032
1039
  (tp.roots() or [tp.DEFAULT_ROOT])
1033
1040
  if tp.codegraph_bin():
@@ -1044,8 +1051,13 @@ def install_common(new_roots, hosts):
1044
1051
  # anywhere, so it moves the tree the install actually used instead of the
1045
1052
  # XDG default. An explicit --prefix or TEZGAH_PREFIX still wins over it.
1046
1053
  cfg["prefix"] = INSTALL_PREFIX
1047
- if REPLY_LANG:
1048
- cfg["reply_lang"] = REPLY_LANG
1054
+ if LANGUAGE:
1055
+ cfg["language"] = LANGUAGE
1056
+ elif not any(key in cfg for key in ("language", "reply_lang")):
1057
+ # an existing `reply_lang` is never rewritten; only a config with
1058
+ # neither key gets one: the locale's on a first install, `tr` on an
1059
+ # older one, so an upgrade changes no one's reply language
1060
+ cfg["language"] = tp.env_language() if first else "tr"
1049
1061
  if own:
1050
1062
  write_json(tp.CONFIG, cfg)
1051
1063
  print("%s %s roots=%s" % (OK, tp.CONFIG, cfg["roots"]))
@@ -5520,7 +5532,7 @@ def upgrade(version, dry_run):
5520
5532
  so its farm links would point back into it. The hosts and roots come from
5521
5533
  config.json, so an upgrade does not re-ask the install questions. Nothing
5522
5534
  else calls this: a flag never upgrades tezgah as a side effect of another
5523
- (hooks/tezgah_policy.py:792). Prints every step before running it, and
5535
+ (hooks/tezgah_policy.py:812). Prints every step before running it, and
5524
5536
  `--dry-run` stops after the printing."""
5525
5537
  cfg = tp.config()
5526
5538
  hosts = cfg.get("hosts") or detected_hosts()
@@ -5713,10 +5725,11 @@ def main(argv):
5713
5725
  version=tezgah_context.version() or "unknown")
5714
5726
  ap.add_argument("--roots")
5715
5727
  ap.add_argument("--hosts")
5716
- ap.add_argument("--reply-lang", choices=tp.REPLY_LANGS,
5728
+ ap.add_argument("--language", "--reply-lang", choices=tp.languages(),
5717
5729
  help="with --install: the language replies are asked for and "
5718
- "the Stop rule checks (config.json `reply_lang`; `tr` "
5719
- "when unset, `any` checks none)")
5730
+ "the Stop rule checks (config.json `language`; a first "
5731
+ "install takes LANGUAGE/LC_ALL/LC_MESSAGES/LANG, `en` "
5732
+ "when none names a pack; `any` checks none)")
5720
5733
  ap.add_argument("--jev", type=tp.jev_name, choices=tp.JEV_CHOICES,
5721
5734
  help="the carrier the judgement seam reaches the Jev model "
5722
5735
  "through (config.json `jev`; `auto` tries every one "
@@ -5736,9 +5749,9 @@ def main(argv):
5736
5749
  # so it is taken off before the flags are parsed (`--dry-run` still reads)
5737
5750
  command = argv[1:2] == ["update"]
5738
5751
  args = ap.parse_args(argv[2:] if command else argv[1:])
5739
- global FEATURES, INSTALL_PREFIX, HOST_DEPS, REPLY_LANG
5752
+ global FEATURES, INSTALL_PREFIX, HOST_DEPS, LANGUAGE
5740
5753
  HOST_DEPS = args.host_deps
5741
- REPLY_LANG = args.reply_lang
5754
+ LANGUAGE = args.language
5742
5755
  select_code = 0
5743
5756
  # --devtools is --enable <id> and nothing else: one code path, so the flag
5744
5757
  # cannot reach the selection in a way the explicit command does not.