@r1z4x/tezgah 1.8.3 → 1.9.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,39 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.9.0] - 2026-10-17
10
+
11
+ ### Added
12
+
13
+ - **The opencode plugin loads on opencode v2.** v2 validates a plugin's default export: an object with an `id` and a `setup(ctx)`. It rejects a module that only exports a callable, which is why opencode v2 refused both tezgah entrypoints at load. The module now carries both shapes, and opencode 1 keeps the callable it uses. The v2 `setup` registers the tool envelopes, the shell env hook and the event subscription. It passes the v2 event as the v1 `input`. It rebuilds the `args` field the v1 callback reads, so a refusal stays the same thrown error. The session, permission, compaction and TUI hooks follow once a probe measures their v2 event.
14
+ - **`tezgah-optimize` decides what a change costs to check.** `next` reads the commands the active plan's Acceptance names and answers `run now`, `defer` or `already green`. The planner spends a green row for the same change on a clean tree. A command whose recorded median is at or above 105 s defers to the merge step. Everything else runs now. `record` appends the row `next` reads. `push` prints the one push a change gets, and a second push of the same sha inside 300 s refuses. The rows live in the repository's own store. Measurement sets the tiers rather than a declaration, so a project that never adopted `AGENTS.md` still gets a batch. See `docs/testing.md`.
15
+
16
+ ### Changed
17
+
18
+ - **`tezgah-task sweep` clears the last session's leftovers.** It lists every pending slice with its `slice resume` command. It removes landed worktrees and branches but keeps the checkout it runs in. It clears a missing branch's `phase:` only when git answers and a checkpoint, landing or reviewed commit proves the phase ran in a checkout. A declared, never-created branch stays active. A second run reports `0 change(s)`. Pending slices stay open for the session to resume.
19
+ - **An idle slice stays owed without inventing a failure.** `pending` used to close an open record as `stale` after six hours. A slice with no observed end now closes `stopped` with an empty failure class and keeps its resume brief. Unknown endings no longer fabricate `cut_off` signatures. Signature counts stay quiet for hosts whose endings tezgah cannot observe. Only a `job` record closes `stale`. The parent transcript already holds its result.
20
+ - **A linked worktree's `.tezgah` link no longer reads as untracked.** `.gitignore` carried `/.tezgah/`, and that pattern matches a directory only. Git therefore never ignored the symlink a worktree shows. The tree read dirty forever. Its implementation phase began on a `checkpoint: pending`. Only a commit adding the link to the repository could clear that. The pattern is `/.tezgah` now.
21
+ - **A phase move reports every precondition at once.** `tezgah-task phase` stopped at the first group it met. The groups are the scope, the Acceptance items that name no command, and an unanswered spike. A plan that owed more than one learned them one refused attempt at a time. The command now prints all of them in one run (`bin/tezgah-task::cmd_phase`).
22
+ - **`close` records why a worktree stayed.** It printed the reason once, and the print went with the session. The next session re-derived the same decision. The kept worktrees and kept branches now go on the record it closes, as `worktrees_kept:`.
23
+ - **`tezgah-task unmet` records an item the owner accepted.** `close NNN done` refuses an unticked Acceptance item that carries no `UNMET:`. Nothing could write that marker: the gate refuses a write to the record itself. So a hand edit was the only way, and the plan could not close. The verb is the record's fifth body writer beside `tick`, `note`, `spike` and `sweep`. It commits nothing.
24
+ - **The temporal spec decides the Stop rule's evidence classes.** Its shadow run agreed on all 420 live Stops it reached. `_stop_block` now asks `tezgah_stopspec`, and the refusal texts stay the same. The `stop_spec` shadow row and `TEZGAH_STOPSPEC_STRICT` are gone. `tests/stop_spec_eval.py` keeps only `--mutants`, and the neuter workflow also runs on a change to the spec.
25
+ - **A claim needs a check someone saw pass.** A done or tested claim over a `verify_ok` row with no passing output now owes `no verify_ok`. An earlier pass and a turn of git bookkeeping no longer excuse it.
26
+ - **A research, analysis, review or plan ask ends at the plan.** The contract now says so. Building that plan waits for the user's answer, and a finding outside the ask becomes a plan, not code.
27
+ - **No plan, no third product file, on any branch.** The no-plan rule held only `main` and `master`, and counted only tezgah's own folders. It now holds every branch that no open plan binds, and counts any path in the checkout. Only `.tezgah/`, `.codegraph/`, `.git/` and the hosts' generated `agents/` sets stay out. A `plan/NNN-*` branch binds only an open plan NNN. A worktree on the branch an open plan names in `branch:` answers to that plan, so Orca's `r1z4x/<name>` worktrees bind too. `tezgah-task start NNN --phase P --branch <name>` writes that binding, and the refusal names it. A merge or rebase in progress passes.
28
+ - **`tezgah-route --mode` switches every routed agent at once.** It writes the table over omp selectors set by hand. That covers the 12 routed agents and the plan and slow roles, and it names each one it replaced. Before, a hand-set selector kept every subagent on another family after a switch. The automatic re-apply at session start still leaves hand-set selectors alone.
29
+ - **`tezgah-route --mode openai`.** It runs every routed omp agent on `openai-codex/gpt-6-luna`, with effort low, medium or high by tier. That is omp's ChatGPT provider. It is no fallback for the other modes, and with no `openai-codex` login it writes nothing and says why.
30
+
31
+ ### Fixed
32
+
33
+ - **The closed-plan census uses the same Acceptance predicate as `close`.** Only `status: done` records count, and each unticked Acceptance item needs its own `UNMET: <reason>`. A marker on another item, a box outside Acceptance or a legitimately discarded record no longer distorts the result.
34
+ - **A bare repository's worktrees share one workspace.** It lives in the bare git directory, `<bare>/.tezgah`, and each worktree's `.tezgah` links to it. No `git worktree remove`, detach or branch switch removes it, and a plan worktree there is held to its plan. Commands read project files in the worktree they run in. That covers the checkpoint, research files and claims, the arena evaluator, the merge checks and `tezgah-taste mine`. A bare repository with one worktree prints no worktrees line.
35
+ - **`close` prunes a worktree whose `.tezgah` is a link.** It read the link to the workspace as the worktree's own data and kept the worktree. The merge step's deferred checks are now looked up in the plan's own worktree when one holds the plan.
36
+ - **A plan worktree moves only its own plan's record.** In a `plan/NNN-*` worktree whose plan is not active, `allow`, `phase` and `stop` refuse. They used to change the main checkout's first phased plan.
37
+ - **`close` prunes a worktree of generated files.** Managed agent files and the `.codegraph` index regenerate, so they keep no landed worktree. Any other ignored file still keeps it. So do a FIFO, a directory link and a directory `close` cannot list.
38
+ - **A Ctrl-C as an installer starts still stops it.** `--deps` armed its group kill only after the installer had started. A Ctrl-C in that gap left the installer running on its own, and a test timed out on it under load.
39
+ - **`close` drops the branch of a worktree Orca removed.** Orca answers before the checkout is gone. Git then refused to delete the branch, and close printed `branch kept`. Close now waits up to 10 seconds for the directory to go, and names a checkout still there as kept.
40
+ - **The no-plan rule reads a `~/` path as a home path.** It joined `~/...` onto the session's directory. A plan worktree's files written by `~/` paths then counted as `main`'s, so the gate refused the third.
41
+
9
42
  ## [1.8.3] - 2026-10-13
10
43
 
11
44
  ### Changed
@@ -1173,7 +1206,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1173
1206
  the copy could not find `hooks/` and died on import. It now links into the
1174
1207
  installed tree, and also installs the command as `tezgah`.
1175
1208
 
1176
- [Unreleased]: https://github.com/r1z4x/tezgah/compare/v1.8.3...HEAD
1209
+ [Unreleased]: https://github.com/r1z4x/tezgah/compare/v1.9.0...HEAD
1210
+ [1.9.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.9.0
1177
1211
  [1.8.3]: https://github.com/r1z4x/tezgah/releases/tag/v1.8.3
1178
1212
  [1.8.2]: https://github.com/r1z4x/tezgah/releases/tag/v1.8.2
1179
1213
  [1.8.1]: https://github.com/r1z4x/tezgah/releases/tag/v1.8.1
package/MANIFEST CHANGED
@@ -64,6 +64,8 @@ bin/tezgah-mcp
64
64
  bin/tezgah-mcp.py
65
65
  bin/tezgah-migrate
66
66
  bin/tezgah-migrate.py
67
+ bin/tezgah-optimize
68
+ bin/tezgah-optimize.py
67
69
  bin/tezgah-pony
68
70
  bin/tezgah-pony.py
69
71
  bin/tezgah-research
@@ -670,7 +672,9 @@ tests/impacted.py
670
672
  tests/install_cache.py
671
673
  tests/neuter_killers.json
672
674
  tests/neuter_matrix.py
675
+ tests/plan_orphans.py
673
676
  tests/replay_context.py
677
+ tests/replay_optimize.py
674
678
  tests/routing-fixtures.md
675
679
  tests/stop_spec_eval.py
676
680
  tests/support.py
@@ -728,6 +732,7 @@ tests/test_lang_packs.py
728
732
  tests/test_lesson_lineage.py
729
733
  tests/test_lessons.py
730
734
  tests/test_lessons_compile.py
735
+ tests/test_lifecycle_sweep.py
731
736
  tests/test_live_probe.py
732
737
  tests/test_mcp_features.py
733
738
  tests/test_mcp_server.py
@@ -737,6 +742,7 @@ tests/test_neuter_matrix.py
737
742
  tests/test_omp_extension.py
738
743
  tests/test_omp_hook.py
739
744
  tests/test_opencode_plugin.py
745
+ tests/test_optimize.py
740
746
  tests/test_orca.py
741
747
  tests/test_packaging.py
742
748
  tests/test_packaging_scripts.py
package/bin/tezgah-docs CHANGED
@@ -106,7 +106,7 @@ RUN = re.compile(r"((?:`[A-Za-z_][A-Za-z0-9_]*(?:\(\))?`\s*[/,]?\s*){1,5})$")
106
106
  CITE = re.compile(r"`([\w.-]+(?:/[\w.-]+)+|[\w-]+\.\w+):(\d+)(?:-(\d+))?`"
107
107
  r"|`(:(\d+)(?:-(\d+))?)`")
108
108
  # The symbol written after the citation instead of before it, the way a table
109
- # cell reads: `hooks/tezgah_policy.py:784-1100` (`CORE`).
109
+ # cell reads: `hooks/tezgah_policy.py:787-1105` (`CORE`).
110
110
  AFTER = re.compile(r"\s*\(`([A-Za-z_][A-Za-z0-9_]*)(?:\(\))?`\)")
111
111
  # Any `path:line`, backticked or not: what CITE does not match of these (a
112
112
  # bare token, a comma list `x.py:12,40`) is counted as unjudged, so a citation
@@ -405,14 +405,15 @@ def ratchet_drift(unjudged, baseline):
405
405
  # audit reads a symbol: the gate's rules are the `_deny(session_id, "<rule>",
406
406
  # ...)` calls in hooks/tezgah_gate.py, and the Stop rule's triggers are the
407
407
  # classes `_stop_block` returns - including the four `_shape_block` and the six
408
- # `_evidence_block` hand back, and the one `_residual_block` adds at every exit
409
- # that would end the turn, which is why those three functions are read too.
408
+ # `_evidence_text` words for the temporal spec's fold, and the one
409
+ # `_residual_block` adds at every exit that would end the turn, which is why
410
+ # those three functions are read too.
410
411
 
411
412
  GATE = os.path.join(HERE, "hooks", "tezgah_gate.py")
412
413
  STOP = os.path.join(HERE, "hooks", "tezgah_integrity.py")
413
414
  LEDGER = os.path.join(HERE, "docs", "gate.md")
414
415
  LEDGER_COLUMNS = ("rule", "incident", "evidence", "pin")
415
- STOP_FUNCS = ("_stop_block", "_shape_block", "_evidence_block", "_residual_block")
416
+ STOP_FUNCS = ("_stop_block", "_shape_block", "_evidence_text", "_residual_block")
416
417
 
417
418
 
418
419
  def source(path):
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env python3
2
+ """tezgah-optimize - one decision over what verification a change costs.
3
+
4
+ tezgah-optimize next [--files PATH ...] [--cmd COMMAND ...] [--sha SHA] [--json]
5
+ tezgah-optimize record <COMMAND> --seconds N --exit N [--sha SHA]
6
+ tezgah-optimize push [--sha SHA]
7
+
8
+ `next` answers, per candidate command, exactly one of three things. `already
9
+ green` when a recorded row for this repository, this sha and this command exited
10
+ 0 - the analogue of a remote build cache, keyed by the change rather than by a
11
+ file. `defer` when the command's recorded median wall time is at or above
12
+ `TURN_SECONDS`, so it belongs to the merge step. Else `run now`. The candidates
13
+ are the commands the active plan's Acceptance items name in backticks, plus every
14
+ `--cmd`; the plan already carries the commands that prove its work, so the
15
+ planner reads them instead of asking for a list a session would re-derive.
16
+
17
+ The tier map is measured, not declared: with no row for a command the answer is
18
+ `run now`, and the median of its rows moves it. A project that never adopted this
19
+ repository's `AGENTS.md` therefore still gets a batch (plan 109, decision d1v1).
20
+
21
+ `record` appends the row `next` reads, and `push` prints the one push the session
22
+ should make for this change - or refuses while the last recorded push of the same
23
+ sha is inside `PUSH_WINDOW`, because a second push pays the same CI minutes
24
+ again. The rows live in the repository's own store
25
+ (`<repo>/.tezgah/taste/taste.db`, tables `checks` and `pushes`), so a second
26
+ session in the same checkout reads the first one's measurements.
27
+
28
+ The orchestrating session calls these. A user does not, and no hook does.
29
+
30
+ Exit codes: 0 ok, 1 a refusal or a problem, 2 misuse.
31
+ """
32
+ import json
33
+ import os
34
+ import re
35
+ import subprocess
36
+ import sys
37
+ import time
38
+
39
+ HERE = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
40
+ sys.path.insert(0, os.path.join(HERE, "hooks"))
41
+ import tezgah_store as store # noqa: E402
42
+ import tezgah_task as tt # noqa: E402
43
+ from tezgah_paths import project_top # noqa: E402
44
+
45
+ # A command whose recorded median is at or above this belongs to the merge step.
46
+ # The repository's own measured tiers (AGENTS.md) put the impacted set in a turn
47
+ # at about 10-95 s and the whole suite, about 105 s, at the merge step, so the bar
48
+ # is the line between them. One number, not a table: the plan's decision rejected
49
+ # a declared per-repository map (d1v2) because a second project would have to
50
+ # write one before the planner helped it at all.
51
+ TURN_SECONDS = 105.0
52
+ # One push per sha inside this window. A second push of the same change pays the
53
+ # same CI minutes again, which is the cost this command coalesces; the window is
54
+ # long enough for a session's next edit to join the first push.
55
+ PUSH_WINDOW = 300.0
56
+ BACKTICK = re.compile(r"`([^`]+)`")
57
+
58
+
59
+ def repo_root(path):
60
+ """The repository this run is about: git's own answer, else the directory it
61
+ was called in."""
62
+ try:
63
+ out = subprocess.run(["git", "-C", path, "rev-parse", "--show-toplevel"],
64
+ capture_output=True, text=True)
65
+ if out.returncode == 0 and out.stdout.strip():
66
+ return project_top(out.stdout.strip())
67
+ except OSError:
68
+ pass
69
+ return os.path.realpath(path)
70
+
71
+
72
+ def misuse(message):
73
+ print("tezgah-optimize: %s" % message, file=sys.stderr)
74
+ return 2
75
+
76
+
77
+ def problems(items):
78
+ for problem in items:
79
+ print("FAIL %s" % problem)
80
+ return 1
81
+
82
+
83
+ def now():
84
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
85
+
86
+
87
+ def git_out(root, *args):
88
+ """One git command's stdout, or None when git cannot answer - a checkout git
89
+ cannot describe never refuses anything here."""
90
+ try:
91
+ out = subprocess.run(["git", "-C", root] + list(args),
92
+ capture_output=True, text=True)
93
+ except OSError:
94
+ return None
95
+ return out.stdout.strip() if out.returncode == 0 else None
96
+
97
+
98
+ def parse(args, repeats=(), valued=(), flags=()):
99
+ """(({repeat: [...]}, {plain: value}), positionals, problem). A repeatable
100
+ option collects every occurrence, a valued one keeps the last, a flag is
101
+ True, and any other `--x` is a problem."""
102
+ collected = {name: [] for name in repeats}
103
+ plain, pos, i = {}, [], 0
104
+ while i < len(args):
105
+ arg = args[i]
106
+ if arg in repeats or arg in valued:
107
+ if i + 1 >= len(args):
108
+ return None, None, "%s needs a value" % arg
109
+ if arg in repeats:
110
+ collected[arg].append(args[i + 1])
111
+ else:
112
+ plain[arg] = args[i + 1]
113
+ i += 2
114
+ elif arg in flags:
115
+ plain[arg] = True
116
+ i += 1
117
+ elif arg.startswith("--"):
118
+ return None, None, "unknown option %r" % arg
119
+ else:
120
+ pos.append(arg)
121
+ i += 1
122
+ return (collected, plain), pos, None
123
+
124
+
125
+ def store_dir(root):
126
+ return os.path.join(root, ".tezgah", "taste")
127
+
128
+
129
+ def recorded(root, table, where="", args=()):
130
+ """One table's rows in append order; [] when the store is not there yet, so a
131
+ first run answers from nothing instead of failing."""
132
+ try:
133
+ with store.taste(store_dir(root), create=False) as db:
134
+ return store.rows(db, table, where, args) if db else []
135
+ except store.ERRORS:
136
+ return []
137
+
138
+
139
+ def median(values):
140
+ ordered = sorted(values)
141
+ if not ordered:
142
+ return None
143
+ mid = len(ordered) // 2
144
+ return ordered[mid] if len(ordered) % 2 else (ordered[mid - 1] + ordered[mid]) / 2.0
145
+
146
+
147
+ PROGRAM = re.compile(
148
+ r"^(?:python3?|pytest|ruff|mypy|bash|sh|zsh|make|npm|npx|yarn|pnpm|cargo|go|gradle|"
149
+ r"\./gradlew|mvn|dotnet|swift|xcodebuild|git|gh|docker|bundle|rake|nox|tox|"
150
+ r"\./bin/[a-z][a-z-]*|bin/[a-z][a-z-]*)$")
151
+
152
+
153
+ def plan_commands(root):
154
+ """The commands the active plan's Acceptance items name in backticks, in
155
+ order, deduplicated. The plan format asks each item for the command that
156
+ proves it, so this is the list the session would otherwise retype.
157
+
158
+ A backticked token counts when its first word is a program - `python3`, `ruff
159
+ check .`, `pytest`, `package.json`'s `npm test`, or a script under `bin/` - and
160
+ it carries no placeholder. An item's prose is backticked too (`stale`, a file
161
+ path such as `hooks/tezgah_store.py`), and a planner that took every token, or
162
+ every token with a slash in it, would hand the session a path as a check and
163
+ drop `ruff check .` (review of plan 109, F3). A command the rule cannot see is
164
+ what `--cmd` is for."""
165
+ task = tt.active(root, root, held=True)
166
+ if not task:
167
+ return []
168
+ try:
169
+ with open(task["path"], encoding="utf-8") as fh:
170
+ text = fh.read()
171
+ except OSError:
172
+ return []
173
+ out = []
174
+ for item in tt.acceptance_items(text):
175
+ for found in BACKTICK.findall(item["text"]):
176
+ command = " ".join(found.split())
177
+ words = command.split()
178
+ if "<" in command or ">" in command or command in out or not words:
179
+ continue
180
+ if not PROGRAM.match(words[0]):
181
+ continue
182
+ out.append(command)
183
+ return out
184
+
185
+
186
+ def same_commit(one, other):
187
+ """True when two sha spellings name the same commit. Git prints a short sha
188
+ that grows as the repository does, so a row recorded with the full one would
189
+ never match the short one `next` reads (review of plan 109)."""
190
+ one, other = str(one or ""), str(other or "")
191
+ return bool(one) and bool(other) and (one.startswith(other) or other.startswith(one))
192
+
193
+
194
+ def decide(root, command, sha):
195
+ """(verdict, why) for one command: the three answers `next` gives.
196
+
197
+ A green row for this change outranks the tier, because running it again would
198
+ add nothing - but only while the working tree is clean. Inside a turn the
199
+ edits are usually uncommitted, so HEAD does not move, and a row for that sha
200
+ can describe a tree this session has already changed: on a dirty tree the
201
+ answer is `run now` whatever the rows say (review of plan 109, F2)."""
202
+ history = [row for row in recorded(root, "checks")
203
+ if row.get("command") == command]
204
+ dirty = bool(git_out(root, "status", "--porcelain")) if sha else False
205
+ green = [row for row in history
206
+ if same_commit(row.get("sha"), sha) and not int(row.get("exit") or 0)
207
+ and row.get("tree") == "clean"]
208
+ if green and not dirty:
209
+ return "already green", "recorded exit 0 for %s at %s" % (
210
+ sha, green[-1].get("ts") or "?")
211
+ seconds = [float(row["seconds"]) for row in history
212
+ if isinstance(row.get("seconds"), (int, float))]
213
+ middle = median(seconds)
214
+ if green:
215
+ return "run now", ("a green row exists for %s, but the working tree holds "
216
+ "uncommitted edits, so that pass is not this change" % sha)
217
+ if middle is None:
218
+ return "run now", "nothing measured for it yet"
219
+ if middle >= TURN_SECONDS:
220
+ return "defer", "its measured median is %.1fs, at or above the %.0fs in-turn bar" % (
221
+ middle, TURN_SECONDS)
222
+ return "run now", "its measured median is %.1fs" % middle
223
+
224
+
225
+ def cmd_next(args):
226
+ parsed, pos, bad = parse(args, repeats=("--files", "--cmd"), valued=("--sha",),
227
+ flags=("--json",))
228
+ if bad or pos:
229
+ return misuse(bad or "next takes options only (%r)" % pos[0])
230
+ collected, plain = parsed
231
+ root = repo_root(os.getcwd())
232
+ sha = plain.get("--sha") or git_out(root, "rev-parse", "--short", "HEAD") or ""
233
+ commands = []
234
+ for command in list(collected["--cmd"]) + plan_commands(root):
235
+ if command not in commands:
236
+ commands.append(command)
237
+ rows = [{"command": command, "verdict": decide(root, command, sha)[0],
238
+ "why": decide(root, command, sha)[1]} for command in commands]
239
+ payload = {"repo": root, "sha": sha, "files": collected["--files"],
240
+ "commands": rows, "turn_seconds": TURN_SECONDS}
241
+ if plain.get("--json"):
242
+ print(json.dumps(payload, indent=2, sort_keys=True))
243
+ return 0
244
+ print("repo %s at %s" % (root, sha or "(no commit yet)"))
245
+ if collected["--files"]:
246
+ print(" changed: %s" % ", ".join(collected["--files"]))
247
+ for row in rows:
248
+ print(" %-13s %s (%s)" % (row["verdict"], row["command"], row["why"]))
249
+ if not rows:
250
+ print("no candidate command: name one with --cmd, or give the active plan "
251
+ "an Acceptance item whose proof names one in backticks")
252
+ return 0
253
+
254
+
255
+ def cmd_record(args):
256
+ parsed, pos, bad = parse(args, valued=("--seconds", "--exit", "--sha"))
257
+ if bad or len(pos) != 1:
258
+ return misuse(bad or "record takes one COMMAND")
259
+ _collected, plain = parsed
260
+ command = " ".join(pos[0].split())
261
+ if not command:
262
+ return misuse("record takes the command it measured")
263
+ for name in ("--seconds", "--exit"):
264
+ if name not in plain:
265
+ return misuse("record needs %s" % name)
266
+ try:
267
+ seconds, code = float(plain["--seconds"]), int(plain["--exit"])
268
+ except ValueError:
269
+ return misuse("--seconds takes a wall time and --exit an exit code")
270
+ if seconds < 0:
271
+ return misuse("--seconds takes a wall time, not a negative one")
272
+ root = repo_root(os.getcwd())
273
+ sha = plain.get("--sha") or git_out(root, "rev-parse", "--short", "HEAD") or ""
274
+ row = {"repo": root, "sha": sha, "command": command, "seconds": seconds,
275
+ "exit": code, "ts": now(),
276
+ # the tree the run describes: a green row recorded over uncommitted
277
+ # edits describes a tree nobody else has, and `decide` will not spend
278
+ # it as a pass (review of plan 109, F2)
279
+ "tree": "clean" if not git_out(root, "status", "--porcelain") else "dirty"}
280
+ try:
281
+ with store.taste(store_dir(root)) as db:
282
+ store.append(db, "checks", row)
283
+ except store.ERRORS as exc:
284
+ return problems(["cannot record in %s: %s" % (store_dir(root), exc)])
285
+ print("recorded: %s %s %.1fs exit %d" % (sha or "(no commit)", command,
286
+ seconds, code))
287
+ return 0
288
+
289
+
290
+ def cmd_push(args):
291
+ parsed, pos, bad = parse(args, valued=("--sha",))
292
+ if bad or pos:
293
+ return misuse(bad or "push takes [--sha SHA]")
294
+ _collected, plain = parsed
295
+ root = repo_root(os.getcwd())
296
+ sha = plain.get("--sha") or git_out(root, "rev-parse", "--short", "HEAD") or ""
297
+ if not sha:
298
+ return problems(["%s has no commit to push" % root])
299
+ name = git_out(root, "symbolic-ref", "-q", "--short", "HEAD")
300
+ if not name:
301
+ return problems(["%s is on a detached HEAD: name the branch to push (check "
302
+ "it out, or push an explicit ref)" % root])
303
+ at = time.time()
304
+ for row in reversed(recorded(root, "pushes")):
305
+ if row.get("sha") != sha or not isinstance(row.get("at"), (int, float)):
306
+ continue
307
+ waited = at - float(row["at"])
308
+ if waited < PUSH_WINDOW:
309
+ return problems([
310
+ "%s is already pushed and CI is paying for it: one push per change "
311
+ "is the point. Add the next edit to this one, or push again in %d s "
312
+ "(`tezgah-optimize push`)" % (sha, int(PUSH_WINDOW - waited))])
313
+ try:
314
+ with store.taste(store_dir(root)) as db:
315
+ store.append(db, "pushes", {"repo": root, "sha": sha, "branch": name,
316
+ "at": at})
317
+ except store.ERRORS as exc:
318
+ return problems(["cannot record the push in %s: %s" % (store_dir(root), exc)])
319
+ print("push: git push -u origin %s - one push for %s" % (name, sha))
320
+ return 0
321
+
322
+
323
+ def main(argv):
324
+ if len(argv) < 2:
325
+ print(__doc__, file=sys.stderr)
326
+ return 2
327
+ cmd, rest = argv[1], argv[2:]
328
+ if cmd == "next":
329
+ return cmd_next(rest)
330
+ if cmd == "record":
331
+ return cmd_record(rest)
332
+ if cmd == "push":
333
+ return cmd_push(rest)
334
+ return misuse("unknown command %r (next, record, push)" % cmd)
335
+
336
+
337
+ if __name__ == "__main__":
338
+ sys.exit(main(sys.argv))
@@ -212,8 +212,8 @@ def cmd_init(args):
212
212
  "candidate and the number it returns")
213
213
  evaluator = arena.get("--evaluator", "").strip()
214
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))):
215
+ or not os.path.isfile(os.path.join(
216
+ tp.work_tree(repo_root(os.getcwd())), evaluator))):
217
217
  return misuse("--evaluator %r is not a file under the repository" % evaluator)
218
218
  elif arena:
219
219
  return misuse("--arena, --budget, --evaluator and --metric belong to "
package/bin/tezgah-route CHANGED
@@ -5,7 +5,8 @@
5
5
  tezgah-route --check the table's age and refresh flags
6
6
  tezgah-route --refresh re-read prices/ids (OpenRouter),
7
7
  resolve opencode selectors
8
- tezgah-route --mode auto|anthropic|any omp's family, then re-apply omp
8
+ tezgah-route --mode auto|anthropic|zai|any|openai|off
9
+ omp's family, then re-apply omp
9
10
  tezgah-route --health probe the other funded families;
10
11
  one at a limit leaves the chain
11
12
  until its reset; re-apply omp
@@ -151,7 +152,10 @@ def main(argv):
151
152
  print("could not write %s" % tm.OVERLAY, file=sys.stderr)
152
153
  return 1
153
154
  print("omp mode: %s" % args.mode)
154
- print(tm.apply_omp() or "omp not answering; the mode applies at the next setup")
155
+ # the user named the family: it reaches every routed agent, hand-set
156
+ # selectors included (plan 106); the automatic re-apply leaves those alone
157
+ print(tm.apply_omp(take=True)
158
+ or "omp not answering; the mode applies at the next setup")
155
159
  return 0
156
160
  if not brief:
157
161
  print("usage: tezgah-route \"<brief>\" [--phase P] [--json] | --check | --refresh "
package/bin/tezgah-setup CHANGED
@@ -2922,8 +2922,22 @@ def _run_bounded(argv, timeout):
2922
2922
  the group first: the new session puts the installer outside the terminal's
2923
2923
  process group, so the interrupt no longer reaches it and it would be left
2924
2924
  running as an orphan."""
2925
- proc = subprocess.Popen(argv, start_new_session=True)
2925
+ # SIGINT is held across Popen and delivered inside the `try` below: one that
2926
+ # landed as Popen returned skipped the group kill (plan 103). The child gets
2927
+ # the old mask back before it execs, so the installer sees no change.
2928
+ # read without changing it, so a KeyboardInterrupt from the block call
2929
+ # itself still restores the mask (review of plan 103)
2930
+ old = signal.pthread_sigmask(signal.SIG_BLOCK, ())
2926
2931
  try:
2932
+ signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGINT})
2933
+ proc = subprocess.Popen(argv, start_new_session=True,
2934
+ preexec_fn=lambda: signal.pthread_sigmask(
2935
+ signal.SIG_SETMASK, old))
2936
+ except BaseException:
2937
+ signal.pthread_sigmask(signal.SIG_SETMASK, old)
2938
+ raise
2939
+ try:
2940
+ signal.pthread_sigmask(signal.SIG_SETMASK, old)
2927
2941
  return proc.wait(timeout=timeout)
2928
2942
  except BaseException:
2929
2943
  try: