@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 +35 -1
- package/MANIFEST +6 -0
- package/bin/tezgah-docs +5 -4
- package/bin/tezgah-optimize +338 -0
- package/bin/tezgah-research +2 -2
- package/bin/tezgah-route +6 -2
- package/bin/tezgah-setup +15 -1
- package/bin/tezgah-task +239 -27
- package/bin/tezgah-taste +3 -1
- package/docs/architecture.md +8 -0
- package/docs/clarity-baseline.json +1 -1
- package/docs/environment.md +1 -1
- package/docs/evidence.md +23 -20
- package/docs/gate.md +19 -14
- package/docs/models.md +9 -2
- package/docs/operations.md +9 -5
- package/docs/testing.md +18 -14
- package/hooks/lang/en.json +3 -0
- package/hooks/lang/tr.json +3 -0
- package/hooks/tezgah_context.py +31 -19
- package/hooks/tezgah_gate.py +75 -36
- package/hooks/tezgah_integrity.py +90 -134
- package/hooks/tezgah_models.py +62 -26
- package/hooks/tezgah_paths.py +84 -28
- package/hooks/tezgah_policy.py +10 -4
- package/hooks/tezgah_research.py +14 -8
- package/hooks/tezgah_stopspec.py +36 -83
- package/hooks/tezgah_store.py +7 -0
- package/hooks/tezgah_task.py +104 -35
- package/hosts/opencode/plugins/tezgah.js +136 -0
- package/output-styles/tezgah.md +5 -2
- package/package.json +1 -1
- package/skills/plan-add/SKILL.md +8 -4
- package/skills/tezgah-contract/SKILL.md +14 -0
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.
|
|
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:
|
|
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
|
-
# `
|
|
409
|
-
# that would end the turn, which is why
|
|
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", "
|
|
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))
|
package/bin/tezgah-research
CHANGED
|
@@ -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(
|
|
216
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|